mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Improve mobile data retrieval and state management
- Add race protection and packId normalization to swipe screen - Add loading/error states to matchStore with couple_id filter - Sync per-match unread counts in realtime - Use useFocusEffect for data freshness on Home screen - Wire RefreshControl to actual loading state on Matches 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
994dd2a295
commit
ae91602921
21 files changed
+2453
-237
No files matched your search
@@ -18,8 +18,8 @@ function createThenableQuery(result: any) {
|
||||
describe('matchStore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useMatchStore.setState({ matches: [], newMatchesCount: 0 } as any);
|
||||
useAuthStore.setState({ user: { id: 'me' } } as any);
|
||||
useMatchStore.setState({ matches: [], newMatchesCount: 0, isLoading: false, error: null } as any);
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||
});
|
||||
|
||||
it('sorts matches with unread messages first', async () => {
|
||||
|
||||
@@ -42,12 +42,14 @@ describe('messageStore', () => {
|
||||
});
|
||||
|
||||
it('markMatchMessagesAsRead updates Supabase and refetches unread count', async () => {
|
||||
const unreadCountQuery = createThenableQuery({ count: 2 }); // count before marking
|
||||
const updateQuery = createThenableQuery({});
|
||||
const countQuery = createThenableQuery({ count: 0 });
|
||||
const finalCountQuery = createThenableQuery({ count: 0 }); // count after marking
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(updateQuery)
|
||||
.mockReturnValueOnce(countQuery);
|
||||
.mockReturnValueOnce(unreadCountQuery) // count unread messages first
|
||||
.mockReturnValueOnce(updateQuery) // update messages
|
||||
.mockReturnValueOnce(finalCountQuery); // fetchUnreadCount
|
||||
|
||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { BlurView } from "expo-blur";
|
||||
import Animated, { FadeIn, FadeOut, ZoomIn, ZoomOut } from "react-native-reanimated";
|
||||
import { colors, spacing, typography, radius } from "../../theme";
|
||||
|
||||
interface DeleteMatchConfirmModalProps {
|
||||
visible: boolean;
|
||||
messageCount: number;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DeleteMatchConfirmModal({
|
||||
visible,
|
||||
messageCount,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DeleteMatchConfirmModalProps) {
|
||||
const messageText =
|
||||
messageCount === 0
|
||||
? "This will delete your match."
|
||||
: messageCount === 1
|
||||
? "This will delete your match and 1 message."
|
||||
: `This will delete your match and ${messageCount} messages.`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onCancel}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Animated.View
|
||||
style={styles.overlay}
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={onCancel}>
|
||||
<View style={styles.backdrop} />
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
<Animated.View
|
||||
style={styles.modalContainer}
|
||||
entering={ZoomIn.springify().damping(20)}
|
||||
exiting={ZoomOut.duration(150)}
|
||||
>
|
||||
{Platform.OS === "ios" ? (
|
||||
<BlurView intensity={80} tint="dark" style={styles.modal}>
|
||||
<ModalContent
|
||||
messageText={messageText}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={[styles.modal, styles.modalAndroid]}>
|
||||
<ModalContent
|
||||
messageText={messageText}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalContent({
|
||||
messageText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
messageText: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Warning icon */}
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="warning" size={32} color={colors.error} />
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text style={styles.title}>Delete Match?</Text>
|
||||
|
||||
{/* Message */}
|
||||
<Text style={styles.message}>
|
||||
Changing your answer to "No" will remove this match permanently.{"\n\n"}
|
||||
{messageText}
|
||||
</Text>
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={styles.buttons}>
|
||||
<TouchableOpacity style={styles.cancelButton} onPress={onCancel} activeOpacity={0.7}>
|
||||
<Text style={styles.cancelButtonText}>Keep Match</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.deleteButton} onPress={onConfirm} activeOpacity={0.7}>
|
||||
<LinearGradient
|
||||
colors={[colors.error, "#c0392b"]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.deleteButtonText}>Delete Match</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: spacing.lg,
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
},
|
||||
modalContainer: {
|
||||
width: "100%",
|
||||
maxWidth: 340,
|
||||
},
|
||||
modal: {
|
||||
borderRadius: radius.xl,
|
||||
overflow: "hidden",
|
||||
padding: spacing.xl,
|
||||
alignItems: "center",
|
||||
},
|
||||
modalAndroid: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: "rgba(231, 76, 60, 0.15)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(231, 76, 60, 0.25)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
message: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 22,
|
||||
},
|
||||
buttons: {
|
||||
width: "100%",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
cancelButton: {
|
||||
width: "100%",
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.15)",
|
||||
alignItems: "center",
|
||||
},
|
||||
cancelButtonText: {
|
||||
...typography.callout,
|
||||
color: colors.text,
|
||||
fontWeight: "600",
|
||||
},
|
||||
deleteButton: {
|
||||
width: "100%",
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
overflow: "hidden",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
deleteButtonText: {
|
||||
...typography.callout,
|
||||
color: colors.text,
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { BlurView } from "expo-blur";
|
||||
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
|
||||
import type { ResponseWithQuestion, UpdateResponseResult } from "../../store";
|
||||
import { useResponsesStore } from "../../store";
|
||||
import type { AnswerType } from "@/types";
|
||||
import { colors, spacing, typography, radius } from "../../theme";
|
||||
import { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
|
||||
|
||||
// Premium color palette
|
||||
const ACCENT = colors.premium.gold;
|
||||
const ACCENT_RGBA = "rgba(212, 175, 55, ";
|
||||
|
||||
// Answer button config
|
||||
const ANSWER_CONFIG = {
|
||||
yes: {
|
||||
color: colors.success,
|
||||
rgba: "rgba(46, 204, 113, ",
|
||||
icon: "checkmark-circle" as const,
|
||||
label: "Yes",
|
||||
},
|
||||
maybe: {
|
||||
color: colors.warning,
|
||||
rgba: "rgba(243, 156, 18, ",
|
||||
icon: "help-circle" as const,
|
||||
label: "Maybe",
|
||||
},
|
||||
no: {
|
||||
color: colors.error,
|
||||
rgba: "rgba(231, 76, 60, ",
|
||||
icon: "close-circle" as const,
|
||||
label: "No",
|
||||
},
|
||||
};
|
||||
|
||||
interface EditResponseSheetProps {
|
||||
visible: boolean;
|
||||
response: ResponseWithQuestion | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function EditResponseSheet({ visible, response, onClose, onSuccess }: EditResponseSheetProps) {
|
||||
const { updateResponse } = useResponsesStore();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [pendingAnswer, setPendingAnswer] = useState<AnswerType | null>(null);
|
||||
const [confirmModalData, setConfirmModalData] = useState<{
|
||||
matchId: string;
|
||||
messageCount: number;
|
||||
} | null>(null);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setIsLoading(false);
|
||||
setPendingAnswer(null);
|
||||
setConfirmModalData(null);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!response) return null;
|
||||
|
||||
const handleAnswerPress = async (newAnswer: AnswerType) => {
|
||||
if (newAnswer === response.answer || isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setPendingAnswer(newAnswer);
|
||||
|
||||
const result = await updateResponse(response.question_id, newAnswer, false);
|
||||
|
||||
if (result.requires_confirmation && result.match_id) {
|
||||
// Show confirmation modal
|
||||
setConfirmModalData({
|
||||
matchId: result.match_id,
|
||||
messageCount: result.message_count || 0,
|
||||
});
|
||||
setIsLoading(false);
|
||||
} else if (result.success) {
|
||||
// Success - close sheet
|
||||
setIsLoading(false);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
// Error
|
||||
setIsLoading(false);
|
||||
setPendingAnswer(null);
|
||||
// Could show an error toast here
|
||||
console.error("Failed to update response:", result.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!pendingAnswer) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setConfirmModalData(null);
|
||||
|
||||
const result = await updateResponse(response.question_id, pendingAnswer, true);
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (result.success) {
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
setPendingAnswer(null);
|
||||
console.error("Failed to delete match:", result.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setConfirmModalData(null);
|
||||
setPendingAnswer(null);
|
||||
};
|
||||
|
||||
const AnswerButton = ({ answer }: { answer: AnswerType }) => {
|
||||
const config = ANSWER_CONFIG[answer];
|
||||
const isSelected = response.answer === answer;
|
||||
const isPending = pendingAnswer === answer;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.answerButton,
|
||||
isSelected && {
|
||||
backgroundColor: `${config.rgba}0.2)`,
|
||||
borderColor: `${config.rgba}0.4)`,
|
||||
},
|
||||
]}
|
||||
onPress={() => handleAnswerPress(answer)}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isPending && isLoading ? (
|
||||
<ActivityIndicator color={config.color} size="small" />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={config.icon}
|
||||
size={28}
|
||||
color={isSelected ? config.color : colors.textSecondary}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.answerButtonText,
|
||||
isSelected && { color: config.color, fontWeight: "700" },
|
||||
]}
|
||||
>
|
||||
{config.label}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<View style={styles.selectedIndicator}>
|
||||
<Ionicons name="checkmark" size={14} color={config.color} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Animated.View style={styles.overlay} entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<View style={styles.backdrop} />
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
<Animated.View
|
||||
style={styles.sheetContainer}
|
||||
entering={SlideInDown.springify().damping(20)}
|
||||
exiting={SlideOutDown.duration(200)}
|
||||
>
|
||||
{Platform.OS === "ios" ? (
|
||||
<BlurView intensity={80} tint="dark" style={styles.sheet}>
|
||||
<SheetContent
|
||||
response={response}
|
||||
AnswerButton={AnswerButton}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={[styles.sheet, styles.sheetAndroid]}>
|
||||
<SheetContent
|
||||
response={response}
|
||||
AnswerButton={AnswerButton}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<DeleteMatchConfirmModal
|
||||
visible={confirmModalData !== null}
|
||||
messageCount={confirmModalData?.messageCount || 0}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Sheet content extracted for reuse between iOS blur and Android solid background
|
||||
function SheetContent({
|
||||
response,
|
||||
AnswerButton,
|
||||
onClose,
|
||||
}: {
|
||||
response: ResponseWithQuestion;
|
||||
AnswerButton: React.FC<{ answer: AnswerType }>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Handle */}
|
||||
<View style={styles.handle} />
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Edit Answer</Text>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<Ionicons name="close" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Question text */}
|
||||
<View style={styles.questionContainer}>
|
||||
<Text style={styles.questionText}>{response.question.text}</Text>
|
||||
<View style={styles.packBadge}>
|
||||
<Ionicons name="layers-outline" size={12} color={colors.textTertiary} />
|
||||
<Text style={styles.packText}>{response.question.pack.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Warning for matched questions */}
|
||||
{response.has_match && (
|
||||
<View style={styles.warningBanner}>
|
||||
<Ionicons name="warning" size={16} color={colors.warning} />
|
||||
<Text style={styles.warningText}>
|
||||
This question has a match. Changing to "No" will delete the match and
|
||||
all messages.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Answer buttons */}
|
||||
<View style={styles.answerButtons}>
|
||||
<AnswerButton answer="yes" />
|
||||
<AnswerButton answer="maybe" />
|
||||
<AnswerButton answer="no" />
|
||||
</View>
|
||||
|
||||
{/* Bottom padding for safe area */}
|
||||
<View style={styles.bottomPadding} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
},
|
||||
sheetContainer: {
|
||||
maxHeight: "80%",
|
||||
},
|
||||
sheet: {
|
||||
borderTopLeftRadius: radius.xl,
|
||||
borderTopRightRadius: radius.xl,
|
||||
overflow: "hidden",
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
sheetAndroid: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
},
|
||||
handle: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.3)",
|
||||
borderRadius: 2,
|
||||
alignSelf: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
},
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
questionContainer: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
questionText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
packBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
packText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
warningBanner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
backgroundColor: "rgba(243, 156, 18, 0.1)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(243, 156, 18, 0.2)",
|
||||
borderRadius: radius.md,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
warningText: {
|
||||
...typography.footnote,
|
||||
color: colors.warning,
|
||||
flex: 1,
|
||||
},
|
||||
answerButtons: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
answerButton: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: spacing.lg,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
answerButtonText: {
|
||||
...typography.callout,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
selectedIndicator: {
|
||||
position: "absolute",
|
||||
top: spacing.sm,
|
||||
right: spacing.sm,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
bottomPadding: {
|
||||
height: Platform.OS === "ios" ? 34 : 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeInRight } from "react-native-reanimated";
|
||||
import type { ResponseWithQuestion } from "../../store";
|
||||
import { colors, spacing, typography, radius } from "../../theme";
|
||||
|
||||
// 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, ";
|
||||
|
||||
// Answer colors (semantic)
|
||||
const ANSWER_COLORS = {
|
||||
yes: {
|
||||
color: colors.success,
|
||||
rgba: "rgba(46, 204, 113, ",
|
||||
label: "YES",
|
||||
},
|
||||
no: {
|
||||
color: colors.error,
|
||||
rgba: "rgba(231, 76, 60, ",
|
||||
label: "NO",
|
||||
},
|
||||
maybe: {
|
||||
color: colors.warning,
|
||||
rgba: "rgba(243, 156, 18, ",
|
||||
label: "MAYBE",
|
||||
},
|
||||
};
|
||||
|
||||
interface ResponseCardProps {
|
||||
response: ResponseWithQuestion;
|
||||
index: number;
|
||||
onEditPress: () => void;
|
||||
onChatPress?: () => void;
|
||||
}
|
||||
|
||||
export function ResponseCard({ response, index, onEditPress, onChatPress }: ResponseCardProps) {
|
||||
const answerStyle = ANSWER_COLORS[response.answer];
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInRight.delay(index * 30).duration(250)}>
|
||||
<View style={styles.card}>
|
||||
{/* Subtle gradient background */}
|
||||
<LinearGradient
|
||||
colors={["rgba(22, 33, 62, 0.6)", "rgba(13, 13, 26, 0.8)"]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
{/* Top silk highlight */}
|
||||
<LinearGradient
|
||||
colors={[`${answerStyle.rgba}0.06)`, "transparent"]}
|
||||
style={styles.cardSilkHighlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.row}>
|
||||
{/* Answer badge */}
|
||||
<View
|
||||
style={[
|
||||
styles.answerBadge,
|
||||
{ backgroundColor: `${answerStyle.rgba}0.15)`, borderColor: `${answerStyle.rgba}0.3)` },
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={
|
||||
response.answer === "yes"
|
||||
? "checkmark"
|
||||
: response.answer === "no"
|
||||
? "close"
|
||||
: "help"
|
||||
}
|
||||
size={18}
|
||||
color={answerStyle.color}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.questionText} numberOfLines={2}>
|
||||
{response.question.text}
|
||||
</Text>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
{/* Answer tag */}
|
||||
<View
|
||||
style={[
|
||||
styles.tag,
|
||||
{
|
||||
backgroundColor: `${answerStyle.rgba}0.1)`,
|
||||
borderColor: `${answerStyle.rgba}0.2)`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.tagText, { color: answerStyle.color }]}>
|
||||
{answerStyle.label}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Pack name */}
|
||||
<View style={styles.packBadge}>
|
||||
<Ionicons name="layers-outline" size={10} color={colors.textTertiary} />
|
||||
<Text style={styles.packText} numberOfLines={1}>
|
||||
{response.question.pack.name}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Partner status indicator */}
|
||||
{response.partner_answered && (
|
||||
<View style={styles.partnerBadge}>
|
||||
<Ionicons name="person" size={10} color={colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Right section */}
|
||||
<View style={styles.rightSection}>
|
||||
{/* Match indicator with chat link */}
|
||||
{response.has_match && onChatPress && (
|
||||
<TouchableOpacity style={styles.matchButton} onPress={onChatPress}>
|
||||
<Ionicons name="heart" size={14} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Edit button */}
|
||||
<TouchableOpacity style={styles.editButton} onPress={onEditPress}>
|
||||
<Ionicons name="pencil" size={14} color={ACCENT} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Premium border */}
|
||||
<View style={styles.cardBorder} pointerEvents="none" />
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
marginBottom: spacing.sm,
|
||||
borderRadius: radius.lg,
|
||||
overflow: "hidden",
|
||||
padding: spacing.md,
|
||||
},
|
||||
cardSilkHighlight: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 50,
|
||||
},
|
||||
cardBorder: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.08)",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
answerBadge: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
questionText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
fontWeight: "500",
|
||||
marginBottom: spacing.xs,
|
||||
lineHeight: 20,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
},
|
||||
tagText: {
|
||||
...typography.caption2,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
packBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
maxWidth: 120,
|
||||
},
|
||||
packText: {
|
||||
...typography.caption2,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
partnerBadge: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
rightSection: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
matchButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "rgba(233, 69, 96, 0.15)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(233, 69, 96, 0.25)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
editButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${ACCENT_RGBA}0.2)`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ResponseCard } from "./ResponseCard";
|
||||
export { EditResponseSheet } from "./EditResponseSheet";
|
||||
export { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
|
||||
@@ -52,6 +52,13 @@ const STEPS: TutorialStep[] = [
|
||||
icon: "images",
|
||||
gradient: gradients.premiumRose as [string, string],
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
title: "Change Your Mind?",
|
||||
description: "Go to My Answers to review and edit any of your responses. Changed your mind? No problem — update your answer anytime.",
|
||||
icon: "create-outline",
|
||||
gradient: gradients.premiumGold as [string, string],
|
||||
},
|
||||
];
|
||||
|
||||
export default function MatchesTutorial({ onComplete }: Props) {
|
||||
@@ -216,6 +223,40 @@ export default function MatchesTutorial({ onComplete }: Props) {
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Edit answers demo - show on fourth step */}
|
||||
{currentStep === 3 && (
|
||||
<View style={styles.editDemo}>
|
||||
<View style={styles.answerCard}>
|
||||
<View style={styles.answerCardContent}>
|
||||
<Text style={styles.answerCardQuestion} numberOfLines={1}>
|
||||
"Try something new together"
|
||||
</Text>
|
||||
<View style={styles.answerCardBadge}>
|
||||
<Ionicons name="checkmark" size={12} color={colors.text} />
|
||||
<Text style={styles.answerCardBadgeText}>YES</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.answerCardEdit}>
|
||||
<Ionicons name="create-outline" size={18} color={colors.premium.gold} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.answerArrow}>
|
||||
<Ionicons name="arrow-down" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
<View style={[styles.answerCard, styles.answerCardUpdated]}>
|
||||
<View style={styles.answerCardContent}>
|
||||
<Text style={styles.answerCardQuestion} numberOfLines={1}>
|
||||
"Try something new together"
|
||||
</Text>
|
||||
<View style={[styles.answerCardBadge, styles.answerCardBadgeMaybe]}>
|
||||
<Ionicons name="help" size={12} color={colors.warning} />
|
||||
<Text style={[styles.answerCardBadgeText, styles.answerCardBadgeTextMaybe]}>MAYBE</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
@@ -425,6 +466,68 @@ const styles = StyleSheet.create({
|
||||
color: colors.success,
|
||||
fontWeight: "600",
|
||||
},
|
||||
editDemo: {
|
||||
marginTop: spacing.lg,
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
answerCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.glass.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
borderRadius: radius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
width: 260,
|
||||
},
|
||||
answerCardUpdated: {
|
||||
borderColor: colors.premium.gold,
|
||||
backgroundColor: "rgba(212, 175, 55, 0.08)",
|
||||
},
|
||||
answerCardContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
answerCardQuestion: {
|
||||
...typography.footnote,
|
||||
color: colors.text,
|
||||
fontStyle: "italic",
|
||||
flex: 1,
|
||||
},
|
||||
answerCardBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.success,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
gap: 2,
|
||||
},
|
||||
answerCardBadgeMaybe: {
|
||||
backgroundColor: "rgba(243, 156, 18, 0.2)",
|
||||
borderWidth: 1,
|
||||
borderColor: colors.warning,
|
||||
},
|
||||
answerCardBadgeText: {
|
||||
...typography.caption2,
|
||||
fontWeight: "700",
|
||||
color: colors.text,
|
||||
},
|
||||
answerCardBadgeTextMaybe: {
|
||||
color: colors.warning,
|
||||
},
|
||||
answerCardEdit: {
|
||||
marginLeft: spacing.sm,
|
||||
padding: spacing.xs,
|
||||
},
|
||||
answerArrow: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
textContainer: {
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.xl,
|
||||
|
||||
@@ -137,51 +137,64 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
marginVertical={spacing.md}
|
||||
/>
|
||||
|
||||
{/* Question Text */}
|
||||
<Text style={styles.matchQuestionText}>
|
||||
"{match.question.text}"
|
||||
</Text>
|
||||
{match.question.partner_text && (
|
||||
<Text style={styles.matchQuestionPartnerText}>
|
||||
"{match.question.partner_text}"
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Response Pills */}
|
||||
{match.responses && match.responses.length > 0 && (
|
||||
<View style={styles.responsePillsRow}>
|
||||
{match.responses.map((response: any) => {
|
||||
const isUser = response.user_id === user?.id;
|
||||
const isYes = response.answer === 'yes';
|
||||
return (
|
||||
<View key={response.user_id} style={styles.responsePillWrapper}>
|
||||
{isYes ? (
|
||||
<LinearGradient
|
||||
colors={isYesYes
|
||||
? gradients.premiumGold as [string, string]
|
||||
: [colors.success, '#27ae60']
|
||||
}
|
||||
style={styles.responsePill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="checkmark" size={12} color={colors.text} />
|
||||
<Text style={styles.responsePillText}>
|
||||
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<View style={styles.responsePillMaybe}>
|
||||
<Ionicons name="help" size={12} color={colors.warning} />
|
||||
<Text style={styles.responsePillTextMaybe}>
|
||||
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* Question and Responses - Show who answered what */}
|
||||
{match.responses && match.responses.length > 0 ? (
|
||||
<View style={styles.responsesContainer}>
|
||||
{(() => {
|
||||
// Sort responses by created_at to determine who answered first
|
||||
const sortedResponses = [...match.responses].sort(
|
||||
(a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
})}
|
||||
const isTwoPart = !!match.question.partner_text;
|
||||
|
||||
return sortedResponses.map((response: any, index: number) => {
|
||||
const isUser = response.user_id === user?.id;
|
||||
const isYes = response.answer === 'yes';
|
||||
const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner');
|
||||
|
||||
// First responder sees main text, second sees partner_text
|
||||
const questionText = isTwoPart && index === 1
|
||||
? match.question.partner_text
|
||||
: match.question.text;
|
||||
|
||||
return (
|
||||
<View key={response.user_id} style={styles.responseCard}>
|
||||
{/* Name and Answer Badge */}
|
||||
<View style={styles.responseHeader}>
|
||||
<Text style={styles.responseName}>{name}</Text>
|
||||
{isYes ? (
|
||||
<LinearGradient
|
||||
colors={isYesYes
|
||||
? gradients.premiumGold as [string, string]
|
||||
: [colors.success, '#27ae60']
|
||||
}
|
||||
style={styles.answerBadge}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="checkmark" size={10} color={colors.text} />
|
||||
<Text style={styles.answerBadgeText}>YES</Text>
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<View style={styles.answerBadgeMaybe}>
|
||||
<Ionicons name="help" size={10} color={colors.warning} />
|
||||
<Text style={styles.answerBadgeTextMaybe}>MAYBE</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* Question they saw */}
|
||||
<Text style={styles.responseQuestionText}>
|
||||
"{questionText}"
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.matchQuestionText}>
|
||||
"{match.question.text}"
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Premium border */}
|
||||
@@ -322,50 +335,66 @@ const styles = StyleSheet.create({
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 20,
|
||||
},
|
||||
matchQuestionPartnerText: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
marginTop: 2,
|
||||
},
|
||||
responsePillsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
responsesContainer: {
|
||||
width: '100%',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
responsePillWrapper: {
|
||||
overflow: 'hidden',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
responsePill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
gap: 4,
|
||||
},
|
||||
responsePillText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
},
|
||||
responsePillMaybe: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
gap: 4,
|
||||
backgroundColor: `rgba(243, 156, 18, 0.15)`,
|
||||
responseCard: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
borderRadius: radius.md,
|
||||
padding: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: `rgba(243, 156, 18, 0.3)`,
|
||||
borderColor: 'rgba(255, 255, 255, 0.06)',
|
||||
},
|
||||
responseHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
responseName: {
|
||||
...typography.caption1,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
answerBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
gap: 3,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
responsePillTextMaybe: {
|
||||
answerBadgeText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
answerBadgeMaybe: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
gap: 3,
|
||||
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
answerBadgeTextMaybe: {
|
||||
...typography.caption2,
|
||||
fontWeight: '700',
|
||||
color: colors.warning,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
responseQuestionText: {
|
||||
...typography.footnote,
|
||||
color: colors.text,
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 18,
|
||||
},
|
||||
matchCardBorder: {
|
||||
position: 'absolute',
|
||||
|
||||
@@ -4,3 +4,5 @@ export { useMatchStore } from "./matchStore";
|
||||
export { usePacksStore } from "./packsStore";
|
||||
export { useMessageStore } from "./messageStore";
|
||||
export { useSubscriptionStore } from "./subscriptionStore";
|
||||
export { useResponsesStore, groupResponses } from "./responsesStore";
|
||||
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption } from "./responsesStore";
|
||||
@@ -6,77 +6,105 @@ import { useAuthStore } from "./authStore";
|
||||
interface MatchState {
|
||||
matches: Match[];
|
||||
newMatchesCount: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
fetchMatches: () => Promise<void>;
|
||||
markAsSeen: (matchId: string) => Promise<void>;
|
||||
markAllAsSeen: () => Promise<void>;
|
||||
addMatch: (match: Match) => void;
|
||||
updateMatchUnreadCount: (matchId: string, delta: number) => void;
|
||||
clearMatches: () => void;
|
||||
}
|
||||
|
||||
export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
matches: [],
|
||||
newMatchesCount: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchMatches: async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
|
||||
const { data: matches } = await supabase
|
||||
.from("matches")
|
||||
.select(`
|
||||
*,
|
||||
question:questions(*)
|
||||
`)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (!matches) return;
|
||||
|
||||
// Fetch all responses for these questions to determine who answered first
|
||||
const questionIds = matches.map(m => m.question_id);
|
||||
const { data: responses } = await supabase
|
||||
.from("responses")
|
||||
.select("*")
|
||||
.in("question_id", questionIds);
|
||||
|
||||
// Fetch unread message counts per match
|
||||
const matchIds = matches.map(m => m.id);
|
||||
let unreadCounts: Record<string, number> = {};
|
||||
|
||||
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;
|
||||
});
|
||||
// Early return if no couple - user isn't paired yet
|
||||
if (!coupleId) {
|
||||
set({ matches: [], newMatchesCount: 0, isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = matches.map(match => ({
|
||||
...match,
|
||||
responses: responses?.filter(r => r.question_id === match.question_id) || [],
|
||||
unreadCount: unreadCounts[match.id] || 0
|
||||
}));
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
// 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;
|
||||
try {
|
||||
const { data: matches, error: matchError } = await supabase
|
||||
.from("matches")
|
||||
.select(`
|
||||
*,
|
||||
question:questions(*)
|
||||
`)
|
||||
.eq("couple_id", coupleId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
// Second priority: most recent first
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
});
|
||||
if (matchError) throw matchError;
|
||||
if (!matches) {
|
||||
set({ matches: [], newMatchesCount: 0, isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const newCount = sortedData.filter((m) => m.is_new).length;
|
||||
set({ matches: sortedData, newMatchesCount: newCount });
|
||||
// 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<string, number> = {};
|
||||
|
||||
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) => {
|
||||
@@ -115,7 +143,17 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
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 });
|
||||
set({ matches: [], newMatchesCount: 0, isLoading: false, error: null });
|
||||
},
|
||||
}));
|
||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import type { Database } from "@/types/supabase";
|
||||
import { useAuthStore } from "./authStore";
|
||||
import { useMatchStore } from "./matchStore";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
@@ -72,6 +73,14 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
// Get count of unread messages before marking them read
|
||||
const { count: unreadInMatch } = await supabase
|
||||
.from("messages")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("match_id", matchId)
|
||||
.neq("user_id", userId)
|
||||
.is("read_at", null);
|
||||
|
||||
await supabase
|
||||
.from("messages")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
@@ -79,8 +88,13 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
.neq("user_id", userId)
|
||||
.is("read_at", null);
|
||||
|
||||
// Refetch unread count
|
||||
// Refetch global unread count
|
||||
await get().fetchUnreadCount();
|
||||
|
||||
// Sync per-match unread count in matchStore
|
||||
if (unreadInMatch && unreadInMatch > 0) {
|
||||
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
|
||||
}
|
||||
},
|
||||
|
||||
clearMessages: () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ interface PacksState {
|
||||
isLoading: boolean;
|
||||
fetchPacks: () => Promise<void>;
|
||||
fetchEnabledPacks: () => Promise<void>;
|
||||
ensureEnabledPacksLoaded: () => Promise<void>;
|
||||
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
|
||||
clearPacks: () => void;
|
||||
}
|
||||
@@ -74,6 +75,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
set({ enabledPackIds: ids });
|
||||
},
|
||||
|
||||
ensureEnabledPacksLoaded: async () => {
|
||||
// Only fetch if not already loaded - lightweight check for swipe screen
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
if (!coupleId) return;
|
||||
|
||||
if (get().enabledPackIds.length === 0) {
|
||||
await get().fetchEnabledPacks();
|
||||
}
|
||||
},
|
||||
|
||||
togglePack: async (packId: string): Promise<{ success: boolean; reason?: string }> => {
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
if (!coupleId) {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
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<QuestionPack, "id" | "name" | "icon">;
|
||||
};
|
||||
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<string, unknown> | 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<void>;
|
||||
updateResponse: (
|
||||
questionId: string,
|
||||
newAnswer: AnswerType,
|
||||
confirmDelete?: boolean
|
||||
) => Promise<UpdateResponseResult>;
|
||||
setGroupBy: (groupBy: GroupByOption) => void;
|
||||
clearResponses: () => void;
|
||||
}
|
||||
|
||||
export const useResponsesStore = create<ResponsesState>((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<string, string>();
|
||||
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<string>(
|
||||
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<UpdateResponseResult> => {
|
||||
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<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 }];
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
// User profile (extends Supabase auth.users)
|
||||
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatar_url: string | null;
|
||||
push_token: string | null;
|
||||
is_premium: boolean;
|
||||
couple_id: string | null;
|
||||
gender: Gender | null;
|
||||
show_explicit_content: boolean;
|
||||
onboarding_completed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** RSA public key for E2EE (JWK format) */
|
||||
public_key_jwk?: Record<string, unknown> | null;
|
||||
}
|
||||
export interface Profile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatar_url: string | null;
|
||||
push_token: string | null;
|
||||
is_premium: boolean;
|
||||
couple_id: string | null;
|
||||
gender: Gender | null;
|
||||
show_explicit_content: boolean;
|
||||
onboarding_completed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** RSA public key for E2EE (JWK format) */
|
||||
public_key_jwk?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// Couple pairing
|
||||
export interface Couple {
|
||||
@@ -87,6 +87,7 @@ export interface Match {
|
||||
match_type: MatchType;
|
||||
is_new: boolean;
|
||||
created_at: string;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
// Extended match with question details
|
||||
|
||||
Reference in new issue
Block a user