mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Implement infinite scroll for Matches and Answers
Add pagination support to stores and update list screens to load data in batches for improved performance with large datasets.
This commit is contained in:
1 parent
ae91602921
commit
bb2ce5a6a6
8 files changed
+1853
-1395
No files matched your search
+663
-539
File diff suppressed because it is too large.
Load diff
@@ -1,386 +1,518 @@
|
|||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
SectionList,
|
SectionList,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Platform,
|
Platform,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store";
|
import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store";
|
||||||
import type { ResponseWithQuestion } from "../../src/store";
|
import type { ResponseWithQuestion } from "../../src/store";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState, useEffect } from "react";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter, useLocalSearchParams } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
import Animated, {
|
||||||
import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
FadeIn,
|
||||||
import { useAmbientOrbAnimation } from "../../src/hooks";
|
FadeInUp,
|
||||||
import { colors, spacing, typography, radius } from "../../src/theme";
|
useSharedValue,
|
||||||
import { ResponseCard } from "../../src/components/responses/ResponseCard";
|
useAnimatedScrollHandler,
|
||||||
import { EditResponseSheet } from "../../src/components/responses/EditResponseSheet";
|
useAnimatedStyle,
|
||||||
|
interpolate,
|
||||||
// Premium color palette
|
Extrapolation,
|
||||||
const ACCENT = colors.premium.gold;
|
} from "react-native-reanimated";
|
||||||
const ACCENT_RGBA = "rgba(212, 175, 55, ";
|
import { BlurView } from "expo-blur";
|
||||||
const ROSE = colors.premium.rose;
|
import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||||
const ROSE_RGBA = "rgba(232, 164, 174, ";
|
import { useAmbientOrbAnimation } from "../../src/hooks";
|
||||||
|
import { colors, spacing, typography, radius } from "../../src/theme";
|
||||||
const MAX_CONTENT_WIDTH = 500;
|
import { ResponseCard } from "../../src/components/responses/ResponseCard";
|
||||||
|
import { EditResponseSheet } from "../../src/components/responses/EditResponseSheet";
|
||||||
type GroupByOption = "pack" | "date" | "answer";
|
|
||||||
|
// Premium color palette
|
||||||
export default function MyAnswersScreen() {
|
const ACCENT = colors.premium.gold;
|
||||||
const { responses, isLoading, groupBy, fetchResponses, setGroupBy } = useResponsesStore();
|
const ACCENT_RGBA = "rgba(212, 175, 55, ";
|
||||||
const { user } = useAuthStore();
|
const ROSE = colors.premium.rose;
|
||||||
const router = useRouter();
|
const ROSE_RGBA = "rgba(232, 164, 174, ";
|
||||||
const { width } = useWindowDimensions();
|
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
|
const NAV_BAR_HEIGHT = 44;
|
||||||
// State for edit sheet
|
const STATUS_BAR_HEIGHT = 60;
|
||||||
const [selectedResponse, setSelectedResponse] = useState<ResponseWithQuestion | null>(null);
|
const HEADER_SCROLL_DISTANCE = 100;
|
||||||
const [isEditSheetVisible, setIsEditSheetVisible] = useState(false);
|
|
||||||
|
const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
|
||||||
// Ambient orb breathing animations
|
|
||||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
type GroupByOption = "pack" | "date" | "answer";
|
||||||
|
|
||||||
// Fetch responses on focus
|
export default function MyAnswersScreen() {
|
||||||
useFocusEffect(
|
const { responses, isLoading, isLoadingMore, hasMore, groupBy, fetchResponses, setGroupBy, totalCount } = useResponsesStore();
|
||||||
useCallback(() => {
|
const { user } = useAuthStore();
|
||||||
fetchResponses();
|
const router = useRouter();
|
||||||
}, [])
|
const params = useLocalSearchParams();
|
||||||
);
|
const { width } = useWindowDimensions();
|
||||||
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
const sections = groupResponses(responses, groupBy);
|
|
||||||
|
const handleBack = () => {
|
||||||
const handleEditPress = (response: ResponseWithQuestion) => {
|
const returnTo = (params.returnTo as string) || "/(app)/swipe";
|
||||||
setSelectedResponse(response);
|
router.push(returnTo as any);
|
||||||
setIsEditSheetVisible(true);
|
};
|
||||||
};
|
|
||||||
|
// State for edit sheet
|
||||||
const handleEditClose = () => {
|
const [selectedResponse, setSelectedResponse] = useState<ResponseWithQuestion | null>(null);
|
||||||
setIsEditSheetVisible(false);
|
const [isEditSheetVisible, setIsEditSheetVisible] = useState(false);
|
||||||
setSelectedResponse(null);
|
|
||||||
};
|
// Scroll animation
|
||||||
|
const scrollY = useSharedValue(0);
|
||||||
const handleEditSuccess = () => {
|
const scrollHandler = useAnimatedScrollHandler({
|
||||||
// Refetch responses to get updated data
|
onScroll: (event) => {
|
||||||
fetchResponses();
|
scrollY.value = event.contentOffset.y;
|
||||||
};
|
},
|
||||||
|
});
|
||||||
const renderSectionHeader = ({ section }: { section: { title: string } }) => (
|
|
||||||
<View style={styles.sectionHeader}>
|
const heroStyle = useAnimatedStyle(() => {
|
||||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
const opacity = interpolate(
|
||||||
</View>
|
scrollY.value,
|
||||||
);
|
[0, HEADER_SCROLL_DISTANCE * 0.7],
|
||||||
|
[1, 0],
|
||||||
const renderItem = ({ item, index }: { item: ResponseWithQuestion; index: number }) => (
|
Extrapolation.CLAMP
|
||||||
<ResponseCard
|
);
|
||||||
response={item}
|
const scale = interpolate(
|
||||||
index={index}
|
scrollY.value,
|
||||||
onEditPress={() => handleEditPress(item)}
|
[0, HEADER_SCROLL_DISTANCE],
|
||||||
onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
|
[1, 0.95],
|
||||||
/>
|
Extrapolation.CLAMP
|
||||||
);
|
);
|
||||||
|
return { opacity, transform: [{ scale }] };
|
||||||
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
|
});
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
|
const compactHeaderStyle = useAnimatedStyle(() => {
|
||||||
onPress={() => setGroupBy(option)}
|
const opacity = interpolate(
|
||||||
>
|
scrollY.value,
|
||||||
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}>
|
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
|
||||||
{label}
|
[0, 1],
|
||||||
</Text>
|
Extrapolation.CLAMP
|
||||||
</TouchableOpacity>
|
);
|
||||||
);
|
return { opacity };
|
||||||
|
});
|
||||||
if (!user) {
|
|
||||||
return (
|
const navBarBackgroundStyle = useAnimatedStyle(() => {
|
||||||
<GradientBackground>
|
const opacity = interpolate(
|
||||||
<View style={styles.loadingContainer}>
|
scrollY.value,
|
||||||
<ActivityIndicator color={colors.primary} size="large" />
|
[0, HEADER_SCROLL_DISTANCE * 0.8],
|
||||||
</View>
|
[0, 1],
|
||||||
</GradientBackground>
|
Extrapolation.CLAMP
|
||||||
);
|
);
|
||||||
}
|
return { opacity };
|
||||||
|
});
|
||||||
return (
|
|
||||||
<GradientBackground>
|
// Ambient orb breathing animations
|
||||||
{/* Ambient Orbs */}
|
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||||
<Animated.View
|
|
||||||
style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]}
|
// Fetch responses on focus
|
||||||
pointerEvents="none"
|
useFocusEffect(
|
||||||
>
|
useCallback(() => {
|
||||||
<LinearGradient
|
fetchResponses(true);
|
||||||
colors={[colors.premium.goldGlow, "transparent"]}
|
}, [])
|
||||||
style={styles.orbGradient}
|
);
|
||||||
start={{ x: 0.5, y: 0.5 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
const handleLoadMore = useCallback(() => {
|
||||||
/>
|
if (!isLoading && !isLoadingMore && hasMore) {
|
||||||
</Animated.View>
|
fetchResponses(false);
|
||||||
<Animated.View
|
}
|
||||||
style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]}
|
}, [isLoading, isLoadingMore, hasMore]);
|
||||||
pointerEvents="none"
|
|
||||||
>
|
const sections = groupResponses(responses, groupBy);
|
||||||
<LinearGradient
|
|
||||||
colors={[`${ROSE_RGBA}0.2)`, "transparent"]}
|
const handleEditPress = (response: ResponseWithQuestion) => {
|
||||||
style={styles.orbGradient}
|
setSelectedResponse(response);
|
||||||
start={{ x: 0.5, y: 0.5 }}
|
setIsEditSheetVisible(true);
|
||||||
end={{ x: 0, y: 0 }}
|
};
|
||||||
/>
|
|
||||||
</Animated.View>
|
const handleEditClose = () => {
|
||||||
|
setIsEditSheetVisible(false);
|
||||||
<View style={styles.container}>
|
setSelectedResponse(null);
|
||||||
{/* Header */}
|
};
|
||||||
<Animated.View
|
|
||||||
entering={FadeIn.duration(400)}
|
const handleEditSuccess = () => {
|
||||||
style={[styles.header, isWideScreen && styles.headerWide]}
|
// Refetch responses to get updated data
|
||||||
>
|
fetchResponses(true);
|
||||||
{/* Back button */}
|
};
|
||||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
|
||||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
const renderSectionHeader = ({ section }: { section: any }) => (
|
||||||
</TouchableOpacity>
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||||
<View style={styles.headerContent}>
|
</View>
|
||||||
<Text style={styles.headerLabel}>REVIEW</Text>
|
);
|
||||||
<Text style={styles.headerTitle}>My Answers</Text>
|
|
||||||
<DecorativeSeparator variant="gold" />
|
const renderItem = ({ item, index }: { item: any; index: number }) => (
|
||||||
|
<ResponseCard
|
||||||
{/* Count badge */}
|
response={item}
|
||||||
<View style={styles.countBadgePremium}>
|
index={index}
|
||||||
<Ionicons name="checkmark-circle" size={12} color={ACCENT} />
|
onEditPress={() => handleEditPress(item)}
|
||||||
<Text style={styles.countTextPremium}>
|
onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
|
||||||
{responses.length} {responses.length === 1 ? "ANSWER" : "ANSWERS"}
|
/>
|
||||||
</Text>
|
);
|
||||||
</View>
|
|
||||||
</View>
|
const renderFooter = () => {
|
||||||
|
if (!isLoadingMore) return null;
|
||||||
{/* Group by buttons */}
|
return (
|
||||||
<View style={styles.groupByContainer}>
|
<View style={styles.footerLoader}>
|
||||||
<GroupByButton option="pack" label="Pack" />
|
<ActivityIndicator color={colors.primary} size="small" />
|
||||||
<GroupByButton option="date" label="Date" />
|
</View>
|
||||||
<GroupByButton option="answer" label="Answer" />
|
);
|
||||||
</View>
|
};
|
||||||
</Animated.View>
|
|
||||||
|
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
|
||||||
{isLoading && responses.length === 0 ? (
|
<TouchableOpacity
|
||||||
<View style={styles.loadingContainer}>
|
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
|
||||||
<ActivityIndicator color={colors.primary} size="large" />
|
onPress={() => setGroupBy(option)}
|
||||||
</View>
|
>
|
||||||
) : (
|
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}>
|
||||||
<SectionList
|
{label}
|
||||||
sections={sections}
|
</Text>
|
||||||
renderItem={renderItem}
|
</TouchableOpacity>
|
||||||
renderSectionHeader={renderSectionHeader}
|
);
|
||||||
keyExtractor={(item) => item.id}
|
|
||||||
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
if (!user) {
|
||||||
showsVerticalScrollIndicator={false}
|
return (
|
||||||
stickySectionHeadersEnabled={false}
|
<GradientBackground>
|
||||||
refreshControl={
|
<View style={styles.loadingContainer}>
|
||||||
<RefreshControl
|
<ActivityIndicator color={colors.primary} size="large" />
|
||||||
refreshing={isLoading}
|
</View>
|
||||||
onRefresh={fetchResponses}
|
</GradientBackground>
|
||||||
tintColor={colors.primary}
|
);
|
||||||
colors={[colors.primary]}
|
}
|
||||||
/>
|
|
||||||
}
|
return (
|
||||||
ListEmptyComponent={
|
<GradientBackground>
|
||||||
<Animated.View
|
{/* Ambient Orbs */}
|
||||||
entering={FadeInUp.duration(600).springify()}
|
<Animated.View
|
||||||
style={styles.emptyContent}
|
style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]}
|
||||||
>
|
pointerEvents="none"
|
||||||
<View style={styles.emptyIcon}>
|
>
|
||||||
<Ionicons name="help-circle-outline" size={48} color={ACCENT} />
|
<LinearGradient
|
||||||
</View>
|
colors={[colors.premium.goldGlow, "transparent"]}
|
||||||
<Text style={styles.emptyTitle}>No Answers Yet</Text>
|
style={styles.orbGradient}
|
||||||
<Text style={styles.emptyDescription}>
|
start={{ x: 0.5, y: 0.5 }}
|
||||||
Start swiping on questions to build your answer history. You can
|
end={{ x: 1, y: 1 }}
|
||||||
come back here to review and change your answers anytime.
|
/>
|
||||||
</Text>
|
</Animated.View>
|
||||||
<GlassButton
|
<Animated.View
|
||||||
onPress={() => router.push("/(app)/swipe")}
|
style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]}
|
||||||
style={{ marginTop: spacing.lg }}
|
pointerEvents="none"
|
||||||
>
|
>
|
||||||
Start Swiping
|
<LinearGradient
|
||||||
</GlassButton>
|
colors={[`${ROSE_RGBA}0.2)`, "transparent"]}
|
||||||
</Animated.View>
|
style={styles.orbGradient}
|
||||||
}
|
start={{ x: 0.5, y: 0.5 }}
|
||||||
/>
|
end={{ x: 0, y: 0 }}
|
||||||
)}
|
/>
|
||||||
</View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Edit Response Sheet */}
|
<View style={styles.container}>
|
||||||
<EditResponseSheet
|
{/* Fixed Nav Bar */}
|
||||||
visible={isEditSheetVisible}
|
<View style={styles.navBar}>
|
||||||
response={selectedResponse}
|
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
|
||||||
onClose={handleEditClose}
|
{Platform.OS === "ios" ? (
|
||||||
onSuccess={handleEditSuccess}
|
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
/>
|
) : (
|
||||||
</GradientBackground>
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
|
||||||
);
|
)}
|
||||||
}
|
</Animated.View>
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
<TouchableOpacity style={styles.navBarBackButton} onPress={handleBack}>
|
||||||
container: {
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
flex: 1,
|
</TouchableOpacity>
|
||||||
},
|
|
||||||
loadingContainer: {
|
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
|
||||||
flex: 1,
|
My Answers
|
||||||
justifyContent: "center",
|
</Animated.Text>
|
||||||
alignItems: "center",
|
</View>
|
||||||
},
|
|
||||||
// Ambient orbs
|
{isLoading && responses.length === 0 ? (
|
||||||
ambientOrb: {
|
<View style={styles.loadingContainer}>
|
||||||
position: "absolute",
|
<ActivityIndicator color={colors.primary} size="large" />
|
||||||
width: 300,
|
</View>
|
||||||
height: 300,
|
) : (
|
||||||
borderRadius: 150,
|
<AnimatedSectionList
|
||||||
},
|
sections={sections}
|
||||||
orbTopRight: {
|
renderItem={renderItem}
|
||||||
top: 60,
|
renderSectionHeader={renderSectionHeader}
|
||||||
right: -40,
|
keyExtractor={(item: any) => item.id}
|
||||||
},
|
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
||||||
orbBottomLeft: {
|
showsVerticalScrollIndicator={false}
|
||||||
bottom: 180,
|
stickySectionHeadersEnabled={false}
|
||||||
left: -40,
|
onScroll={scrollHandler}
|
||||||
},
|
scrollEventThrottle={16}
|
||||||
orbGradient: {
|
refreshControl={
|
||||||
width: "100%",
|
<RefreshControl
|
||||||
height: "100%",
|
refreshing={isLoading}
|
||||||
borderRadius: 150,
|
onRefresh={() => fetchResponses(true)}
|
||||||
},
|
tintColor={colors.primary}
|
||||||
// Header
|
colors={[colors.primary]}
|
||||||
header: {
|
progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT}
|
||||||
paddingTop: 60,
|
/>
|
||||||
paddingHorizontal: spacing.lg,
|
}
|
||||||
paddingBottom: spacing.md,
|
onEndReached={handleLoadMore}
|
||||||
},
|
onEndReachedThreshold={2}
|
||||||
headerWide: {
|
ListFooterComponent={renderFooter}
|
||||||
alignSelf: "center",
|
ListHeaderComponent={
|
||||||
width: "100%",
|
<Animated.View
|
||||||
maxWidth: MAX_CONTENT_WIDTH,
|
entering={FadeIn.duration(400)}
|
||||||
},
|
style={[styles.header, isWideScreen && styles.headerWide, heroStyle]}
|
||||||
backButton: {
|
>
|
||||||
position: "absolute",
|
<View style={styles.headerContent}>
|
||||||
top: 60,
|
<Text style={styles.headerLabel}>REVIEW</Text>
|
||||||
left: spacing.md,
|
<Text style={styles.headerTitle}>My Answers</Text>
|
||||||
zIndex: 10,
|
<DecorativeSeparator variant="gold" />
|
||||||
width: 40,
|
|
||||||
height: 40,
|
{/* Count badge */}
|
||||||
borderRadius: 20,
|
<View style={styles.countBadgePremium}>
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
<Ionicons name="checkmark-circle" size={12} color={ACCENT} />
|
||||||
justifyContent: "center",
|
<Text style={styles.countTextPremium}>
|
||||||
alignItems: "center",
|
{totalCount ?? responses.length} {(totalCount ?? responses.length) === 1 ? "ANSWER" : "ANSWERS"}
|
||||||
},
|
</Text>
|
||||||
headerContent: {
|
</View>
|
||||||
alignItems: "center",
|
</View>
|
||||||
},
|
|
||||||
headerLabel: {
|
{/* Group by buttons */}
|
||||||
...typography.caption2,
|
<View style={styles.groupByContainer}>
|
||||||
fontWeight: "600",
|
<GroupByButton option="pack" label="Pack" />
|
||||||
letterSpacing: 3,
|
<GroupByButton option="date" label="Date" />
|
||||||
color: ACCENT,
|
<GroupByButton option="answer" label="Answer" />
|
||||||
marginBottom: spacing.xs,
|
</View>
|
||||||
},
|
</Animated.View>
|
||||||
headerTitle: {
|
}
|
||||||
...typography.largeTitle,
|
ListEmptyComponent={
|
||||||
color: colors.text,
|
<Animated.View
|
||||||
textAlign: "center",
|
entering={FadeInUp.duration(600).springify()}
|
||||||
},
|
style={styles.emptyContent}
|
||||||
countBadgePremium: {
|
>
|
||||||
flexDirection: "row",
|
<View style={styles.emptyIcon}>
|
||||||
alignItems: "center",
|
<Ionicons name="help-circle-outline" size={48} color={ACCENT} />
|
||||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
</View>
|
||||||
paddingHorizontal: spacing.md,
|
<Text style={styles.emptyTitle}>No Answers Yet</Text>
|
||||||
paddingVertical: spacing.sm,
|
<Text style={styles.emptyDescription}>
|
||||||
borderRadius: radius.full,
|
Start swiping on questions to build your answer history. You can
|
||||||
borderWidth: 1,
|
come back here to review and change your answers anytime.
|
||||||
borderColor: `${ACCENT_RGBA}0.2)`,
|
</Text>
|
||||||
gap: spacing.xs,
|
<GlassButton
|
||||||
},
|
onPress={() => router.push("/(app)/swipe")}
|
||||||
countTextPremium: {
|
style={{ marginTop: spacing.lg }}
|
||||||
...typography.caption2,
|
>
|
||||||
fontWeight: "600",
|
Start Swiping
|
||||||
letterSpacing: 2,
|
</GlassButton>
|
||||||
color: ACCENT,
|
</Animated.View>
|
||||||
},
|
}
|
||||||
// Group by buttons
|
/>
|
||||||
groupByContainer: {
|
)}
|
||||||
flexDirection: "row",
|
</View>
|
||||||
justifyContent: "center",
|
|
||||||
gap: spacing.sm,
|
{/* Edit Response Sheet */}
|
||||||
marginTop: spacing.md,
|
<EditResponseSheet
|
||||||
},
|
visible={isEditSheetVisible}
|
||||||
groupByButton: {
|
response={selectedResponse}
|
||||||
paddingHorizontal: spacing.md,
|
onClose={handleEditClose}
|
||||||
paddingVertical: spacing.sm,
|
onSuccess={handleEditSuccess}
|
||||||
borderRadius: radius.full,
|
/>
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
</GradientBackground>
|
||||||
borderWidth: 1,
|
);
|
||||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
}
|
||||||
},
|
|
||||||
groupByButtonActive: {
|
const styles = StyleSheet.create({
|
||||||
backgroundColor: `${ACCENT_RGBA}0.15)`,
|
container: {
|
||||||
borderColor: `${ACCENT_RGBA}0.3)`,
|
flex: 1,
|
||||||
},
|
},
|
||||||
groupByText: {
|
loadingContainer: {
|
||||||
...typography.caption1,
|
flex: 1,
|
||||||
color: colors.textSecondary,
|
justifyContent: "center",
|
||||||
fontWeight: "500",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
groupByTextActive: {
|
// Ambient orbs
|
||||||
color: ACCENT,
|
ambientOrb: {
|
||||||
fontWeight: "600",
|
position: "absolute",
|
||||||
},
|
width: 300,
|
||||||
// Section header
|
height: 300,
|
||||||
sectionHeader: {
|
borderRadius: 150,
|
||||||
paddingVertical: spacing.sm,
|
},
|
||||||
paddingHorizontal: spacing.xs,
|
orbTopRight: {
|
||||||
marginTop: spacing.md,
|
top: 60,
|
||||||
marginBottom: spacing.xs,
|
right: -40,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
orbBottomLeft: {
|
||||||
...typography.title3,
|
bottom: 180,
|
||||||
color: colors.text,
|
left: -40,
|
||||||
},
|
},
|
||||||
// List
|
orbGradient: {
|
||||||
list: {
|
width: "100%",
|
||||||
padding: spacing.lg,
|
height: "100%",
|
||||||
paddingTop: spacing.sm,
|
borderRadius: 150,
|
||||||
paddingBottom: Platform.OS === "ios" ? 120 : 100,
|
},
|
||||||
},
|
// Fixed Nav Bar
|
||||||
listWide: {
|
navBar: {
|
||||||
alignSelf: "center",
|
position: "absolute",
|
||||||
width: "100%",
|
top: 0,
|
||||||
maxWidth: MAX_CONTENT_WIDTH,
|
left: 0,
|
||||||
},
|
right: 0,
|
||||||
// Empty state
|
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
|
||||||
emptyContent: {
|
flexDirection: "row",
|
||||||
width: "100%",
|
alignItems: "center",
|
||||||
alignItems: "center",
|
justifyContent: "center",
|
||||||
paddingHorizontal: spacing.md,
|
paddingTop: STATUS_BAR_HEIGHT - 10,
|
||||||
paddingVertical: spacing.xxl,
|
paddingHorizontal: spacing.md,
|
||||||
},
|
zIndex: 100,
|
||||||
emptyIcon: {
|
},
|
||||||
width: 80,
|
navBarBackground: {
|
||||||
height: 80,
|
...StyleSheet.absoluteFillObject,
|
||||||
borderRadius: 40,
|
borderBottomWidth: 1,
|
||||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
borderBottomColor: 'rgba(212, 175, 55, 0.15)', // Gold tint
|
||||||
justifyContent: "center",
|
overflow: "hidden",
|
||||||
alignItems: "center",
|
},
|
||||||
marginBottom: spacing.lg,
|
navBarBackButton: {
|
||||||
},
|
position: "absolute",
|
||||||
emptyTitle: {
|
top: STATUS_BAR_HEIGHT - 5,
|
||||||
...typography.title2,
|
left: spacing.md,
|
||||||
color: colors.text,
|
zIndex: 10,
|
||||||
marginBottom: spacing.sm,
|
width: 36,
|
||||||
},
|
height: 36,
|
||||||
emptyDescription: {
|
borderRadius: 18,
|
||||||
...typography.body,
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
color: colors.textSecondary,
|
justifyContent: "center",
|
||||||
textAlign: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: spacing.md,
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
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 { useLocalSearchParams, router, useFocusEffect } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -186,15 +186,6 @@ export default function SwipeScreen() {
|
|||||||
}
|
}
|
||||||
}, [enabledPackIds]);
|
}, [enabledPackIds]);
|
||||||
|
|
||||||
// Fisher-Yates shuffle for stable randomization
|
|
||||||
const shuffleArray = <T,>(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 () => {
|
const fetchQuestions = async () => {
|
||||||
// Race protection: increment request ID and track current request
|
// Race protection: increment request ID and track current request
|
||||||
@@ -231,26 +222,10 @@ export default function SwipeScreen() {
|
|||||||
setGapInfo(null);
|
setGapInfo(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// First shuffle to randomize, then stable sort by priority scores
|
// Server provides ordering (daily seeded random with priority)
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only update state if still the current request
|
// Only update state if still the current request
|
||||||
if (currentRequestId === fetchRequestId.current) {
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
setQuestions(sorted);
|
setQuestions(filtered);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (currentRequestId === fetchRequestId.current) {
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
@@ -655,6 +630,14 @@ export default function SwipeScreen() {
|
|||||||
style={styles.hintContainer}
|
style={styles.hintContainer}
|
||||||
>
|
>
|
||||||
<Text style={styles.hintTextPremium}>Swipe or tap to answer</Text>
|
<Text style={styles.hintTextPremium}>Swipe or tap to answer</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push({ pathname: "/my-answers", params: { returnTo: "/(app)/swipe" } })}
|
||||||
|
style={styles.editLinkButton}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.editLinkText}>Edit previous answers</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Bottom spacing for tab bar */}
|
{/* Bottom spacing for tab bar */}
|
||||||
@@ -799,6 +782,18 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
letterSpacing: 0.5,
|
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: {
|
bottomSpacer: {
|
||||||
height: Platform.OS === 'ios' ? 100 : 80,
|
height: Platform.OS === 'ios' ? 100 : 80,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ function createThenableQuery(result: any) {
|
|||||||
is: jest.fn(() => query),
|
is: jest.fn(() => query),
|
||||||
in: jest.fn(() => query),
|
in: jest.fn(() => query),
|
||||||
order: jest.fn(() => query),
|
order: jest.fn(() => query),
|
||||||
|
range: jest.fn(() => query),
|
||||||
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||||
};
|
};
|
||||||
return query;
|
return query;
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
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 { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
|
||||||
import { GradientBackground, GlassCard } from '../../components/ui';
|
import { GradientBackground, GlassCard } from '../../components/ui';
|
||||||
import { FeedbackModal } from '../../components/feedback';
|
import { FeedbackModal } from '../../components/feedback';
|
||||||
@@ -22,6 +30,11 @@ import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings
|
|||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 500;
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
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() {
|
export function ProfileScreen() {
|
||||||
const { width } = useWindowDimensions();
|
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 (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
<ScrollView
|
{/* Fixed Nav Bar */}
|
||||||
|
<View style={styles.navBar}>
|
||||||
|
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
) : (
|
||||||
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
|
||||||
|
Settings
|
||||||
|
</Animated.Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<AnimatedScrollView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
styles.contentContainer,
|
styles.contentContainer,
|
||||||
isWideScreen && styles.contentContainerWide,
|
isWideScreen && styles.contentContainerWide,
|
||||||
]}
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
onScroll={scrollHandler}
|
||||||
|
scrollEventThrottle={16}
|
||||||
>
|
>
|
||||||
{/* Header title */}
|
{/* Header title */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(100).duration(500)}
|
entering={FadeInDown.delay(100).duration(500)}
|
||||||
style={styles.header}
|
style={[styles.header, heroStyle]}
|
||||||
>
|
>
|
||||||
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
|
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
|
||||||
<Text style={styles.title}>Settings</Text>
|
<Text style={styles.title}>Settings</Text>
|
||||||
@@ -305,7 +379,7 @@ export function ProfileScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.bottomSpacer} />
|
<View style={styles.bottomSpacer} />
|
||||||
</ScrollView>
|
</AnimatedScrollView>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<FeedbackModal
|
<FeedbackModal
|
||||||
@@ -324,7 +398,33 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
// 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(155, 89, 182, 0.15)', // Purple tint for profile
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
navBarTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
|
// Remove top padding here as the header adds it
|
||||||
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||||
},
|
},
|
||||||
contentContainerWide: {
|
contentContainerWide: {
|
||||||
@@ -333,7 +433,7 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: MAX_CONTENT_WIDTH,
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: STATUS_BAR_HEIGHT + spacing.md,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.md,
|
paddingBottom: spacing.md,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
+215
-159
@@ -1,159 +1,215 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { supabase } from "../lib/supabase";
|
import { supabase } from "../lib/supabase";
|
||||||
import type { Match } from "@/types";
|
import type { Match } from "@/types";
|
||||||
import { useAuthStore } from "./authStore";
|
import { useAuthStore } from "./authStore";
|
||||||
|
|
||||||
interface MatchState {
|
const BATCH_SIZE = 20;
|
||||||
matches: Match[];
|
|
||||||
newMatchesCount: number;
|
interface MatchState {
|
||||||
isLoading: boolean;
|
matches: Match[];
|
||||||
error: string | null;
|
newMatchesCount: number;
|
||||||
fetchMatches: () => Promise<void>;
|
totalCount: number | null;
|
||||||
markAsSeen: (matchId: string) => Promise<void>;
|
isLoading: boolean;
|
||||||
markAllAsSeen: () => Promise<void>;
|
isLoadingMore: boolean;
|
||||||
addMatch: (match: Match) => void;
|
page: number;
|
||||||
updateMatchUnreadCount: (matchId: string, delta: number) => void;
|
hasMore: boolean;
|
||||||
clearMatches: () => void;
|
error: string | null;
|
||||||
}
|
fetchMatches: (refresh?: boolean) => Promise<void>;
|
||||||
|
markAsSeen: (matchId: string) => Promise<void>;
|
||||||
export const useMatchStore = create<MatchState>((set, get) => ({
|
markAllAsSeen: () => Promise<void>;
|
||||||
matches: [],
|
addMatch: (match: Match) => void;
|
||||||
newMatchesCount: 0,
|
updateMatchUnreadCount: (matchId: string, delta: number) => void;
|
||||||
isLoading: false,
|
clearMatches: () => void;
|
||||||
error: null,
|
}
|
||||||
|
|
||||||
fetchMatches: async () => {
|
export const useMatchStore = create<MatchState>((set, get) => ({
|
||||||
const userId = useAuthStore.getState().user?.id;
|
matches: [],
|
||||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
newMatchesCount: 0,
|
||||||
|
totalCount: null,
|
||||||
// Early return if no couple - user isn't paired yet
|
isLoading: false,
|
||||||
if (!coupleId) {
|
isLoadingMore: false,
|
||||||
set({ matches: [], newMatchesCount: 0, isLoading: false });
|
page: 0,
|
||||||
return;
|
hasMore: true,
|
||||||
}
|
error: null,
|
||||||
|
|
||||||
set({ isLoading: true, error: null });
|
fetchMatches: async (refresh = false) => {
|
||||||
|
const userId = useAuthStore.getState().user?.id;
|
||||||
try {
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
const { data: matches, error: matchError } = await supabase
|
|
||||||
.from("matches")
|
// Early return if no couple - user isn't paired yet
|
||||||
.select(`
|
if (!coupleId) {
|
||||||
*,
|
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false });
|
||||||
question:questions(*)
|
return;
|
||||||
`)
|
}
|
||||||
.eq("couple_id", coupleId)
|
|
||||||
.order("created_at", { ascending: false });
|
const state = get();
|
||||||
|
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
|
||||||
if (matchError) throw matchError;
|
|
||||||
if (!matches) {
|
if (refresh) {
|
||||||
set({ matches: [], newMatchesCount: 0, isLoading: false });
|
set({ isLoading: true, error: null, page: 0, hasMore: true });
|
||||||
return;
|
} else {
|
||||||
}
|
if (!state.hasMore) return;
|
||||||
|
set({ isLoadingMore: true });
|
||||||
// Fetch all responses for these questions to determine who answered first
|
}
|
||||||
const questionIds = matches.map(m => m.question_id);
|
|
||||||
let responses: any[] = [];
|
try {
|
||||||
if (questionIds.length > 0) {
|
const currentPage = refresh ? 0 : state.page;
|
||||||
const { data } = await supabase
|
const from = currentPage * BATCH_SIZE;
|
||||||
.from("responses")
|
const to = from + BATCH_SIZE - 1;
|
||||||
.select("*")
|
|
||||||
.in("question_id", questionIds);
|
// Fetch total count on refresh
|
||||||
responses = data ?? [];
|
let totalCount = state.totalCount;
|
||||||
}
|
if (refresh) {
|
||||||
|
const { count, error: countError } = await supabase
|
||||||
// Fetch unread message counts per match
|
.from("matches")
|
||||||
const matchIds = matches.map(m => m.id);
|
.select("*", { count: "exact", head: true })
|
||||||
let unreadCounts: Record<string, number> = {};
|
.eq("couple_id", coupleId);
|
||||||
|
|
||||||
if (userId && matchIds.length > 0) {
|
if (!countError) {
|
||||||
const { data: unreadMessages, error } = await supabase
|
totalCount = count ?? 0;
|
||||||
.from("messages")
|
}
|
||||||
.select("match_id")
|
}
|
||||||
.in("match_id", matchIds)
|
|
||||||
.neq("user_id", userId)
|
const { data: matches, error: matchError } = await supabase
|
||||||
.is("read_at", null);
|
.from("matches")
|
||||||
|
.select(`
|
||||||
if (error) {
|
*,
|
||||||
console.error("Error fetching unread messages:", error);
|
question:questions(*)
|
||||||
}
|
`)
|
||||||
|
.eq("couple_id", coupleId)
|
||||||
// Count unread messages per match
|
.order("created_at", { ascending: false })
|
||||||
unreadMessages?.forEach(msg => {
|
.range(from, to);
|
||||||
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
|
|
||||||
});
|
if (matchError) throw matchError;
|
||||||
}
|
if (!matches || matches.length === 0) {
|
||||||
|
if (refresh) {
|
||||||
const data = matches.map(match => ({
|
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
|
||||||
...match,
|
} else {
|
||||||
responses: responses.filter(r => r.question_id === match.question_id),
|
set({ isLoadingMore: false, hasMore: false });
|
||||||
unreadCount: unreadCounts[match.id] || 0
|
}
|
||||||
}));
|
return;
|
||||||
|
}
|
||||||
// Sort: unread messages first, then by most recent
|
|
||||||
const sortedData = data.sort((a, b) => {
|
// Fetch all responses for these questions to determine who answered first
|
||||||
// First priority: matches with unread messages
|
const questionIds = matches.map(m => m.question_id);
|
||||||
if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
|
let responses: any[] = [];
|
||||||
if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
|
if (questionIds.length > 0) {
|
||||||
|
const { data } = await supabase
|
||||||
// Second priority: most recent first
|
.from("responses")
|
||||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
.select("*")
|
||||||
});
|
.in("question_id", questionIds);
|
||||||
|
responses = data ?? [];
|
||||||
const newCount = sortedData.filter((m) => m.is_new).length;
|
}
|
||||||
set({ matches: sortedData, newMatchesCount: newCount, isLoading: false });
|
|
||||||
} catch (err) {
|
// Fetch unread message counts per match
|
||||||
console.error("Error fetching matches:", err);
|
const matchIds = matches.map(m => m.id);
|
||||||
set({ error: "Failed to load matches", isLoading: false });
|
let unreadCounts: Record<string, number> = {};
|
||||||
}
|
|
||||||
},
|
if (userId && matchIds.length > 0) {
|
||||||
|
const { data: unreadMessages, error } = await supabase
|
||||||
markAsSeen: async (matchId) => {
|
.from("messages")
|
||||||
await supabase
|
.select("match_id")
|
||||||
.from("matches")
|
.in("match_id", matchIds)
|
||||||
.update({ is_new: false })
|
.neq("user_id", userId)
|
||||||
.eq("id", matchId);
|
.is("read_at", null);
|
||||||
|
|
||||||
const matches = get().matches.map((m) =>
|
if (error) {
|
||||||
m.id === matchId ? { ...m, is_new: false } : m
|
console.error("Error fetching unread messages:", error);
|
||||||
);
|
}
|
||||||
const newCount = matches.filter((m) => m.is_new).length;
|
|
||||||
set({ matches, newMatchesCount: newCount });
|
// Count unread messages per match
|
||||||
},
|
unreadMessages?.forEach(msg => {
|
||||||
|
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
|
||||||
markAllAsSeen: async () => {
|
});
|
||||||
const newMatches = get().matches.filter((m) => m.is_new);
|
}
|
||||||
if (newMatches.length === 0) return;
|
|
||||||
|
const data = matches.map(match => ({
|
||||||
const newMatchIds = newMatches.map((m) => m.id);
|
...match,
|
||||||
await supabase
|
responses: responses.filter(r => r.question_id === match.question_id),
|
||||||
.from("matches")
|
unreadCount: unreadCounts[match.id] || 0
|
||||||
.update({ is_new: false })
|
}));
|
||||||
.in("id", newMatchIds);
|
|
||||||
|
// Sort: unread messages first, then by most recent
|
||||||
const matches = get().matches.map((m) =>
|
// Note: Since we fetch by created_at desc, and then sort by unread in memory,
|
||||||
m.is_new ? { ...m, is_new: false } : m
|
// pagination might be slightly inconsistent if unread messages are scattered.
|
||||||
);
|
// However, implementing complex sort in Supabase with computed columns is harder.
|
||||||
set({ matches, newMatchesCount: 0 });
|
// 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
|
||||||
addMatch: (match) => {
|
if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
|
||||||
set((state) => ({
|
if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
|
||||||
matches: [match, ...state.matches],
|
|
||||||
newMatchesCount: state.newMatchesCount + 1,
|
// Second priority: most recent first
|
||||||
}));
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||||
},
|
});
|
||||||
|
|
||||||
updateMatchUnreadCount: (matchId, delta) => {
|
// Calculate new matches count - this is tricky with pagination
|
||||||
set((state) => ({
|
// Ideally we should have a separate query for count, but we'll just count in current batch
|
||||||
matches: state.matches.map((m) =>
|
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
|
||||||
m.id === matchId
|
|
||||||
? { ...m, unreadCount: Math.max(0, (m.unreadCount || 0) + delta) }
|
set((state) => ({
|
||||||
: m
|
matches: refresh ? sortedData : [...state.matches, ...sortedData],
|
||||||
),
|
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
|
||||||
}));
|
totalCount: refresh ? totalCount : state.totalCount,
|
||||||
},
|
isLoading: false,
|
||||||
|
isLoadingMore: false,
|
||||||
clearMatches: () => {
|
page: currentPage + 1,
|
||||||
set({ matches: [], newMatchesCount: 0, isLoading: false, error: null });
|
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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,278 +1,327 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { supabase } from "../lib/supabase";
|
import { supabase } from "../lib/supabase";
|
||||||
import { invokeWithAuthRetry } from "../lib/authErrorHandler";
|
import { invokeWithAuthRetry } from "../lib/authErrorHandler";
|
||||||
import type { Question, QuestionPack, AnswerType } from "@/types";
|
import type { Question, QuestionPack, AnswerType } from "@/types";
|
||||||
import { useAuthStore } from "./authStore";
|
import { useAuthStore } from "./authStore";
|
||||||
import { useMatchStore } from "./matchStore";
|
import { useMatchStore } from "./matchStore";
|
||||||
|
|
||||||
// Response with joined question and pack data
|
// Response with joined question and pack data
|
||||||
export interface ResponseWithQuestion {
|
export interface ResponseWithQuestion {
|
||||||
id: string;
|
id: string;
|
||||||
question_id: string;
|
question_id: string;
|
||||||
answer: AnswerType;
|
answer: AnswerType;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
question: Question & {
|
question: Question & {
|
||||||
pack: Pick<QuestionPack, "id" | "name" | "icon">;
|
pack: Pick<QuestionPack, "id" | "name" | "icon">;
|
||||||
};
|
};
|
||||||
has_match: boolean;
|
has_match: boolean;
|
||||||
match_id?: string;
|
match_id?: string;
|
||||||
partner_answered: boolean;
|
partner_answered: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result from update-response edge function
|
// Result from update-response edge function
|
||||||
export interface UpdateResponseResult {
|
export interface UpdateResponseResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
requires_confirmation?: boolean;
|
requires_confirmation?: boolean;
|
||||||
match_id?: string;
|
match_id?: string;
|
||||||
message_count?: number;
|
message_count?: number;
|
||||||
new_match?: Record<string, unknown> | null;
|
new_match?: Record<string, unknown> | null;
|
||||||
match_deleted?: boolean;
|
match_deleted?: boolean;
|
||||||
match_type_updated?: boolean;
|
match_type_updated?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GroupByOption = "pack" | "date" | "answer";
|
export type GroupByOption = "pack" | "date" | "answer";
|
||||||
|
|
||||||
interface ResponsesState {
|
const BATCH_SIZE = 20;
|
||||||
responses: ResponseWithQuestion[];
|
|
||||||
isLoading: boolean;
|
interface ResponsesState {
|
||||||
groupBy: GroupByOption;
|
responses: ResponseWithQuestion[];
|
||||||
fetchResponses: () => Promise<void>;
|
isLoading: boolean;
|
||||||
updateResponse: (
|
isLoadingMore: boolean;
|
||||||
questionId: string,
|
groupBy: GroupByOption;
|
||||||
newAnswer: AnswerType,
|
hasMore: boolean;
|
||||||
confirmDelete?: boolean
|
page: number;
|
||||||
) => Promise<UpdateResponseResult>;
|
totalCount: number | null;
|
||||||
setGroupBy: (groupBy: GroupByOption) => void;
|
fetchResponses: (refresh?: boolean) => Promise<void>;
|
||||||
clearResponses: () => void;
|
updateResponse: (
|
||||||
}
|
questionId: string,
|
||||||
|
newAnswer: AnswerType,
|
||||||
export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
confirmDelete?: boolean
|
||||||
responses: [],
|
) => Promise<UpdateResponseResult>;
|
||||||
isLoading: false,
|
setGroupBy: (groupBy: GroupByOption) => void;
|
||||||
groupBy: "pack",
|
clearResponses: () => void;
|
||||||
|
}
|
||||||
fetchResponses: async () => {
|
|
||||||
const userId = useAuthStore.getState().user?.id;
|
export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
responses: [],
|
||||||
|
isLoading: false,
|
||||||
if (!userId || !coupleId) return;
|
isLoadingMore: false,
|
||||||
|
groupBy: "pack",
|
||||||
set({ isLoading: true });
|
hasMore: true,
|
||||||
|
page: 0,
|
||||||
try {
|
totalCount: null,
|
||||||
// Fetch all user's responses with questions and packs
|
|
||||||
const { data: responses, error: responsesError } = await supabase
|
fetchResponses: async (refresh = false) => {
|
||||||
.from("responses")
|
const userId = useAuthStore.getState().user?.id;
|
||||||
.select(`
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
id,
|
|
||||||
question_id,
|
if (!userId || !coupleId) return;
|
||||||
answer,
|
|
||||||
created_at,
|
const state = get();
|
||||||
question:questions(
|
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
|
||||||
id,
|
|
||||||
text,
|
if (refresh) {
|
||||||
partner_text,
|
set({ isLoading: true, page: 0, hasMore: true });
|
||||||
intensity,
|
} else {
|
||||||
pack_id,
|
if (!state.hasMore) return;
|
||||||
pack:question_packs(id, name, icon)
|
set({ isLoadingMore: true });
|
||||||
)
|
}
|
||||||
`)
|
|
||||||
.eq("user_id", userId)
|
try {
|
||||||
.order("created_at", { ascending: false });
|
const currentPage = refresh ? 0 : state.page;
|
||||||
|
const from = currentPage * BATCH_SIZE;
|
||||||
if (responsesError) {
|
const to = from + BATCH_SIZE - 1;
|
||||||
console.error("Error fetching responses:", responsesError);
|
|
||||||
set({ isLoading: false });
|
// Fetch total count on refresh
|
||||||
return;
|
let totalCount = state.totalCount;
|
||||||
}
|
if (refresh) {
|
||||||
|
const { count, error: countError } = await supabase
|
||||||
if (!responses) {
|
.from("responses")
|
||||||
set({ responses: [], isLoading: false });
|
.select("*", { count: "exact", head: true })
|
||||||
return;
|
.eq("user_id", userId);
|
||||||
}
|
|
||||||
|
if (!countError) {
|
||||||
// Get question IDs for match lookup
|
totalCount = count ?? 0;
|
||||||
const questionIds = responses.map((r) => r.question_id);
|
}
|
||||||
|
}
|
||||||
// Fetch matches for these questions
|
|
||||||
const { data: matches } = await supabase
|
// Fetch all user's responses with questions and packs
|
||||||
.from("matches")
|
const { data: responses, error: responsesError } = await supabase
|
||||||
.select("id, question_id")
|
.from("responses")
|
||||||
.eq("couple_id", coupleId)
|
.select(`
|
||||||
.in("question_id", questionIds);
|
id,
|
||||||
|
question_id,
|
||||||
// Create a map of question_id -> match_id
|
answer,
|
||||||
const matchMap = new Map<string, string>();
|
created_at,
|
||||||
matches?.forEach((m) => matchMap.set(m.question_id, m.id));
|
question:questions(
|
||||||
|
id,
|
||||||
// Fetch partner's responses to check if they've answered
|
text,
|
||||||
const { data: partnerResponses } = await supabase
|
partner_text,
|
||||||
.from("responses")
|
intensity,
|
||||||
.select("question_id")
|
pack_id,
|
||||||
.eq("couple_id", coupleId)
|
pack:question_packs(id, name, icon)
|
||||||
.neq("user_id", userId)
|
)
|
||||||
.in("question_id", questionIds);
|
`)
|
||||||
|
.eq("user_id", userId)
|
||||||
// Create a set of question IDs that partner has answered
|
.order("created_at", { ascending: false })
|
||||||
const partnerAnsweredSet = new Set<string>(
|
.range(from, to);
|
||||||
partnerResponses?.map((r) => r.question_id) || []
|
|
||||||
);
|
if (responsesError) {
|
||||||
|
console.error("Error fetching responses:", responsesError);
|
||||||
// Transform responses with match and partner info
|
set({ isLoading: false, isLoadingMore: false });
|
||||||
const transformedResponses: ResponseWithQuestion[] = responses
|
return;
|
||||||
.filter((r) => {
|
}
|
||||||
// Supabase returns single relations as objects, but TypeScript may infer arrays
|
|
||||||
const question = r.question as any;
|
if (!responses || responses.length === 0) {
|
||||||
return question && question.pack;
|
if (refresh) {
|
||||||
})
|
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
|
||||||
.map((r) => {
|
} else {
|
||||||
// Cast to any to handle Supabase's nested select typing
|
set({ isLoadingMore: false, hasMore: false });
|
||||||
const question = r.question as any;
|
}
|
||||||
const pack = question.pack as any;
|
return;
|
||||||
|
}
|
||||||
return {
|
|
||||||
id: r.id,
|
// Get question IDs for match lookup
|
||||||
question_id: r.question_id,
|
const questionIds = responses.map((r) => r.question_id);
|
||||||
answer: r.answer as AnswerType,
|
|
||||||
created_at: r.created_at,
|
// Fetch matches for these questions
|
||||||
question: {
|
const { data: matches } = await supabase
|
||||||
id: question.id,
|
.from("matches")
|
||||||
text: question.text,
|
.select("id, question_id")
|
||||||
partner_text: question.partner_text,
|
.eq("couple_id", coupleId)
|
||||||
intensity: question.intensity,
|
.in("question_id", questionIds);
|
||||||
pack_id: question.pack_id,
|
|
||||||
created_at: question.created_at || "",
|
// Create a map of question_id -> match_id
|
||||||
pack: {
|
const matchMap = new Map<string, string>();
|
||||||
id: pack.id,
|
matches?.forEach((m) => matchMap.set(m.question_id, m.id));
|
||||||
name: pack.name,
|
|
||||||
icon: pack.icon,
|
// Fetch partner's responses to check if they've answered
|
||||||
},
|
const { data: partnerResponses } = await supabase
|
||||||
},
|
.from("responses")
|
||||||
has_match: matchMap.has(r.question_id),
|
.select("question_id")
|
||||||
match_id: matchMap.get(r.question_id),
|
.eq("couple_id", coupleId)
|
||||||
partner_answered: partnerAnsweredSet.has(r.question_id),
|
.neq("user_id", userId)
|
||||||
};
|
.in("question_id", questionIds);
|
||||||
});
|
|
||||||
|
// Create a set of question IDs that partner has answered
|
||||||
set({ responses: transformedResponses, isLoading: false });
|
const partnerAnsweredSet = new Set<string>(
|
||||||
} catch (error) {
|
partnerResponses?.map((r) => r.question_id) || []
|
||||||
console.error("Error in fetchResponses:", error);
|
);
|
||||||
set({ isLoading: false });
|
|
||||||
}
|
// Transform responses with match and partner info
|
||||||
},
|
const transformedResponses: ResponseWithQuestion[] = responses
|
||||||
|
.filter((r) => {
|
||||||
updateResponse: async (
|
// Supabase returns single relations as objects, but TypeScript may infer arrays
|
||||||
questionId: string,
|
const question = r.question as any;
|
||||||
newAnswer: AnswerType,
|
return question && question.pack;
|
||||||
confirmDelete = false
|
})
|
||||||
): Promise<UpdateResponseResult> => {
|
.map((r) => {
|
||||||
const { data, error } = await invokeWithAuthRetry("update-response", {
|
// Cast to any to handle Supabase's nested select typing
|
||||||
body: {
|
const question = r.question as any;
|
||||||
question_id: questionId,
|
const pack = question.pack as any;
|
||||||
new_answer: newAnswer,
|
|
||||||
confirm_delete_match: confirmDelete,
|
return {
|
||||||
},
|
id: r.id,
|
||||||
});
|
question_id: r.question_id,
|
||||||
|
answer: r.answer as AnswerType,
|
||||||
if (error) {
|
created_at: r.created_at,
|
||||||
console.error("Error updating response:", error);
|
question: {
|
||||||
return { success: false, error: error.message || "Failed to update response" };
|
id: question.id,
|
||||||
}
|
text: question.text,
|
||||||
|
partner_text: question.partner_text,
|
||||||
const result = data as UpdateResponseResult;
|
intensity: question.intensity,
|
||||||
|
pack_id: question.pack_id,
|
||||||
// If update was successful (not just requiring confirmation), update local state
|
created_at: question.created_at || "",
|
||||||
if (result.success && !result.requires_confirmation) {
|
pack: {
|
||||||
// Update local response state
|
id: pack.id,
|
||||||
set((state) => ({
|
name: pack.name,
|
||||||
responses: state.responses.map((r) => {
|
icon: pack.icon,
|
||||||
if (r.question_id === questionId) {
|
},
|
||||||
return {
|
},
|
||||||
...r,
|
has_match: matchMap.has(r.question_id),
|
||||||
answer: newAnswer,
|
match_id: matchMap.get(r.question_id),
|
||||||
// Update match status based on result
|
partner_answered: partnerAnsweredSet.has(r.question_id),
|
||||||
has_match: result.match_deleted
|
};
|
||||||
? false
|
});
|
||||||
: result.new_match
|
|
||||||
? true
|
set((state) => ({
|
||||||
: r.has_match,
|
responses: refresh
|
||||||
match_id: result.match_deleted
|
? transformedResponses
|
||||||
? undefined
|
: [...state.responses, ...transformedResponses],
|
||||||
: result.new_match
|
totalCount: refresh ? totalCount : state.totalCount,
|
||||||
? (result.new_match.id as string)
|
isLoading: false,
|
||||||
: r.match_id,
|
isLoadingMore: false,
|
||||||
};
|
page: currentPage + 1,
|
||||||
}
|
hasMore: responses.length === BATCH_SIZE
|
||||||
return r;
|
}));
|
||||||
}),
|
} catch (error) {
|
||||||
}));
|
console.error("Error in fetchResponses:", error);
|
||||||
|
set({ isLoading: false, isLoadingMore: false });
|
||||||
// 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();
|
|
||||||
}
|
updateResponse: async (
|
||||||
}
|
questionId: string,
|
||||||
|
newAnswer: AnswerType,
|
||||||
return result;
|
confirmDelete = false
|
||||||
},
|
): Promise<UpdateResponseResult> => {
|
||||||
|
const { data, error } = await invokeWithAuthRetry("update-response", {
|
||||||
setGroupBy: (groupBy: GroupByOption) => {
|
body: {
|
||||||
set({ groupBy });
|
question_id: questionId,
|
||||||
},
|
new_answer: newAnswer,
|
||||||
|
confirm_delete_match: confirmDelete,
|
||||||
clearResponses: () => {
|
},
|
||||||
set({ responses: [], isLoading: false, groupBy: "pack" });
|
});
|
||||||
},
|
|
||||||
}));
|
if (error) {
|
||||||
|
console.error("Error updating response:", error);
|
||||||
// Helper function to group responses
|
return { success: false, error: error.message || "Failed to update response" };
|
||||||
export function groupResponses(
|
}
|
||||||
responses: ResponseWithQuestion[],
|
|
||||||
groupBy: GroupByOption
|
const result = data as UpdateResponseResult;
|
||||||
): { title: string; data: ResponseWithQuestion[] }[] {
|
|
||||||
if (groupBy === "pack") {
|
// If update was successful (not just requiring confirmation), update local state
|
||||||
// Group by pack name
|
if (result.success && !result.requires_confirmation) {
|
||||||
const groups = new Map<string, ResponseWithQuestion[]>();
|
// Update local response state
|
||||||
responses.forEach((r) => {
|
set((state) => ({
|
||||||
const packName = r.question.pack.name;
|
responses: state.responses.map((r) => {
|
||||||
if (!groups.has(packName)) {
|
if (r.question_id === questionId) {
|
||||||
groups.set(packName, []);
|
return {
|
||||||
}
|
...r,
|
||||||
groups.get(packName)!.push(r);
|
answer: newAnswer,
|
||||||
});
|
// Update match status based on result
|
||||||
return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
|
has_match: result.match_deleted
|
||||||
}
|
? false
|
||||||
|
: result.new_match
|
||||||
if (groupBy === "answer") {
|
? true
|
||||||
// Group by answer type
|
: r.has_match,
|
||||||
const yesResponses = responses.filter((r) => r.answer === "yes");
|
match_id: result.match_deleted
|
||||||
const maybeResponses = responses.filter((r) => r.answer === "maybe");
|
? undefined
|
||||||
const noResponses = responses.filter((r) => r.answer === "no");
|
: result.new_match
|
||||||
|
? (result.new_match.id as string)
|
||||||
const sections = [];
|
: r.match_id,
|
||||||
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 r;
|
||||||
return sections;
|
}),
|
||||||
}
|
}));
|
||||||
|
|
||||||
if (groupBy === "date") {
|
// Refresh matches store if match was created, deleted, or type updated
|
||||||
// Group by date (day)
|
if (result.match_deleted || result.new_match || result.match_type_updated) {
|
||||||
const groups = new Map<string, ResponseWithQuestion[]>();
|
useMatchStore.getState().fetchMatches();
|
||||||
responses.forEach((r) => {
|
}
|
||||||
const date = new Date(r.created_at).toLocaleDateString("en-US", {
|
}
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
return result;
|
||||||
day: "numeric",
|
},
|
||||||
});
|
|
||||||
if (!groups.has(date)) {
|
setGroupBy: (groupBy: GroupByOption) => {
|
||||||
groups.set(date, []);
|
set({ groupBy });
|
||||||
}
|
},
|
||||||
groups.get(date)!.push(r);
|
|
||||||
});
|
clearResponses: () => {
|
||||||
return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
|
set({ responses: [], isLoading: false, groupBy: "pack", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
|
||||||
}
|
},
|
||||||
|
}));
|
||||||
return [{ title: "All Responses", data: responses }];
|
|
||||||
}
|
// 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<string, ResponseWithQuestion[]>();
|
||||||
|
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<string, ResponseWithQuestion[]>();
|
||||||
|
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 }];
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ jest.mock('@supabase/supabase-js', () => {
|
|||||||
is: jest.fn(),
|
is: jest.fn(),
|
||||||
in: jest.fn(),
|
in: jest.fn(),
|
||||||
order: jest.fn(),
|
order: jest.fn(),
|
||||||
|
range: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
upsert: jest.fn(),
|
upsert: jest.fn(),
|
||||||
insert: jest.fn(),
|
insert: jest.fn(),
|
||||||
|
|||||||
Reference in new issue
Block a user