diff --git a/apps/mobile/DATA_RETRIEVAL_TASKS.md b/apps/mobile/DATA_RETRIEVAL_TASKS.md new file mode 100644 index 0000000..d8b3347 --- /dev/null +++ b/apps/mobile/DATA_RETRIEVAL_TASKS.md @@ -0,0 +1,120 @@ +# Mobile data retrieval review: task list + +This task list targets the **Home**, **Swipe**, and **Matches** flows in `apps/mobile`, focusing on correctness, performance, and realtime consistency. + +## Overview (current state) + +- Data fetching is split between Zustand stores (`useMatchStore`, `usePacksStore`) and screen-local Supabase RPC calls (notably `SwipeScreen`). +- Realtime updates are centralized in `apps/mobile/app/(app)/_layout.tsx` (matches/messages/profile/pack-settings subscriptions). +- Main risks today: missing query filters & error/loading states, redundant fetches, state races on param changes, and unread-count drift between realtime and the matches list. + +## Scope + +- Screens + - Home: `apps/mobile/app/(app)/index.tsx` + - Swipe: `apps/mobile/app/(app)/swipe.tsx` + - Matches: `apps/mobile/app/(app)/matches.tsx` +- Stores + - Matches: `apps/mobile/src/store/matchStore.ts` + - Packs: `apps/mobile/src/store/packsStore.ts` + - Messages: `apps/mobile/src/store/messageStore.ts` +- Realtime orchestration + - `apps/mobile/app/(app)/_layout.tsx` + +## Priority 0 — correctness (must fix) + +- [ ] **Home: fix packs count source-of-truth** + - Current: `enabledPacksCount = packs.length` (counts available packs). + - Decide + implement: + - If label means *enabled*: use `enabledPackIds.length`. + - If label means *available*: rename the variable + label. + - Acceptance: packs number matches user expectations and store state. + +- [ ] **Swipe: normalize `packId` and reset state on param changes** + - Normalize `packId` from `useLocalSearchParams()` (`string | string[]`) before sending to RPC. + - Reset `currentIndex`, `isLoading`, and any derived UI state when `packId` changes. + - Acceptance: switching packs never shows stale index/loading/UI. + +- [ ] **Swipe: add race protection for overlapping async fetches** + - Use a request-id guard (or abort pattern) so older `fetchQuestions()` results cannot overwrite newer results. + - Acceptance: rapid navigation/focus changes never clobber newer state. + +- [ ] **Swipe: prevent stale “blocked” state on answer-gap errors** + - Ensure `checkAnswerGap()` clears/updates `isBlocked` + `gapInfo` deterministically even when RPC errors. + - Acceptance: blocked UI never “sticks” due to transient failures. + +## Priority 1 — UX and data freshness + +- [ ] **Home: ensure data freshness on revisit** + - Add `useFocusEffect` or a simple staleness TTL in stores. + - Acceptance: counts (matches/new/packs) refresh when returning to Home. + +- [ ] **Home: align “Recent Matches” semantics** + - `useMatchStore.fetchMatches()` sorts unread first; Home slices top 3. + - Decide: + - True recent by `created_at`, or + - “Top matches” by unread-first. + - Acceptance: section name and behavior match. + +- [ ] **Matches screen: wire up pull-to-refresh correctly** + - Add `isLoading`/`isRefreshing` + `error` to `useMatchStore`. + - Use those flags for `RefreshControl.refreshing`. + - Acceptance: pull-to-refresh shows spinner and recovers from errors. + +## Priority 2 — performance and query efficiency + +- [ ] **MatchStore: add explicit filtering and guard empty `in(...)` lists** + - Filter matches by `couple_id` (in addition to RLS) for clarity/perf. + - If no matches, avoid calling `.in('question_id', [])`. + - Acceptance: fewer noisy requests; no empty-list edge behavior. + +- [ ] **MatchStore: reduce unread-count work** + - Current approach fetches one row per unread message and counts on client. + - Options: + - Add RPC/view that returns per-match unread counts. + - Or store a denormalized `unread_count` per match (if acceptable). + - Acceptance: fetching matches scales with large message histories. + +- [ ] **Swipe: remove heavy dependency on `fetchPacks()`** + - Swipe often only needs `enabledPackIds`. + - Add lightweight `ensureEnabledPacksLoaded()` or call `fetchEnabledPacks()` directly. + - Acceptance: less data fetched when entering Swipe. + +- [ ] **Swipe: replace random comparator sort** + - Replace `Array.sort()` comparator using `Math.random()` with a stable score/shuffle pass. + - Acceptance: consistent ordering logic; avoids comparator contract violations. + +## Priority 3 — realtime correctness and consistency + +- [ ] **Realtime messages: tighten subscription filter** + - Current `messages` subscription listens without a `filter`. + - Improve by filtering to the user’s couple scope (schema permitting) or alternative channel strategy. + - Acceptance: avoids noise; reduces risk of unintended cross-couple events. + +- [ ] **Sync per-match unread counts with realtime events** + - Currently global unread count updates live; per-match counts update only on `fetchMatches()`. + - Options: + - Update `useMatchStore` unreadCount per match when realtime message arrives. + - Or trigger a targeted refresh when new partner message arrives. + - Acceptance: Matches list ordering/badges update as messages arrive. + +## Optional: structural improvements (bigger refactors) + +- [ ] Consolidate fetching into a query layer (e.g., TanStack Query) with caching, invalidation, retries, and focus refetch. +- [ ] Create a single “matches list” RPC that returns: + - match + question + - who-answered-first info (for two-part display) + - per-match unread counts + - new match flag +- [ ] Add lightweight integration tests around: + - `packId` parsing + - request race guards + - unread count updates + +## Suggested implementation order + +1. P0 correctness (Home packs count, Swipe param/reset/races, gap-state) +2. Matches loading/refresh UX +3. Query efficiency (couple filters, unread-count optimization) +4. Realtime tightening + unread sync +5. Optional refactors diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index c0e616b..6bbbea3 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -50,21 +50,21 @@ function MessageToastPreview({ currentUserId, }); - if (isDecrypting) { - return ( - - Waiting for message... - - ); - } - - if (error) { - return ( - - Encrypted message - - ); - } + if (isDecrypting) { + return ( + + Waiting for message... + + ); + } + + if (error) { + return ( + + Encrypted message + + ); + } return ( @@ -175,7 +175,7 @@ export default function AppLayout() { const router = useRouter(); const segments = useSegments(); const { isAuthenticated, isLoading, user, signOut } = useAuthStore(); - const { matches, newMatchesCount, addMatch } = useMatchStore(); + const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore(); const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore(); const { fetchEnabledPacks } = usePacksStore(); const { initializeRevenueCat } = useSubscriptionStore(); @@ -477,6 +477,9 @@ export default function AppLayout() { delivered_at: newMessage.delivered_at || new Date().toISOString(), match: { id: match.id, question: { text: questionText || "" } }, }); + + // Sync per-match unread count for realtime updates + updateMatchUnreadCount(match.id, 1); } } ) @@ -697,6 +700,12 @@ export default function AppLayout() { href: null, // Hide from tab bar }} /> + {/* Radial Menu Overlay */} diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index ec3c777..d6b66a1 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useCallback } from "react"; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native"; import Animated, { FadeInDown, @@ -13,7 +13,7 @@ import Animated, { import { LinearGradient } from "expo-linear-gradient"; import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store"; import { Ionicons } from "@expo/vector-icons"; -import { router } from "expo-router"; +import { router, useFocusEffect } from "expo-router"; import { GradientBackground, GlassCard } from "../../src/components/ui"; import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; @@ -23,17 +23,20 @@ const ACCENT_COLOR = colors.primary; export default function HomeScreen() { const { user, partner, couple } = useAuthStore(); const { matches, newMatchesCount, fetchMatches } = useMatchStore(); - const { packs, fetchPacks } = usePacksStore(); + const { enabledPackIds, fetchPacks } = usePacksStore(); const { width } = useWindowDimensions(); - useEffect(() => { - fetchMatches(); - fetchPacks(); - }, []); + // Refresh data when screen gains focus for freshness + useFocusEffect( + useCallback(() => { + fetchMatches(); + fetchPacks(); + }, []) + ); - const recentMatches = matches.slice(0, 3); + const topMatches = matches.slice(0, 3); const isWideScreen = width > MAX_CONTENT_WIDTH; - const enabledPacksCount = packs.length; + const enabledPacksCount = enabledPackIds.length; // Subtle pulsating animation for the CTA icon const pulseScale = useSharedValue(1); @@ -219,8 +222,8 @@ export default function HomeScreen() { - {/* Recent Matches */} - {recentMatches.length > 0 && ( + {/* Top Matches (sorted by unread first, then recent) */} + {topMatches.length > 0 && ( - Recent Matches + Top Matches router.push("/(app)/matches")} @@ -240,7 +243,7 @@ export default function HomeScreen() { - {recentMatches.map((match, index) => ( + {topMatches.map((match, index) => ( + {/* My Answers button */} + router.push("/(app)/my-answers")} + > + + + {/* Premium label */} DISCOVER @@ -229,7 +237,7 @@ export default function MatchesScreen() { showsVerticalScrollIndicator={false} refreshControl={ MAX_CONTENT_WIDTH; + + // State for edit sheet + const [selectedResponse, setSelectedResponse] = useState(null); + const [isEditSheetVisible, setIsEditSheetVisible] = useState(false); + + // Ambient orb breathing animations + const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); + + // Fetch responses on focus + useFocusEffect( + useCallback(() => { + fetchResponses(); + }, []) + ); + + const sections = groupResponses(responses, groupBy); + + const handleEditPress = (response: ResponseWithQuestion) => { + setSelectedResponse(response); + setIsEditSheetVisible(true); + }; + + const handleEditClose = () => { + setIsEditSheetVisible(false); + setSelectedResponse(null); + }; + + const handleEditSuccess = () => { + // Refetch responses to get updated data + fetchResponses(); + }; + + const renderSectionHeader = ({ section }: { section: { title: string } }) => ( + + {section.title} + + ); + + const renderItem = ({ item, index }: { item: ResponseWithQuestion; index: number }) => ( + handleEditPress(item)} + onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined} + /> + ); + + const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => ( + setGroupBy(option)} + > + + {label} + + + ); + + if (!user) { + return ( + + + + + + ); + } + + return ( + + {/* Ambient Orbs */} + + + + + + + + + {/* Header */} + + {/* Back button */} + router.back()}> + + + + + REVIEW + My Answers + + + {/* Count badge */} + + + + {responses.length} {responses.length === 1 ? "ANSWER" : "ANSWERS"} + + + + + {/* Group by buttons */} + + + + + + + + {isLoading && responses.length === 0 ? ( + + + + ) : ( + item.id} + contentContainerStyle={[styles.list, isWideScreen && styles.listWide]} + showsVerticalScrollIndicator={false} + stickySectionHeadersEnabled={false} + refreshControl={ + + } + ListEmptyComponent={ + + + + + No Answers Yet + + Start swiping on questions to build your answer history. You can + come back here to review and change your answers anytime. + + router.push("/(app)/swipe")} + style={{ marginTop: spacing.lg }} + > + Start Swiping + + + } + /> + )} + + + {/* Edit Response Sheet */} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + // Ambient orbs + ambientOrb: { + position: "absolute", + width: 300, + height: 300, + borderRadius: 150, + }, + orbTopRight: { + top: 60, + right: -40, + }, + orbBottomLeft: { + bottom: 180, + left: -40, + }, + orbGradient: { + width: "100%", + height: "100%", + borderRadius: 150, + }, + // Header + header: { + paddingTop: 60, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.md, + }, + headerWide: { + alignSelf: "center", + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + }, + backButton: { + position: "absolute", + top: 60, + left: spacing.md, + zIndex: 10, + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "rgba(255, 255, 255, 0.1)", + justifyContent: "center", + alignItems: "center", + }, + headerContent: { + alignItems: "center", + }, + headerLabel: { + ...typography.caption2, + fontWeight: "600", + letterSpacing: 3, + color: ACCENT, + marginBottom: spacing.xs, + }, + headerTitle: { + ...typography.largeTitle, + color: colors.text, + textAlign: "center", + }, + countBadgePremium: { + flexDirection: "row", + alignItems: "center", + backgroundColor: `${ACCENT_RGBA}0.1)`, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + borderWidth: 1, + borderColor: `${ACCENT_RGBA}0.2)`, + gap: spacing.xs, + }, + countTextPremium: { + ...typography.caption2, + fontWeight: "600", + letterSpacing: 2, + color: ACCENT, + }, + // Group by buttons + groupByContainer: { + flexDirection: "row", + justifyContent: "center", + gap: spacing.sm, + marginTop: spacing.md, + }, + groupByButton: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + }, + groupByButtonActive: { + backgroundColor: `${ACCENT_RGBA}0.15)`, + borderColor: `${ACCENT_RGBA}0.3)`, + }, + groupByText: { + ...typography.caption1, + color: colors.textSecondary, + fontWeight: "500", + }, + groupByTextActive: { + color: ACCENT, + fontWeight: "600", + }, + // Section header + sectionHeader: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.xs, + marginTop: spacing.md, + marginBottom: spacing.xs, + }, + sectionTitle: { + ...typography.title3, + color: colors.text, + }, + // List + list: { + padding: spacing.lg, + paddingTop: spacing.sm, + paddingBottom: Platform.OS === "ios" ? 120 : 100, + }, + listWide: { + alignSelf: "center", + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + }, + // Empty state + emptyContent: { + width: "100%", + alignItems: "center", + paddingHorizontal: spacing.md, + paddingVertical: spacing.xxl, + }, + emptyIcon: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: `${ACCENT_RGBA}0.1)`, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.lg, + }, + emptyTitle: { + ...typography.title2, + color: colors.text, + marginBottom: spacing.sm, + }, + emptyDescription: { + ...typography.body, + color: colors.textSecondary, + textAlign: "center", + paddingHorizontal: spacing.md, + }, +}); diff --git a/apps/mobile/app/(app)/swipe.tsx b/apps/mobile/app/(app)/swipe.tsx index 3a90c74..352e35f 100755 --- a/apps/mobile/app/(app)/swipe.tsx +++ b/apps/mobile/app/(app)/swipe.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native"; -import { useLocalSearchParams, router } from "expo-router"; +import { useLocalSearchParams, router, useFocusEffect } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; import Animated, { FadeIn, @@ -20,25 +20,28 @@ import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuesti import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen"; import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler"; // import SwipeCard from "../../src/components/SwipeCard"; -import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling -import { SwipeTutorial } from "../../src/components/tutorials"; -import { QuestionFeedbackModal } from "../../src/components/feedback"; -import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui"; +import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling +import { SwipeTutorial } from "../../src/components/tutorials"; +import { QuestionFeedbackModal } from "../../src/components/feedback"; +import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui"; import { Events } from "../../src/lib/analytics"; import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme"; export default function SwipeScreen() { - const { packId } = useLocalSearchParams(); - const [questions, setQuestions] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [currentIndex, setCurrentIndex] = useState(0); - const [showTutorial, setShowTutorial] = useState(false); - const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null); - const [isBlocked, setIsBlocked] = useState(false); - const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null); - const { enabledPackIds, fetchPacks } = usePacksStore(); + const params = useLocalSearchParams(); + // Normalize packId - useLocalSearchParams can return string | string[] + const packId = Array.isArray(params.packId) ? params.packId[0] : params.packId; + const [questions, setQuestions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [currentIndex, setCurrentIndex] = useState(0); + const [showTutorial, setShowTutorial] = useState(false); + const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null); + const [isBlocked, setIsBlocked] = useState(false); + const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null); + const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore(); const { partner, couple } = useAuthStore(); const hasTrackedExhausted = useRef(false); + const fetchRequestId = useRef(0); // Ambient orb breathing animations const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); @@ -61,12 +64,34 @@ export default function SwipeScreen() { ], })); + const isFirstMount = useRef(true); + useEffect(() => { - fetchPacks().then(() => fetchQuestions()); + // Reset state when pack changes + setCurrentIndex(0); + setIsLoading(true); + setIsBlocked(false); + setGapInfo(null); + hasTrackedExhausted.current = false; + + // Use lightweight ensureEnabledPacksLoaded instead of full fetchPacks + ensureEnabledPacksLoaded().then(() => fetchQuestions()); checkTutorial(); - hasTrackedExhausted.current = false; // Reset when pack changes }, [packId]); + // Refresh questions when screen gains focus (handles partner answering while away) + useFocusEffect( + useCallback(() => { + // Skip on first mount since useEffect already fetches + if (isFirstMount.current) { + isFirstMount.current = false; + return; + } + // Silently refetch to get updated partner_answered status + fetchQuestions(); + }, [packId]) + ); + // Check if user is too far ahead of partner (called when questions empty or after swipe) const checkAnswerGap = async (): Promise => { if (!couple || !partner) { @@ -80,6 +105,9 @@ export default function SwipeScreen() { if (error) { console.error('Error checking answer gap:', error); + // Clear blocked state on error so user isn't stuck + setIsBlocked(false); + setGapInfo(null); return false; } @@ -97,10 +125,18 @@ export default function SwipeScreen() { } return result.is_blocked; } + + // No data returned - clear blocked state + setIsBlocked(false); + setGapInfo(null); + return false; } catch (error) { console.error('Failed to check answer gap:', error); + // Clear blocked state on error so user isn't stuck + setIsBlocked(false); + setGapInfo(null); + return false; } - return false; }; // Calculate effective total questions (accounting for gap limit) @@ -150,7 +186,20 @@ export default function SwipeScreen() { } }, [enabledPackIds]); + // Fisher-Yates shuffle for stable randomization + const shuffleArray = (array: T[]): T[] => { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; + }; + const fetchQuestions = async () => { + // Race protection: increment request ID and track current request + const currentRequestId = ++fetchRequestId.current; + try { const [{ data, error }, skippedIds] = await Promise.all([ supabase.rpc("get_recommended_questions", { @@ -159,6 +208,11 @@ export default function SwipeScreen() { getSkippedQuestionIds() ]); + // Race guard: if a newer request started, discard this result + if (currentRequestId !== fetchRequestId.current) { + return; + } + if (error) { console.error("Error fetching recommended questions:", error); throw error; @@ -177,9 +231,11 @@ export default function SwipeScreen() { setGapInfo(null); } - const sorted = filtered.sort((a: any, b: any) => { - let scoreA = Math.random(); - let scoreB = Math.random(); + // First shuffle to randomize, then stable sort by priority scores + const shuffled = shuffleArray(filtered); + const sorted = shuffled.sort((a: any, b: any) => { + let scoreA = 0; + let scoreB = 0; if (a.partner_answered && a.is_two_part) scoreA += 1.5; else if (a.partner_answered) scoreA += 0.7; @@ -192,11 +248,18 @@ export default function SwipeScreen() { return scoreB - scoreA; }); - setQuestions(sorted); + // Only update state if still the current request + if (currentRequestId === fetchRequestId.current) { + setQuestions(sorted); + } } catch (error) { - console.error(error); + if (currentRequestId === fetchRequestId.current) { + console.error(error); + } } finally { - setIsLoading(false); + if (currentRequestId === fetchRequestId.current) { + setIsLoading(false); + } } }; @@ -574,17 +637,17 @@ export default function SwipeScreen() { )} - {/* Active card */} - setFeedbackQuestion({ - id: questions[currentIndex].id, - text: questions[currentIndex].text - })} - /> - + {/* Active card */} + setFeedbackQuestion({ + id: questions[currentIndex].id, + text: questions[currentIndex].text + })} + /> + {/* Hint - Premium styling */} - {/* Tutorial Overlay */} - {showTutorial && ( - - )} - - {/* Question Feedback Modal */} - setFeedbackQuestion(null)} - questionId={feedbackQuestion?.id || ''} - questionText={feedbackQuestion?.text || ''} - /> - - ); -} + {/* Tutorial Overlay */} + {showTutorial && ( + + )} + + {/* Question Feedback Modal */} + setFeedbackQuestion(null)} + questionId={feedbackQuestion?.id || ''} + questionText={feedbackQuestion?.text || ''} + /> + + ); +} const styles = StyleSheet.create({ container: { diff --git a/apps/mobile/src/__tests__/stores/matchStore.test.ts b/apps/mobile/src/__tests__/stores/matchStore.test.ts index 9f6798d..19331fe 100644 --- a/apps/mobile/src/__tests__/stores/matchStore.test.ts +++ b/apps/mobile/src/__tests__/stores/matchStore.test.ts @@ -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 () => { diff --git a/apps/mobile/src/__tests__/stores/messageStore.test.ts b/apps/mobile/src/__tests__/stores/messageStore.test.ts index fdadec9..2f4afee 100644 --- a/apps/mobile/src/__tests__/stores/messageStore.test.ts +++ b/apps/mobile/src/__tests__/stores/messageStore.test.ts @@ -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'); diff --git a/apps/mobile/src/components/responses/DeleteMatchConfirmModal.tsx b/apps/mobile/src/components/responses/DeleteMatchConfirmModal.tsx new file mode 100644 index 0000000..2ad7cfc --- /dev/null +++ b/apps/mobile/src/components/responses/DeleteMatchConfirmModal.tsx @@ -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 ( + + + + + + + + {Platform.OS === "ios" ? ( + + + + ) : ( + + + + )} + + + + ); +} + +function ModalContent({ + messageText, + onConfirm, + onCancel, +}: { + messageText: string; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + <> + {/* Warning icon */} + + + + + {/* Title */} + Delete Match? + + {/* Message */} + + Changing your answer to "No" will remove this match permanently.{"\n\n"} + {messageText} + + + {/* Buttons */} + + + Keep Match + + + + + Delete Match + + + + ); +} + +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", + }, +}); diff --git a/apps/mobile/src/components/responses/EditResponseSheet.tsx b/apps/mobile/src/components/responses/EditResponseSheet.tsx new file mode 100644 index 0000000..79e916c --- /dev/null +++ b/apps/mobile/src/components/responses/EditResponseSheet.tsx @@ -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(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 ( + handleAnswerPress(answer)} + disabled={isLoading} + activeOpacity={0.7} + > + {isPending && isLoading ? ( + + ) : ( + + )} + + {config.label} + + {isSelected && ( + + + + )} + + ); + }; + + return ( + + + + + + + + {Platform.OS === "ios" ? ( + + + + ) : ( + + + + )} + + + + {/* Confirmation 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 */} + + + {/* Header */} + + Edit Answer + + + + + + {/* Question text */} + + {response.question.text} + + + {response.question.pack.name} + + + + {/* Warning for matched questions */} + {response.has_match && ( + + + + This question has a match. Changing to "No" will delete the match and + all messages. + + + )} + + {/* Answer buttons */} + + + + + + + {/* Bottom padding for safe area */} + + + ); +} + +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, + }, +}); diff --git a/apps/mobile/src/components/responses/ResponseCard.tsx b/apps/mobile/src/components/responses/ResponseCard.tsx new file mode 100644 index 0000000..1146b68 --- /dev/null +++ b/apps/mobile/src/components/responses/ResponseCard.tsx @@ -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 ( + + + {/* Subtle gradient background */} + + {/* Top silk highlight */} + + + + {/* Answer badge */} + + + + + {/* Content */} + + + {response.question.text} + + + + {/* Answer tag */} + + + {answerStyle.label} + + + + {/* Pack name */} + + + + {response.question.pack.name} + + + + {/* Partner status indicator */} + {response.partner_answered && ( + + + + )} + + + + {/* Right section */} + + {/* Match indicator with chat link */} + {response.has_match && onChatPress && ( + + + + )} + + {/* Edit button */} + + + + + + + {/* Premium border */} + + + + ); +} + +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", + }, +}); diff --git a/apps/mobile/src/components/responses/index.ts b/apps/mobile/src/components/responses/index.ts new file mode 100644 index 0000000..7bf1fad --- /dev/null +++ b/apps/mobile/src/components/responses/index.ts @@ -0,0 +1,3 @@ +export { ResponseCard } from "./ResponseCard"; +export { EditResponseSheet } from "./EditResponseSheet"; +export { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal"; diff --git a/apps/mobile/src/components/tutorials/MatchesTutorial.tsx b/apps/mobile/src/components/tutorials/MatchesTutorial.tsx index 64b542f..cb80f65 100644 --- a/apps/mobile/src/components/tutorials/MatchesTutorial.tsx +++ b/apps/mobile/src/components/tutorials/MatchesTutorial.tsx @@ -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) { )} + + {/* Edit answers demo - show on fourth step */} + {currentStep === 3 && ( + + + + + "Try something new together" + + + + YES + + + + + + + + + + + + + "Try something new together" + + + + MAYBE + + + + + )} @@ -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, diff --git a/apps/mobile/src/features/chat/components/ChatHeader.tsx b/apps/mobile/src/features/chat/components/ChatHeader.tsx index e85c9dc..1920411 100644 --- a/apps/mobile/src/features/chat/components/ChatHeader.tsx +++ b/apps/mobile/src/features/chat/components/ChatHeader.tsx @@ -137,51 +137,64 @@ export const ChatHeader: React.FC = ({ marginVertical={spacing.md} /> - {/* Question Text */} - - "{match.question.text}" - - {match.question.partner_text && ( - - "{match.question.partner_text}" - - )} - - {/* Response Pills */} - {match.responses && match.responses.length > 0 && ( - - {match.responses.map((response: any) => { - const isUser = response.user_id === user?.id; - const isYes = response.answer === 'yes'; - return ( - - {isYes ? ( - - - - {isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')} - - - ) : ( - - - - {isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')} - - - )} - + {/* Question and Responses - Show who answered what */} + {match.responses && match.responses.length > 0 ? ( + + {(() => { + // 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 ( + + {/* Name and Answer Badge */} + + {name} + {isYes ? ( + + + YES + + ) : ( + + + MAYBE + + )} + + {/* Question they saw */} + + "{questionText}" + + + ); + }); + })()} + ) : ( + + "{match.question.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', diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts index 52480a5..638407d 100755 --- a/apps/mobile/src/store/index.ts +++ b/apps/mobile/src/store/index.ts @@ -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"; diff --git a/apps/mobile/src/store/matchStore.ts b/apps/mobile/src/store/matchStore.ts index 0bb8d6d..31d2131 100644 --- a/apps/mobile/src/store/matchStore.ts +++ b/apps/mobile/src/store/matchStore.ts @@ -6,77 +6,105 @@ import { useAuthStore } from "./authStore"; interface MatchState { matches: Match[]; newMatchesCount: number; + isLoading: boolean; + error: string | null; fetchMatches: () => Promise; markAsSeen: (matchId: string) => Promise; markAllAsSeen: () => Promise; addMatch: (match: Match) => void; + updateMatchUnreadCount: (matchId: string, delta: number) => void; clearMatches: () => void; } export const useMatchStore = create((set, get) => ({ matches: [], newMatchesCount: 0, + isLoading: false, + error: null, fetchMatches: async () => { const userId = useAuthStore.getState().user?.id; + const coupleId = useAuthStore.getState().user?.couple_id; - 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 = {}; - - 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 = {}; + + 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((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 }); }, })); diff --git a/apps/mobile/src/store/messageStore.ts b/apps/mobile/src/store/messageStore.ts index 1192f99..82244b1 100644 --- a/apps/mobile/src/store/messageStore.ts +++ b/apps/mobile/src/store/messageStore.ts @@ -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((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((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: () => { diff --git a/apps/mobile/src/store/packsStore.ts b/apps/mobile/src/store/packsStore.ts index 8a3ce30..d1ba87f 100644 --- a/apps/mobile/src/store/packsStore.ts +++ b/apps/mobile/src/store/packsStore.ts @@ -11,6 +11,7 @@ interface PacksState { isLoading: boolean; fetchPacks: () => Promise; fetchEnabledPacks: () => Promise; + ensureEnabledPacksLoaded: () => Promise; togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>; clearPacks: () => void; } @@ -74,6 +75,16 @@ export const usePacksStore = create((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) { diff --git a/apps/mobile/src/store/responsesStore.ts b/apps/mobile/src/store/responsesStore.ts new file mode 100644 index 0000000..cb30705 --- /dev/null +++ b/apps/mobile/src/store/responsesStore.ts @@ -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; + }; + has_match: boolean; + match_id?: string; + partner_answered: boolean; +} + +// Result from update-response edge function +export interface UpdateResponseResult { + success: boolean; + requires_confirmation?: boolean; + match_id?: string; + message_count?: number; + new_match?: Record | null; + match_deleted?: boolean; + match_type_updated?: boolean; + error?: string; +} + +export type GroupByOption = "pack" | "date" | "answer"; + +interface ResponsesState { + responses: ResponseWithQuestion[]; + isLoading: boolean; + groupBy: GroupByOption; + fetchResponses: () => Promise; + updateResponse: ( + questionId: string, + newAnswer: AnswerType, + confirmDelete?: boolean + ) => Promise; + setGroupBy: (groupBy: GroupByOption) => void; + clearResponses: () => void; +} + +export const useResponsesStore = create((set, get) => ({ + responses: [], + isLoading: false, + groupBy: "pack", + + fetchResponses: async () => { + const userId = useAuthStore.getState().user?.id; + const coupleId = useAuthStore.getState().user?.couple_id; + + if (!userId || !coupleId) return; + + set({ isLoading: true }); + + try { + // Fetch all user's responses with questions and packs + const { data: responses, error: responsesError } = await supabase + .from("responses") + .select(` + id, + question_id, + answer, + created_at, + question:questions( + id, + text, + partner_text, + intensity, + pack_id, + pack:question_packs(id, name, icon) + ) + `) + .eq("user_id", userId) + .order("created_at", { ascending: false }); + + if (responsesError) { + console.error("Error fetching responses:", responsesError); + set({ isLoading: false }); + return; + } + + if (!responses) { + set({ responses: [], isLoading: false }); + return; + } + + // Get question IDs for match lookup + const questionIds = responses.map((r) => r.question_id); + + // Fetch matches for these questions + const { data: matches } = await supabase + .from("matches") + .select("id, question_id") + .eq("couple_id", coupleId) + .in("question_id", questionIds); + + // Create a map of question_id -> match_id + const matchMap = new Map(); + matches?.forEach((m) => matchMap.set(m.question_id, m.id)); + + // Fetch partner's responses to check if they've answered + const { data: partnerResponses } = await supabase + .from("responses") + .select("question_id") + .eq("couple_id", coupleId) + .neq("user_id", userId) + .in("question_id", questionIds); + + // Create a set of question IDs that partner has answered + const partnerAnsweredSet = new Set( + partnerResponses?.map((r) => r.question_id) || [] + ); + + // Transform responses with match and partner info + const transformedResponses: ResponseWithQuestion[] = responses + .filter((r) => { + // Supabase returns single relations as objects, but TypeScript may infer arrays + const question = r.question as any; + return question && question.pack; + }) + .map((r) => { + // Cast to any to handle Supabase's nested select typing + const question = r.question as any; + const pack = question.pack as any; + + return { + id: r.id, + question_id: r.question_id, + answer: r.answer as AnswerType, + created_at: r.created_at, + question: { + id: question.id, + text: question.text, + partner_text: question.partner_text, + intensity: question.intensity, + pack_id: question.pack_id, + created_at: question.created_at || "", + pack: { + id: pack.id, + name: pack.name, + icon: pack.icon, + }, + }, + has_match: matchMap.has(r.question_id), + match_id: matchMap.get(r.question_id), + partner_answered: partnerAnsweredSet.has(r.question_id), + }; + }); + + set({ responses: transformedResponses, isLoading: false }); + } catch (error) { + console.error("Error in fetchResponses:", error); + set({ isLoading: false }); + } + }, + + updateResponse: async ( + questionId: string, + newAnswer: AnswerType, + confirmDelete = false + ): Promise => { + const { data, error } = await invokeWithAuthRetry("update-response", { + body: { + question_id: questionId, + new_answer: newAnswer, + confirm_delete_match: confirmDelete, + }, + }); + + if (error) { + console.error("Error updating response:", error); + return { success: false, error: error.message || "Failed to update response" }; + } + + const result = data as UpdateResponseResult; + + // If update was successful (not just requiring confirmation), update local state + if (result.success && !result.requires_confirmation) { + // Update local response state + set((state) => ({ + responses: state.responses.map((r) => { + if (r.question_id === questionId) { + return { + ...r, + answer: newAnswer, + // Update match status based on result + has_match: result.match_deleted + ? false + : result.new_match + ? true + : r.has_match, + match_id: result.match_deleted + ? undefined + : result.new_match + ? (result.new_match.id as string) + : r.match_id, + }; + } + return r; + }), + })); + + // Refresh matches store if match was created, deleted, or type updated + if (result.match_deleted || result.new_match || result.match_type_updated) { + useMatchStore.getState().fetchMatches(); + } + } + + return result; + }, + + setGroupBy: (groupBy: GroupByOption) => { + set({ groupBy }); + }, + + clearResponses: () => { + set({ responses: [], isLoading: false, groupBy: "pack" }); + }, +})); + +// Helper function to group responses +export function groupResponses( + responses: ResponseWithQuestion[], + groupBy: GroupByOption +): { title: string; data: ResponseWithQuestion[] }[] { + if (groupBy === "pack") { + // Group by pack name + const groups = new Map(); + responses.forEach((r) => { + const packName = r.question.pack.name; + if (!groups.has(packName)) { + groups.set(packName, []); + } + groups.get(packName)!.push(r); + }); + return Array.from(groups.entries()).map(([title, data]) => ({ title, data })); + } + + if (groupBy === "answer") { + // Group by answer type + const yesResponses = responses.filter((r) => r.answer === "yes"); + const maybeResponses = responses.filter((r) => r.answer === "maybe"); + const noResponses = responses.filter((r) => r.answer === "no"); + + const sections = []; + if (yesResponses.length > 0) sections.push({ title: "Yes", data: yesResponses }); + if (maybeResponses.length > 0) sections.push({ title: "Maybe", data: maybeResponses }); + if (noResponses.length > 0) sections.push({ title: "No", data: noResponses }); + return sections; + } + + if (groupBy === "date") { + // Group by date (day) + const groups = new Map(); + responses.forEach((r) => { + const date = new Date(r.created_at).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + if (!groups.has(date)) { + groups.set(date, []); + } + groups.get(date)!.push(r); + }); + return Array.from(groups.entries()).map(([title, data]) => ({ title, data })); + } + + return [{ title: "All Responses", data: responses }]; +} diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts index e3bd1a6..2d726f3 100755 --- a/apps/mobile/src/types/index.ts +++ b/apps/mobile/src/types/index.ts @@ -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 | 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 | 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 diff --git a/apps/supabase/functions/update-response/index.ts b/apps/supabase/functions/update-response/index.ts new file mode 100644 index 0000000..2b65524 --- /dev/null +++ b/apps/supabase/functions/update-response/index.ts @@ -0,0 +1,279 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +type Answer = "yes" | "no" | "maybe"; +type MatchType = "yes_yes" | "yes_maybe" | "maybe_maybe"; + +interface UpdateResponseBody { + question_id: string; + new_answer: Answer; + confirm_delete_match?: boolean; +} + +interface UpdateResponseResult { + success: boolean; + requires_confirmation?: boolean; + match_id?: string; + message_count?: number; + new_match?: Record | null; + match_deleted?: boolean; + match_type_updated?: boolean; +} + +function calculateMatch(a1: Answer, a2: Answer): MatchType | null { + if (a1 === "no" || a2 === "no") return null; + if (a1 === "yes" && a2 === "yes") return "yes_yes"; + if (a1 === "maybe" && a2 === "maybe") return "maybe_maybe"; + return "yes_maybe"; +} + +Deno.serve(async (req) => { + // Handle CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + // Get user from JWT + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return new Response( + JSON.stringify({ error: "Missing authorization header" }), + { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + const { data: { user }, error: authError } = await supabase.auth.getUser( + authHeader.replace("Bearer ", "") + ); + + if (authError || !user) { + return new Response( + JSON.stringify({ error: "Invalid token" }), + { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Parse request body + const { question_id, new_answer, confirm_delete_match }: UpdateResponseBody = await req.json(); + + if (!question_id || !new_answer) { + return new Response( + JSON.stringify({ error: "Missing question_id or new_answer" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Validate answer + if (!["yes", "no", "maybe"].includes(new_answer)) { + return new Response( + JSON.stringify({ error: "Invalid answer. Must be yes, no, or maybe" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Get user's profile and couple + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("couple_id") + .eq("id", user.id) + .single(); + + if (profileError || !profile?.couple_id) { + return new Response( + JSON.stringify({ error: "User is not in a couple" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Get current response + const { data: currentResponse, error: currentResponseError } = await supabase + .from("responses") + .select("answer") + .eq("user_id", user.id) + .eq("question_id", question_id) + .maybeSingle(); + + if (currentResponseError) { + return new Response( + JSON.stringify({ error: "Failed to get current response" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + if (!currentResponse) { + return new Response( + JSON.stringify({ error: "No existing response found for this question" }), + { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // If answer hasn't changed, return early + if (currentResponse.answer === new_answer) { + return new Response( + JSON.stringify({ success: true, message: "Answer unchanged" } as UpdateResponseResult), + { headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Check if a match exists for this question + const { data: existingMatch } = await supabase + .from("matches") + .select("id") + .eq("couple_id", profile.couple_id) + .eq("question_id", question_id) + .maybeSingle(); + + // Get partner's response + const { data: partnerResponse } = await supabase + .from("responses") + .select("answer") + .eq("couple_id", profile.couple_id) + .eq("question_id", question_id) + .neq("user_id", user.id) + .maybeSingle(); + + const result: UpdateResponseResult = { success: true }; + + // Case 1: Changing to "no" AND match exists - requires confirmation + if (new_answer === "no" && existingMatch) { + // Get message count for confirmation dialog + const { count: messageCount } = await supabase + .from("messages") + .select("*", { count: "exact", head: true }) + .eq("match_id", existingMatch.id); + + if (!confirm_delete_match) { + // Return confirmation required + return new Response( + JSON.stringify({ + success: false, + requires_confirmation: true, + match_id: existingMatch.id, + message_count: messageCount || 0, + } as UpdateResponseResult), + { headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // User confirmed deletion - delete media from storage + const { data: files } = await supabase.storage + .from("chat-media") + .list(existingMatch.id); + + if (files && files.length > 0) { + const filePaths = files.map(f => `${existingMatch.id}/${f.name}`); + await supabase.storage + .from("chat-media") + .remove(filePaths); + } + + // Delete the match (messages cascade automatically via FK) + const { error: deleteMatchError } = await supabase + .from("matches") + .delete() + .eq("id", existingMatch.id); + + if (deleteMatchError) { + console.error("Failed to delete match:", deleteMatchError); + return new Response( + JSON.stringify({ error: "Failed to delete match" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + result.match_deleted = true; + } + + // Case 2: Changing FROM "no" to yes/maybe - may create a new match + if (currentResponse.answer === "no" && new_answer !== "no" && partnerResponse) { + const newMatchType = calculateMatch(new_answer, partnerResponse.answer as Answer); + + if (newMatchType) { + // Create new match + const { data: newMatch, error: matchError } = await supabase + .from("matches") + .upsert( + { + couple_id: profile.couple_id, + question_id, + match_type: newMatchType, + is_new: true, + }, + { onConflict: "couple_id,question_id" } + ) + .select(`*, question:questions(*)`) + .single(); + + if (!matchError && newMatch) { + result.new_match = newMatch; + + // Trigger push notification for new match + try { + await supabase.functions.invoke("send-notification", { + body: { couple_id: profile.couple_id, match_id: newMatch.id }, + }); + } catch (notifyError) { + console.error("Failed to send notification:", notifyError); + } + } + } + } + + // Case 3: Changing between yes/maybe - may update match type + if ( + currentResponse.answer !== "no" && + new_answer !== "no" && + existingMatch && + partnerResponse + ) { + const newMatchType = calculateMatch(new_answer, partnerResponse.answer as Answer); + + if (newMatchType) { + const { error: updateMatchError } = await supabase + .from("matches") + .update({ match_type: newMatchType }) + .eq("id", existingMatch.id); + + if (!updateMatchError) { + result.match_type_updated = true; + } + } + } + + // Update the response + const { error: updateError } = await supabase + .from("responses") + .update({ answer: new_answer }) + .eq("user_id", user.id) + .eq("question_id", question_id); + + if (updateError) { + return new Response( + JSON.stringify({ error: "Failed to update response", details: updateError }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + return new Response( + JSON.stringify(result), + { headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Error:", error); + return new Response( + JSON.stringify({ error: "Internal server error" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } +});