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
+2316
-100
No files matched your search
@@ -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
|
||||||
@@ -175,7 +175,7 @@ export default function AppLayout() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
|
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 { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||||
const { fetchEnabledPacks } = usePacksStore();
|
const { fetchEnabledPacks } = usePacksStore();
|
||||||
const { initializeRevenueCat } = useSubscriptionStore();
|
const { initializeRevenueCat } = useSubscriptionStore();
|
||||||
@@ -477,6 +477,9 @@ export default function AppLayout() {
|
|||||||
delivered_at: newMessage.delivered_at || new Date().toISOString(),
|
delivered_at: newMessage.delivered_at || new Date().toISOString(),
|
||||||
match: { id: match.id, question: { text: questionText || "" } },
|
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
|
href: null, // Hide from tab bar
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="my-answers"
|
||||||
|
options={{
|
||||||
|
href: null, // Hide from tab bar
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Radial Menu Overlay */}
|
{/* Radial Menu Overlay */}
|
||||||
|
|||||||
@@ -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 { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeInDown,
|
FadeInDown,
|
||||||
@@ -13,7 +13,7 @@ import Animated, {
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
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 { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||||
|
|
||||||
@@ -23,17 +23,20 @@ const ACCENT_COLOR = colors.primary;
|
|||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { user, partner, couple } = useAuthStore();
|
const { user, partner, couple } = useAuthStore();
|
||||||
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
||||||
const { packs, fetchPacks } = usePacksStore();
|
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
|
|
||||||
useEffect(() => {
|
// Refresh data when screen gains focus for freshness
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
fetchMatches();
|
fetchMatches();
|
||||||
fetchPacks();
|
fetchPacks();
|
||||||
}, []);
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
const recentMatches = matches.slice(0, 3);
|
const topMatches = matches.slice(0, 3);
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
const enabledPacksCount = packs.length;
|
const enabledPacksCount = enabledPackIds.length;
|
||||||
|
|
||||||
// Subtle pulsating animation for the CTA icon
|
// Subtle pulsating animation for the CTA icon
|
||||||
const pulseScale = useSharedValue(1);
|
const pulseScale = useSharedValue(1);
|
||||||
@@ -219,8 +222,8 @@ export default function HomeScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Recent Matches */}
|
{/* Top Matches (sorted by unread first, then recent) */}
|
||||||
{recentMatches.length > 0 && (
|
{topMatches.length > 0 && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(400).duration(500)}
|
entering={FadeInDown.delay(400).duration(500)}
|
||||||
style={styles.section}
|
style={styles.section}
|
||||||
@@ -230,7 +233,7 @@ export default function HomeScreen() {
|
|||||||
<View style={styles.sectionIcon}>
|
<View style={styles.sectionIcon}>
|
||||||
<Ionicons name="heart" size={14} color={ACCENT_COLOR} />
|
<Ionicons name="heart" size={14} color={ACCENT_COLOR} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.sectionTitle}>Recent Matches</Text>
|
<Text style={styles.sectionTitle}>Top Matches</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.push("/(app)/matches")}
|
onPress={() => router.push("/(app)/matches")}
|
||||||
@@ -240,7 +243,7 @@ export default function HomeScreen() {
|
|||||||
<Ionicons name="chevron-forward" size={14} color={ACCENT_COLOR} />
|
<Ionicons name="chevron-forward" size={14} color={ACCENT_COLOR} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
{recentMatches.map((match, index) => (
|
{topMatches.map((match, index) => (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
key={match.id}
|
key={match.id}
|
||||||
entering={FadeInRight.delay(450 + index * 100).duration(400)}
|
entering={FadeInRight.delay(450 + index * 100).duration(400)}
|
||||||
@@ -272,7 +275,7 @@ export default function HomeScreen() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tips when no matches */}
|
{/* Tips when no matches */}
|
||||||
{recentMatches.length === 0 && (
|
{topMatches.length === 0 && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(400).duration(500)}
|
entering={FadeInDown.delay(400).duration(500)}
|
||||||
style={styles.section}
|
style={styles.section}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const ROSE_RGBA = 'rgba(232, 164, 174, ';
|
|||||||
const MAX_CONTENT_WIDTH = 500;
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
|
|
||||||
export default function MatchesScreen() {
|
export default function MatchesScreen() {
|
||||||
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
|
const { matches, fetchMatches, markAllAsSeen, isLoading } = useMatchStore();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
@@ -205,6 +205,14 @@ export default function MatchesScreen() {
|
|||||||
entering={FadeIn.duration(400)}
|
entering={FadeIn.duration(400)}
|
||||||
style={[styles.header, isWideScreen && styles.headerWide]}
|
style={[styles.header, isWideScreen && styles.headerWide]}
|
||||||
>
|
>
|
||||||
|
{/* My Answers button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.myAnswersButton}
|
||||||
|
onPress={() => router.push("/(app)/my-answers")}
|
||||||
|
>
|
||||||
|
<Ionicons name="list-outline" size={20} color={ACCENT} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.headerContent}>
|
<View style={styles.headerContent}>
|
||||||
{/* Premium label */}
|
{/* Premium label */}
|
||||||
<Text style={styles.headerLabel}>DISCOVER</Text>
|
<Text style={styles.headerLabel}>DISCOVER</Text>
|
||||||
@@ -229,7 +237,7 @@ export default function MatchesScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={false}
|
refreshing={isLoading}
|
||||||
onRefresh={fetchMatches}
|
onRefresh={fetchMatches}
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
colors={[colors.primary]}
|
colors={[colors.primary]}
|
||||||
@@ -323,6 +331,20 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: MAX_CONTENT_WIDTH,
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
},
|
},
|
||||||
|
myAnswersButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 60,
|
||||||
|
right: spacing.md,
|
||||||
|
zIndex: 10,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: `${ACCENT_RGBA}0.2)`,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
headerContent: {
|
headerContent: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
SectionList,
|
||||||
|
RefreshControl,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
|
useWindowDimensions,
|
||||||
|
} from "react-native";
|
||||||
|
import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store";
|
||||||
|
import type { ResponseWithQuestion } from "../../src/store";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
|
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||||
|
import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||||
|
import { useAmbientOrbAnimation } from "../../src/hooks";
|
||||||
|
import { colors, spacing, typography, radius } from "../../src/theme";
|
||||||
|
import { ResponseCard } from "../../src/components/responses/ResponseCard";
|
||||||
|
import { EditResponseSheet } from "../../src/components/responses/EditResponseSheet";
|
||||||
|
|
||||||
|
// Premium color palette
|
||||||
|
const ACCENT = colors.premium.gold;
|
||||||
|
const ACCENT_RGBA = "rgba(212, 175, 55, ";
|
||||||
|
const ROSE = colors.premium.rose;
|
||||||
|
const ROSE_RGBA = "rgba(232, 164, 174, ";
|
||||||
|
|
||||||
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
|
|
||||||
|
type GroupByOption = "pack" | "date" | "answer";
|
||||||
|
|
||||||
|
export default function MyAnswersScreen() {
|
||||||
|
const { responses, isLoading, groupBy, fetchResponses, setGroupBy } = useResponsesStore();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const { width } = useWindowDimensions();
|
||||||
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
|
||||||
|
// State for edit sheet
|
||||||
|
const [selectedResponse, setSelectedResponse] = useState<ResponseWithQuestion | null>(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 } }) => (
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItem = ({ item, index }: { item: ResponseWithQuestion; index: number }) => (
|
||||||
|
<ResponseCard
|
||||||
|
response={item}
|
||||||
|
index={index}
|
||||||
|
onEditPress={() => handleEditPress(item)}
|
||||||
|
onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
|
||||||
|
onPress={() => setGroupBy(option)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator color={colors.primary} size="large" />
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
{/* Ambient Orbs */}
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]}
|
||||||
|
pointerEvents="none"
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.premium.goldGlow, "transparent"]}
|
||||||
|
style={styles.orbGradient}
|
||||||
|
start={{ x: 0.5, y: 0.5 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]}
|
||||||
|
pointerEvents="none"
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[`${ROSE_RGBA}0.2)`, "transparent"]}
|
||||||
|
style={styles.orbGradient}
|
||||||
|
start={{ x: 0.5, y: 0.5 }}
|
||||||
|
end={{ x: 0, y: 0 }}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(400)}
|
||||||
|
style={[styles.header, isWideScreen && styles.headerWide]}
|
||||||
|
>
|
||||||
|
{/* Back button */}
|
||||||
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.headerContent}>
|
||||||
|
<Text style={styles.headerLabel}>REVIEW</Text>
|
||||||
|
<Text style={styles.headerTitle}>My Answers</Text>
|
||||||
|
<DecorativeSeparator variant="gold" />
|
||||||
|
|
||||||
|
{/* Count badge */}
|
||||||
|
<View style={styles.countBadgePremium}>
|
||||||
|
<Ionicons name="checkmark-circle" size={12} color={ACCENT} />
|
||||||
|
<Text style={styles.countTextPremium}>
|
||||||
|
{responses.length} {responses.length === 1 ? "ANSWER" : "ANSWERS"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Group by buttons */}
|
||||||
|
<View style={styles.groupByContainer}>
|
||||||
|
<GroupByButton option="pack" label="Pack" />
|
||||||
|
<GroupByButton option="date" label="Date" />
|
||||||
|
<GroupByButton option="answer" label="Answer" />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{isLoading && responses.length === 0 ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator color={colors.primary} size="large" />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<SectionList
|
||||||
|
sections={sections}
|
||||||
|
renderItem={renderItem}
|
||||||
|
renderSectionHeader={renderSectionHeader}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
stickySectionHeadersEnabled={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isLoading}
|
||||||
|
onRefresh={fetchResponses}
|
||||||
|
tintColor={colors.primary}
|
||||||
|
colors={[colors.primary]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInUp.duration(600).springify()}
|
||||||
|
style={styles.emptyContent}
|
||||||
|
>
|
||||||
|
<View style={styles.emptyIcon}>
|
||||||
|
<Ionicons name="help-circle-outline" size={48} color={ACCENT} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyTitle}>No Answers Yet</Text>
|
||||||
|
<Text style={styles.emptyDescription}>
|
||||||
|
Start swiping on questions to build your answer history. You can
|
||||||
|
come back here to review and change your answers anytime.
|
||||||
|
</Text>
|
||||||
|
<GlassButton
|
||||||
|
onPress={() => router.push("/(app)/swipe")}
|
||||||
|
style={{ marginTop: spacing.lg }}
|
||||||
|
>
|
||||||
|
Start Swiping
|
||||||
|
</GlassButton>
|
||||||
|
</Animated.View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Edit Response Sheet */}
|
||||||
|
<EditResponseSheet
|
||||||
|
visible={isEditSheetVisible}
|
||||||
|
response={selectedResponse}
|
||||||
|
onClose={handleEditClose}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 { 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 { Ionicons } from "@expo/vector-icons";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
@@ -28,7 +28,9 @@ import { Events } from "../../src/lib/analytics";
|
|||||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||||
|
|
||||||
export default function SwipeScreen() {
|
export default function SwipeScreen() {
|
||||||
const { packId } = useLocalSearchParams();
|
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<any[]>([]);
|
const [questions, setQuestions] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
@@ -36,9 +38,10 @@ export default function SwipeScreen() {
|
|||||||
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
|
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
|
||||||
const [isBlocked, setIsBlocked] = useState(false);
|
const [isBlocked, setIsBlocked] = useState(false);
|
||||||
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
||||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
|
||||||
const { partner, couple } = useAuthStore();
|
const { partner, couple } = useAuthStore();
|
||||||
const hasTrackedExhausted = useRef(false);
|
const hasTrackedExhausted = useRef(false);
|
||||||
|
const fetchRequestId = useRef(0);
|
||||||
|
|
||||||
// Ambient orb breathing animations
|
// Ambient orb breathing animations
|
||||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||||
@@ -61,12 +64,34 @@ export default function SwipeScreen() {
|
|||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const isFirstMount = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
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();
|
checkTutorial();
|
||||||
hasTrackedExhausted.current = false; // Reset when pack changes
|
|
||||||
}, [packId]);
|
}, [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)
|
// Check if user is too far ahead of partner (called when questions empty or after swipe)
|
||||||
const checkAnswerGap = async (): Promise<boolean> => {
|
const checkAnswerGap = async (): Promise<boolean> => {
|
||||||
if (!couple || !partner) {
|
if (!couple || !partner) {
|
||||||
@@ -80,6 +105,9 @@ export default function SwipeScreen() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Error checking answer gap:', error);
|
console.error('Error checking answer gap:', error);
|
||||||
|
// Clear blocked state on error so user isn't stuck
|
||||||
|
setIsBlocked(false);
|
||||||
|
setGapInfo(null);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,10 +125,18 @@ export default function SwipeScreen() {
|
|||||||
}
|
}
|
||||||
return result.is_blocked;
|
return result.is_blocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No data returned - clear blocked state
|
||||||
|
setIsBlocked(false);
|
||||||
|
setGapInfo(null);
|
||||||
|
return false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check answer gap:', 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)
|
// Calculate effective total questions (accounting for gap limit)
|
||||||
@@ -150,7 +186,20 @@ export default function SwipeScreen() {
|
|||||||
}
|
}
|
||||||
}, [enabledPackIds]);
|
}, [enabledPackIds]);
|
||||||
|
|
||||||
|
// Fisher-Yates shuffle for stable randomization
|
||||||
|
const shuffleArray = <T,>(array: T[]): T[] => {
|
||||||
|
const shuffled = [...array];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
return shuffled;
|
||||||
|
};
|
||||||
|
|
||||||
const fetchQuestions = async () => {
|
const fetchQuestions = async () => {
|
||||||
|
// Race protection: increment request ID and track current request
|
||||||
|
const currentRequestId = ++fetchRequestId.current;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [{ data, error }, skippedIds] = await Promise.all([
|
const [{ data, error }, skippedIds] = await Promise.all([
|
||||||
supabase.rpc("get_recommended_questions", {
|
supabase.rpc("get_recommended_questions", {
|
||||||
@@ -159,6 +208,11 @@ export default function SwipeScreen() {
|
|||||||
getSkippedQuestionIds()
|
getSkippedQuestionIds()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Race guard: if a newer request started, discard this result
|
||||||
|
if (currentRequestId !== fetchRequestId.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Error fetching recommended questions:", error);
|
console.error("Error fetching recommended questions:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -177,9 +231,11 @@ export default function SwipeScreen() {
|
|||||||
setGapInfo(null);
|
setGapInfo(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = filtered.sort((a: any, b: any) => {
|
// First shuffle to randomize, then stable sort by priority scores
|
||||||
let scoreA = Math.random();
|
const shuffled = shuffleArray(filtered);
|
||||||
let scoreB = Math.random();
|
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;
|
if (a.partner_answered && a.is_two_part) scoreA += 1.5;
|
||||||
else if (a.partner_answered) scoreA += 0.7;
|
else if (a.partner_answered) scoreA += 0.7;
|
||||||
@@ -192,12 +248,19 @@ export default function SwipeScreen() {
|
|||||||
return scoreB - scoreA;
|
return scoreB - scoreA;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only update state if still the current request
|
||||||
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
setQuestions(sorted);
|
setQuestions(sorted);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSwipe = async (direction: "left" | "right" | "up" | "down") => {
|
const handleSwipe = async (direction: "left" | "right" | "up" | "down") => {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ function createThenableQuery(result: any) {
|
|||||||
describe('matchStore', () => {
|
describe('matchStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
useMatchStore.setState({ matches: [], newMatchesCount: 0 } as any);
|
useMatchStore.setState({ matches: [], newMatchesCount: 0, isLoading: false, error: null } as any);
|
||||||
useAuthStore.setState({ user: { id: 'me' } } as any);
|
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sorts matches with unread messages first', async () => {
|
it('sorts matches with unread messages first', async () => {
|
||||||
|
|||||||
@@ -42,12 +42,14 @@ describe('messageStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('markMatchMessagesAsRead updates Supabase and refetches unread count', async () => {
|
it('markMatchMessagesAsRead updates Supabase and refetches unread count', async () => {
|
||||||
|
const unreadCountQuery = createThenableQuery({ count: 2 }); // count before marking
|
||||||
const updateQuery = createThenableQuery({});
|
const updateQuery = createThenableQuery({});
|
||||||
const countQuery = createThenableQuery({ count: 0 });
|
const finalCountQuery = createThenableQuery({ count: 0 }); // count after marking
|
||||||
|
|
||||||
(supabase.from as jest.Mock)
|
(supabase.from as jest.Mock)
|
||||||
.mockReturnValueOnce(updateQuery)
|
.mockReturnValueOnce(unreadCountQuery) // count unread messages first
|
||||||
.mockReturnValueOnce(countQuery);
|
.mockReturnValueOnce(updateQuery) // update messages
|
||||||
|
.mockReturnValueOnce(finalCountQuery); // fetchUnreadCount
|
||||||
|
|
||||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
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",
|
icon: "images",
|
||||||
gradient: gradients.premiumRose as [string, string],
|
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) {
|
export default function MatchesTutorial({ onComplete }: Props) {
|
||||||
@@ -216,6 +223,40 @@ export default function MatchesTutorial({ onComplete }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
</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>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -425,6 +466,68 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.success,
|
color: colors.success,
|
||||||
fontWeight: "600",
|
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: {
|
textContainer: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: spacing.xl,
|
marginBottom: spacing.xl,
|
||||||
|
|||||||
@@ -137,51 +137,64 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|||||||
marginVertical={spacing.md}
|
marginVertical={spacing.md}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Question Text */}
|
{/* Question and Responses - Show who answered what */}
|
||||||
<Text style={styles.matchQuestionText}>
|
{match.responses && match.responses.length > 0 ? (
|
||||||
"{match.question.text}"
|
<View style={styles.responsesContainer}>
|
||||||
</Text>
|
{(() => {
|
||||||
{match.question.partner_text && (
|
// Sort responses by created_at to determine who answered first
|
||||||
<Text style={styles.matchQuestionPartnerText}>
|
const sortedResponses = [...match.responses].sort(
|
||||||
"{match.question.partner_text}"
|
(a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||||
</Text>
|
);
|
||||||
)}
|
const isTwoPart = !!match.question.partner_text;
|
||||||
|
|
||||||
{/* Response Pills */}
|
return sortedResponses.map((response: any, index: number) => {
|
||||||
{match.responses && match.responses.length > 0 && (
|
|
||||||
<View style={styles.responsePillsRow}>
|
|
||||||
{match.responses.map((response: any) => {
|
|
||||||
const isUser = response.user_id === user?.id;
|
const isUser = response.user_id === user?.id;
|
||||||
const isYes = response.answer === 'yes';
|
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 (
|
return (
|
||||||
<View key={response.user_id} style={styles.responsePillWrapper}>
|
<View key={response.user_id} style={styles.responseCard}>
|
||||||
|
{/* Name and Answer Badge */}
|
||||||
|
<View style={styles.responseHeader}>
|
||||||
|
<Text style={styles.responseName}>{name}</Text>
|
||||||
{isYes ? (
|
{isYes ? (
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={isYesYes
|
colors={isYesYes
|
||||||
? gradients.premiumGold as [string, string]
|
? gradients.premiumGold as [string, string]
|
||||||
: [colors.success, '#27ae60']
|
: [colors.success, '#27ae60']
|
||||||
}
|
}
|
||||||
style={styles.responsePill}
|
style={styles.answerBadge}
|
||||||
start={{ x: 0, y: 0 }}
|
start={{ x: 0, y: 0 }}
|
||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
>
|
>
|
||||||
<Ionicons name="checkmark" size={12} color={colors.text} />
|
<Ionicons name="checkmark" size={10} color={colors.text} />
|
||||||
<Text style={styles.responsePillText}>
|
<Text style={styles.answerBadgeText}>YES</Text>
|
||||||
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
|
|
||||||
</Text>
|
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.responsePillMaybe}>
|
<View style={styles.answerBadgeMaybe}>
|
||||||
<Ionicons name="help" size={12} color={colors.warning} />
|
<Ionicons name="help" size={10} color={colors.warning} />
|
||||||
<Text style={styles.responsePillTextMaybe}>
|
<Text style={styles.answerBadgeTextMaybe}>MAYBE</Text>
|
||||||
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
{/* Question they saw */}
|
||||||
})}
|
<Text style={styles.responseQuestionText}>
|
||||||
|
"{questionText}"
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.matchQuestionText}>
|
||||||
|
"{match.question.text}"
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Premium border */}
|
{/* Premium border */}
|
||||||
@@ -322,50 +335,66 @@ const styles = StyleSheet.create({
|
|||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
matchQuestionPartnerText: {
|
responsesContainer: {
|
||||||
...typography.caption1,
|
width: '100%',
|
||||||
color: colors.textSecondary,
|
|
||||||
textAlign: 'center',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
responsePillsRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
marginTop: spacing.sm,
|
|
||||||
},
|
},
|
||||||
responsePillWrapper: {
|
responseCard: {
|
||||||
overflow: 'hidden',
|
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.md,
|
||||||
},
|
padding: spacing.sm,
|
||||||
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)`,
|
|
||||||
borderWidth: 1,
|
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,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
responsePillTextMaybe: {
|
answerBadgeText: {
|
||||||
...typography.caption2,
|
...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,
|
color: colors.warning,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
responseQuestionText: {
|
||||||
|
...typography.footnote,
|
||||||
|
color: colors.text,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
matchCardBorder: {
|
matchCardBorder: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export { useMatchStore } from "./matchStore";
|
|||||||
export { usePacksStore } from "./packsStore";
|
export { usePacksStore } from "./packsStore";
|
||||||
export { useMessageStore } from "./messageStore";
|
export { useMessageStore } from "./messageStore";
|
||||||
export { useSubscriptionStore } from "./subscriptionStore";
|
export { useSubscriptionStore } from "./subscriptionStore";
|
||||||
|
export { useResponsesStore, groupResponses } from "./responsesStore";
|
||||||
|
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption } from "./responsesStore";
|
||||||
@@ -6,36 +6,60 @@ import { useAuthStore } from "./authStore";
|
|||||||
interface MatchState {
|
interface MatchState {
|
||||||
matches: Match[];
|
matches: Match[];
|
||||||
newMatchesCount: number;
|
newMatchesCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
fetchMatches: () => Promise<void>;
|
fetchMatches: () => Promise<void>;
|
||||||
markAsSeen: (matchId: string) => Promise<void>;
|
markAsSeen: (matchId: string) => Promise<void>;
|
||||||
markAllAsSeen: () => Promise<void>;
|
markAllAsSeen: () => Promise<void>;
|
||||||
addMatch: (match: Match) => void;
|
addMatch: (match: Match) => void;
|
||||||
|
updateMatchUnreadCount: (matchId: string, delta: number) => void;
|
||||||
clearMatches: () => void;
|
clearMatches: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMatchStore = create<MatchState>((set, get) => ({
|
export const useMatchStore = create<MatchState>((set, get) => ({
|
||||||
matches: [],
|
matches: [],
|
||||||
newMatchesCount: 0,
|
newMatchesCount: 0,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
fetchMatches: async () => {
|
fetchMatches: async () => {
|
||||||
const userId = useAuthStore.getState().user?.id;
|
const userId = useAuthStore.getState().user?.id;
|
||||||
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
|
|
||||||
const { data: matches } = await supabase
|
// Early return if no couple - user isn't paired yet
|
||||||
|
if (!coupleId) {
|
||||||
|
set({ matches: [], newMatchesCount: 0, isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: matches, error: matchError } = await supabase
|
||||||
.from("matches")
|
.from("matches")
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
question:questions(*)
|
question:questions(*)
|
||||||
`)
|
`)
|
||||||
|
.eq("couple_id", coupleId)
|
||||||
.order("created_at", { ascending: false });
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
if (!matches) return;
|
if (matchError) throw matchError;
|
||||||
|
if (!matches) {
|
||||||
|
set({ matches: [], newMatchesCount: 0, isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch all responses for these questions to determine who answered first
|
// Fetch all responses for these questions to determine who answered first
|
||||||
const questionIds = matches.map(m => m.question_id);
|
const questionIds = matches.map(m => m.question_id);
|
||||||
const { data: responses } = await supabase
|
let responses: any[] = [];
|
||||||
|
if (questionIds.length > 0) {
|
||||||
|
const { data } = await supabase
|
||||||
.from("responses")
|
.from("responses")
|
||||||
.select("*")
|
.select("*")
|
||||||
.in("question_id", questionIds);
|
.in("question_id", questionIds);
|
||||||
|
responses = data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch unread message counts per match
|
// Fetch unread message counts per match
|
||||||
const matchIds = matches.map(m => m.id);
|
const matchIds = matches.map(m => m.id);
|
||||||
@@ -61,7 +85,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
|
|
||||||
const data = matches.map(match => ({
|
const data = matches.map(match => ({
|
||||||
...match,
|
...match,
|
||||||
responses: responses?.filter(r => r.question_id === match.question_id) || [],
|
responses: responses.filter(r => r.question_id === match.question_id),
|
||||||
unreadCount: unreadCounts[match.id] || 0
|
unreadCount: unreadCounts[match.id] || 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -76,7 +100,11 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const newCount = sortedData.filter((m) => m.is_new).length;
|
const newCount = sortedData.filter((m) => m.is_new).length;
|
||||||
set({ matches: sortedData, newMatchesCount: newCount });
|
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) => {
|
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: () => {
|
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 { supabase } from "../lib/supabase";
|
||||||
import type { Database } from "@/types/supabase";
|
import type { Database } from "@/types/supabase";
|
||||||
import { useAuthStore } from "./authStore";
|
import { useAuthStore } from "./authStore";
|
||||||
|
import { useMatchStore } from "./matchStore";
|
||||||
|
|
||||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||||
|
|
||||||
@@ -72,6 +73,14 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
const userId = useAuthStore.getState().user?.id;
|
const userId = useAuthStore.getState().user?.id;
|
||||||
if (!userId) return;
|
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
|
await supabase
|
||||||
.from("messages")
|
.from("messages")
|
||||||
.update({ read_at: new Date().toISOString() })
|
.update({ read_at: new Date().toISOString() })
|
||||||
@@ -79,8 +88,13 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
|||||||
.neq("user_id", userId)
|
.neq("user_id", userId)
|
||||||
.is("read_at", null);
|
.is("read_at", null);
|
||||||
|
|
||||||
// Refetch unread count
|
// Refetch global unread count
|
||||||
await get().fetchUnreadCount();
|
await get().fetchUnreadCount();
|
||||||
|
|
||||||
|
// Sync per-match unread count in matchStore
|
||||||
|
if (unreadInMatch && unreadInMatch > 0) {
|
||||||
|
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
clearMessages: () => {
|
clearMessages: () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface PacksState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
fetchPacks: () => Promise<void>;
|
fetchPacks: () => Promise<void>;
|
||||||
fetchEnabledPacks: () => Promise<void>;
|
fetchEnabledPacks: () => Promise<void>;
|
||||||
|
ensureEnabledPacksLoaded: () => Promise<void>;
|
||||||
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
|
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
|
||||||
clearPacks: () => void;
|
clearPacks: () => void;
|
||||||
}
|
}
|
||||||
@@ -74,6 +75,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
|||||||
set({ enabledPackIds: ids });
|
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 }> => {
|
togglePack: async (packId: string): Promise<{ success: boolean; reason?: string }> => {
|
||||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
if (!coupleId) {
|
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 }];
|
||||||
|
}
|
||||||
@@ -87,6 +87,7 @@ export interface Match {
|
|||||||
match_type: MatchType;
|
match_type: MatchType;
|
||||||
is_new: boolean;
|
is_new: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
unreadCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extended match with question details
|
// Extended match with question details
|
||||||
|
|||||||
@@ -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<string, unknown> | 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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user