mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 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 segments = useSegments();
|
||||
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
|
||||
const { matches, newMatchesCount, addMatch } = useMatchStore();
|
||||
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
|
||||
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||
const { fetchEnabledPacks } = usePacksStore();
|
||||
const { initializeRevenueCat } = useSubscriptionStore();
|
||||
@@ -477,6 +477,9 @@ export default function AppLayout() {
|
||||
delivered_at: newMessage.delivered_at || new Date().toISOString(),
|
||||
match: { id: match.id, question: { text: questionText || "" } },
|
||||
});
|
||||
|
||||
// Sync per-match unread count for realtime updates
|
||||
updateMatchUnreadCount(match.id, 1);
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -697,6 +700,12 @@ export default function AppLayout() {
|
||||
href: null, // Hide from tab bar
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="my-answers"
|
||||
options={{
|
||||
href: null, // Hide from tab bar
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* 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 Animated, {
|
||||
FadeInDown,
|
||||
@@ -13,7 +13,7 @@ import Animated, {
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||
|
||||
@@ -23,17 +23,20 @@ const ACCENT_COLOR = colors.primary;
|
||||
export default function HomeScreen() {
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
||||
const { packs, fetchPacks } = usePacksStore();
|
||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh data when screen gains focus for freshness
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchMatches();
|
||||
fetchPacks();
|
||||
}, []);
|
||||
}, [])
|
||||
);
|
||||
|
||||
const recentMatches = matches.slice(0, 3);
|
||||
const topMatches = matches.slice(0, 3);
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
const enabledPacksCount = packs.length;
|
||||
const enabledPacksCount = enabledPackIds.length;
|
||||
|
||||
// Subtle pulsating animation for the CTA icon
|
||||
const pulseScale = useSharedValue(1);
|
||||
@@ -219,8 +222,8 @@ export default function HomeScreen() {
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Recent Matches */}
|
||||
{recentMatches.length > 0 && (
|
||||
{/* Top Matches (sorted by unread first, then recent) */}
|
||||
{topMatches.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
@@ -230,7 +233,7 @@ export default function HomeScreen() {
|
||||
<View style={styles.sectionIcon}>
|
||||
<Ionicons name="heart" size={14} color={ACCENT_COLOR} />
|
||||
</View>
|
||||
<Text style={styles.sectionTitle}>Recent Matches</Text>
|
||||
<Text style={styles.sectionTitle}>Top Matches</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(app)/matches")}
|
||||
@@ -240,7 +243,7 @@ export default function HomeScreen() {
|
||||
<Ionicons name="chevron-forward" size={14} color={ACCENT_COLOR} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{recentMatches.map((match, index) => (
|
||||
{topMatches.map((match, index) => (
|
||||
<Animated.View
|
||||
key={match.id}
|
||||
entering={FadeInRight.delay(450 + index * 100).duration(400)}
|
||||
@@ -272,7 +275,7 @@ export default function HomeScreen() {
|
||||
)}
|
||||
|
||||
{/* Tips when no matches */}
|
||||
{recentMatches.length === 0 && (
|
||||
{topMatches.length === 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
|
||||
@@ -26,7 +26,7 @@ const ROSE_RGBA = 'rgba(232, 164, 174, ';
|
||||
const MAX_CONTENT_WIDTH = 500;
|
||||
|
||||
export default function MatchesScreen() {
|
||||
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
|
||||
const { matches, fetchMatches, markAllAsSeen, isLoading } = useMatchStore();
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
@@ -205,6 +205,14 @@ export default function MatchesScreen() {
|
||||
entering={FadeIn.duration(400)}
|
||||
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}>
|
||||
{/* Premium label */}
|
||||
<Text style={styles.headerLabel}>DISCOVER</Text>
|
||||
@@ -229,7 +237,7 @@ export default function MatchesScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
refreshing={isLoading}
|
||||
onRefresh={fetchMatches}
|
||||
tintColor={colors.primary}
|
||||
colors={[colors.primary]}
|
||||
@@ -323,6 +331,20 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
myAnswersButton: {
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
right: spacing.md,
|
||||
zIndex: 10,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${ACCENT_RGBA}0.2)`,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerContent: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
@@ -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 { useLocalSearchParams, router } from "expo-router";
|
||||
import { useLocalSearchParams, router, useFocusEffect } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
@@ -28,7 +28,9 @@ import { Events } from "../../src/lib/analytics";
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||
|
||||
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 [isLoading, setIsLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
@@ -36,9 +38,10 @@ export default function SwipeScreen() {
|
||||
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
|
||||
const [isBlocked, setIsBlocked] = useState(false);
|
||||
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
|
||||
const { partner, couple } = useAuthStore();
|
||||
const hasTrackedExhausted = useRef(false);
|
||||
const fetchRequestId = useRef(0);
|
||||
|
||||
// Ambient orb breathing animations
|
||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||
@@ -61,12 +64,34 @@ export default function SwipeScreen() {
|
||||
],
|
||||
}));
|
||||
|
||||
const isFirstMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPacks().then(() => fetchQuestions());
|
||||
// Reset state when pack changes
|
||||
setCurrentIndex(0);
|
||||
setIsLoading(true);
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
hasTrackedExhausted.current = false;
|
||||
|
||||
// Use lightweight ensureEnabledPacksLoaded instead of full fetchPacks
|
||||
ensureEnabledPacksLoaded().then(() => fetchQuestions());
|
||||
checkTutorial();
|
||||
hasTrackedExhausted.current = false; // Reset when pack changes
|
||||
}, [packId]);
|
||||
|
||||
// Refresh questions when screen gains focus (handles partner answering while away)
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Skip on first mount since useEffect already fetches
|
||||
if (isFirstMount.current) {
|
||||
isFirstMount.current = false;
|
||||
return;
|
||||
}
|
||||
// Silently refetch to get updated partner_answered status
|
||||
fetchQuestions();
|
||||
}, [packId])
|
||||
);
|
||||
|
||||
// Check if user is too far ahead of partner (called when questions empty or after swipe)
|
||||
const checkAnswerGap = async (): Promise<boolean> => {
|
||||
if (!couple || !partner) {
|
||||
@@ -80,6 +105,9 @@ export default function SwipeScreen() {
|
||||
|
||||
if (error) {
|
||||
console.error('Error checking answer gap:', error);
|
||||
// Clear blocked state on error so user isn't stuck
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -97,10 +125,18 @@ export default function SwipeScreen() {
|
||||
}
|
||||
return result.is_blocked;
|
||||
}
|
||||
|
||||
// No data returned - clear blocked state
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to check answer gap:', error);
|
||||
}
|
||||
// Clear blocked state on error so user isn't stuck
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate effective total questions (accounting for gap limit)
|
||||
@@ -150,7 +186,20 @@ export default function SwipeScreen() {
|
||||
}
|
||||
}, [enabledPackIds]);
|
||||
|
||||
// Fisher-Yates shuffle for stable randomization
|
||||
const shuffleArray = <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 () => {
|
||||
// Race protection: increment request ID and track current request
|
||||
const currentRequestId = ++fetchRequestId.current;
|
||||
|
||||
try {
|
||||
const [{ data, error }, skippedIds] = await Promise.all([
|
||||
supabase.rpc("get_recommended_questions", {
|
||||
@@ -159,6 +208,11 @@ export default function SwipeScreen() {
|
||||
getSkippedQuestionIds()
|
||||
]);
|
||||
|
||||
// Race guard: if a newer request started, discard this result
|
||||
if (currentRequestId !== fetchRequestId.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching recommended questions:", error);
|
||||
throw error;
|
||||
@@ -177,9 +231,11 @@ export default function SwipeScreen() {
|
||||
setGapInfo(null);
|
||||
}
|
||||
|
||||
const sorted = filtered.sort((a: any, b: any) => {
|
||||
let scoreA = Math.random();
|
||||
let scoreB = Math.random();
|
||||
// First shuffle to randomize, then stable sort by priority scores
|
||||
const shuffled = shuffleArray(filtered);
|
||||
const sorted = shuffled.sort((a: any, b: any) => {
|
||||
let scoreA = 0;
|
||||
let scoreB = 0;
|
||||
|
||||
if (a.partner_answered && a.is_two_part) scoreA += 1.5;
|
||||
else if (a.partner_answered) scoreA += 0.7;
|
||||
@@ -192,12 +248,19 @@ export default function SwipeScreen() {
|
||||
return scoreB - scoreA;
|
||||
});
|
||||
|
||||
// Only update state if still the current request
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
setQuestions(sorted);
|
||||
}
|
||||
} catch (error) {
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwipe = async (direction: "left" | "right" | "up" | "down") => {
|
||||
|
||||
@@ -18,8 +18,8 @@ function createThenableQuery(result: any) {
|
||||
describe('matchStore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useMatchStore.setState({ matches: [], newMatchesCount: 0 } as any);
|
||||
useAuthStore.setState({ user: { id: 'me' } } as any);
|
||||
useMatchStore.setState({ matches: [], newMatchesCount: 0, isLoading: false, error: null } as any);
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||
});
|
||||
|
||||
it('sorts matches with unread messages first', async () => {
|
||||
|
||||
@@ -42,12 +42,14 @@ describe('messageStore', () => {
|
||||
});
|
||||
|
||||
it('markMatchMessagesAsRead updates Supabase and refetches unread count', async () => {
|
||||
const unreadCountQuery = createThenableQuery({ count: 2 }); // count before marking
|
||||
const updateQuery = createThenableQuery({});
|
||||
const countQuery = createThenableQuery({ count: 0 });
|
||||
const finalCountQuery = createThenableQuery({ count: 0 }); // count after marking
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(updateQuery)
|
||||
.mockReturnValueOnce(countQuery);
|
||||
.mockReturnValueOnce(unreadCountQuery) // count unread messages first
|
||||
.mockReturnValueOnce(updateQuery) // update messages
|
||||
.mockReturnValueOnce(finalCountQuery); // fetchUnreadCount
|
||||
|
||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { BlurView } from "expo-blur";
|
||||
import Animated, { FadeIn, FadeOut, ZoomIn, ZoomOut } from "react-native-reanimated";
|
||||
import { colors, spacing, typography, radius } from "../../theme";
|
||||
|
||||
interface DeleteMatchConfirmModalProps {
|
||||
visible: boolean;
|
||||
messageCount: number;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DeleteMatchConfirmModal({
|
||||
visible,
|
||||
messageCount,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DeleteMatchConfirmModalProps) {
|
||||
const messageText =
|
||||
messageCount === 0
|
||||
? "This will delete your match."
|
||||
: messageCount === 1
|
||||
? "This will delete your match and 1 message."
|
||||
: `This will delete your match and ${messageCount} messages.`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onCancel}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Animated.View
|
||||
style={styles.overlay}
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={onCancel}>
|
||||
<View style={styles.backdrop} />
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
<Animated.View
|
||||
style={styles.modalContainer}
|
||||
entering={ZoomIn.springify().damping(20)}
|
||||
exiting={ZoomOut.duration(150)}
|
||||
>
|
||||
{Platform.OS === "ios" ? (
|
||||
<BlurView intensity={80} tint="dark" style={styles.modal}>
|
||||
<ModalContent
|
||||
messageText={messageText}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={[styles.modal, styles.modalAndroid]}>
|
||||
<ModalContent
|
||||
messageText={messageText}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalContent({
|
||||
messageText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
messageText: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Warning icon */}
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons name="warning" size={32} color={colors.error} />
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text style={styles.title}>Delete Match?</Text>
|
||||
|
||||
{/* Message */}
|
||||
<Text style={styles.message}>
|
||||
Changing your answer to "No" will remove this match permanently.{"\n\n"}
|
||||
{messageText}
|
||||
</Text>
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={styles.buttons}>
|
||||
<TouchableOpacity style={styles.cancelButton} onPress={onCancel} activeOpacity={0.7}>
|
||||
<Text style={styles.cancelButtonText}>Keep Match</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.deleteButton} onPress={onConfirm} activeOpacity={0.7}>
|
||||
<LinearGradient
|
||||
colors={[colors.error, "#c0392b"]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Text style={styles.deleteButtonText}>Delete Match</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: spacing.lg,
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
},
|
||||
modalContainer: {
|
||||
width: "100%",
|
||||
maxWidth: 340,
|
||||
},
|
||||
modal: {
|
||||
borderRadius: radius.xl,
|
||||
overflow: "hidden",
|
||||
padding: spacing.xl,
|
||||
alignItems: "center",
|
||||
},
|
||||
modalAndroid: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: "rgba(231, 76, 60, 0.15)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(231, 76, 60, 0.25)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
message: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 22,
|
||||
},
|
||||
buttons: {
|
||||
width: "100%",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
cancelButton: {
|
||||
width: "100%",
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.15)",
|
||||
alignItems: "center",
|
||||
},
|
||||
cancelButtonText: {
|
||||
...typography.callout,
|
||||
color: colors.text,
|
||||
fontWeight: "600",
|
||||
},
|
||||
deleteButton: {
|
||||
width: "100%",
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
overflow: "hidden",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
deleteButtonText: {
|
||||
...typography.callout,
|
||||
color: colors.text,
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { BlurView } from "expo-blur";
|
||||
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
|
||||
import type { ResponseWithQuestion, UpdateResponseResult } from "../../store";
|
||||
import { useResponsesStore } from "../../store";
|
||||
import type { AnswerType } from "@/types";
|
||||
import { colors, spacing, typography, radius } from "../../theme";
|
||||
import { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
|
||||
|
||||
// Premium color palette
|
||||
const ACCENT = colors.premium.gold;
|
||||
const ACCENT_RGBA = "rgba(212, 175, 55, ";
|
||||
|
||||
// Answer button config
|
||||
const ANSWER_CONFIG = {
|
||||
yes: {
|
||||
color: colors.success,
|
||||
rgba: "rgba(46, 204, 113, ",
|
||||
icon: "checkmark-circle" as const,
|
||||
label: "Yes",
|
||||
},
|
||||
maybe: {
|
||||
color: colors.warning,
|
||||
rgba: "rgba(243, 156, 18, ",
|
||||
icon: "help-circle" as const,
|
||||
label: "Maybe",
|
||||
},
|
||||
no: {
|
||||
color: colors.error,
|
||||
rgba: "rgba(231, 76, 60, ",
|
||||
icon: "close-circle" as const,
|
||||
label: "No",
|
||||
},
|
||||
};
|
||||
|
||||
interface EditResponseSheetProps {
|
||||
visible: boolean;
|
||||
response: ResponseWithQuestion | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function EditResponseSheet({ visible, response, onClose, onSuccess }: EditResponseSheetProps) {
|
||||
const { updateResponse } = useResponsesStore();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [pendingAnswer, setPendingAnswer] = useState<AnswerType | null>(null);
|
||||
const [confirmModalData, setConfirmModalData] = useState<{
|
||||
matchId: string;
|
||||
messageCount: number;
|
||||
} | null>(null);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setIsLoading(false);
|
||||
setPendingAnswer(null);
|
||||
setConfirmModalData(null);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!response) return null;
|
||||
|
||||
const handleAnswerPress = async (newAnswer: AnswerType) => {
|
||||
if (newAnswer === response.answer || isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setPendingAnswer(newAnswer);
|
||||
|
||||
const result = await updateResponse(response.question_id, newAnswer, false);
|
||||
|
||||
if (result.requires_confirmation && result.match_id) {
|
||||
// Show confirmation modal
|
||||
setConfirmModalData({
|
||||
matchId: result.match_id,
|
||||
messageCount: result.message_count || 0,
|
||||
});
|
||||
setIsLoading(false);
|
||||
} else if (result.success) {
|
||||
// Success - close sheet
|
||||
setIsLoading(false);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
// Error
|
||||
setIsLoading(false);
|
||||
setPendingAnswer(null);
|
||||
// Could show an error toast here
|
||||
console.error("Failed to update response:", result.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!pendingAnswer) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setConfirmModalData(null);
|
||||
|
||||
const result = await updateResponse(response.question_id, pendingAnswer, true);
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (result.success) {
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
setPendingAnswer(null);
|
||||
console.error("Failed to delete match:", result.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setConfirmModalData(null);
|
||||
setPendingAnswer(null);
|
||||
};
|
||||
|
||||
const AnswerButton = ({ answer }: { answer: AnswerType }) => {
|
||||
const config = ANSWER_CONFIG[answer];
|
||||
const isSelected = response.answer === answer;
|
||||
const isPending = pendingAnswer === answer;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.answerButton,
|
||||
isSelected && {
|
||||
backgroundColor: `${config.rgba}0.2)`,
|
||||
borderColor: `${config.rgba}0.4)`,
|
||||
},
|
||||
]}
|
||||
onPress={() => handleAnswerPress(answer)}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isPending && isLoading ? (
|
||||
<ActivityIndicator color={config.color} size="small" />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={config.icon}
|
||||
size={28}
|
||||
color={isSelected ? config.color : colors.textSecondary}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.answerButtonText,
|
||||
isSelected && { color: config.color, fontWeight: "700" },
|
||||
]}
|
||||
>
|
||||
{config.label}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<View style={styles.selectedIndicator}>
|
||||
<Ionicons name="checkmark" size={14} color={config.color} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Animated.View style={styles.overlay} entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)}>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<View style={styles.backdrop} />
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
<Animated.View
|
||||
style={styles.sheetContainer}
|
||||
entering={SlideInDown.springify().damping(20)}
|
||||
exiting={SlideOutDown.duration(200)}
|
||||
>
|
||||
{Platform.OS === "ios" ? (
|
||||
<BlurView intensity={80} tint="dark" style={styles.sheet}>
|
||||
<SheetContent
|
||||
response={response}
|
||||
AnswerButton={AnswerButton}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={[styles.sheet, styles.sheetAndroid]}>
|
||||
<SheetContent
|
||||
response={response}
|
||||
AnswerButton={AnswerButton}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<DeleteMatchConfirmModal
|
||||
visible={confirmModalData !== null}
|
||||
messageCount={confirmModalData?.messageCount || 0}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Sheet content extracted for reuse between iOS blur and Android solid background
|
||||
function SheetContent({
|
||||
response,
|
||||
AnswerButton,
|
||||
onClose,
|
||||
}: {
|
||||
response: ResponseWithQuestion;
|
||||
AnswerButton: React.FC<{ answer: AnswerType }>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Handle */}
|
||||
<View style={styles.handle} />
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Edit Answer</Text>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<Ionicons name="close" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Question text */}
|
||||
<View style={styles.questionContainer}>
|
||||
<Text style={styles.questionText}>{response.question.text}</Text>
|
||||
<View style={styles.packBadge}>
|
||||
<Ionicons name="layers-outline" size={12} color={colors.textTertiary} />
|
||||
<Text style={styles.packText}>{response.question.pack.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Warning for matched questions */}
|
||||
{response.has_match && (
|
||||
<View style={styles.warningBanner}>
|
||||
<Ionicons name="warning" size={16} color={colors.warning} />
|
||||
<Text style={styles.warningText}>
|
||||
This question has a match. Changing to "No" will delete the match and
|
||||
all messages.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Answer buttons */}
|
||||
<View style={styles.answerButtons}>
|
||||
<AnswerButton answer="yes" />
|
||||
<AnswerButton answer="maybe" />
|
||||
<AnswerButton answer="no" />
|
||||
</View>
|
||||
|
||||
{/* Bottom padding for safe area */}
|
||||
<View style={styles.bottomPadding} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
},
|
||||
sheetContainer: {
|
||||
maxHeight: "80%",
|
||||
},
|
||||
sheet: {
|
||||
borderTopLeftRadius: radius.xl,
|
||||
borderTopRightRadius: radius.xl,
|
||||
overflow: "hidden",
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
sheetAndroid: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
},
|
||||
handle: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.3)",
|
||||
borderRadius: 2,
|
||||
alignSelf: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
},
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
questionContainer: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
questionText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
packBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
packText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
warningBanner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
backgroundColor: "rgba(243, 156, 18, 0.1)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(243, 156, 18, 0.2)",
|
||||
borderRadius: radius.md,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
warningText: {
|
||||
...typography.footnote,
|
||||
color: colors.warning,
|
||||
flex: 1,
|
||||
},
|
||||
answerButtons: {
|
||||
flexDirection: "row",
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
answerButton: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: spacing.lg,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
answerButtonText: {
|
||||
...typography.callout,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
selectedIndicator: {
|
||||
position: "absolute",
|
||||
top: spacing.sm,
|
||||
right: spacing.sm,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
bottomPadding: {
|
||||
height: Platform.OS === "ios" ? 34 : 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeInRight } from "react-native-reanimated";
|
||||
import type { ResponseWithQuestion } from "../../store";
|
||||
import { colors, spacing, typography, radius } from "../../theme";
|
||||
|
||||
// Premium color palette
|
||||
const ACCENT = colors.premium.gold;
|
||||
const ACCENT_RGBA = "rgba(212, 175, 55, ";
|
||||
const ROSE = colors.premium.rose;
|
||||
const ROSE_RGBA = "rgba(232, 164, 174, ";
|
||||
|
||||
// Answer colors (semantic)
|
||||
const ANSWER_COLORS = {
|
||||
yes: {
|
||||
color: colors.success,
|
||||
rgba: "rgba(46, 204, 113, ",
|
||||
label: "YES",
|
||||
},
|
||||
no: {
|
||||
color: colors.error,
|
||||
rgba: "rgba(231, 76, 60, ",
|
||||
label: "NO",
|
||||
},
|
||||
maybe: {
|
||||
color: colors.warning,
|
||||
rgba: "rgba(243, 156, 18, ",
|
||||
label: "MAYBE",
|
||||
},
|
||||
};
|
||||
|
||||
interface ResponseCardProps {
|
||||
response: ResponseWithQuestion;
|
||||
index: number;
|
||||
onEditPress: () => void;
|
||||
onChatPress?: () => void;
|
||||
}
|
||||
|
||||
export function ResponseCard({ response, index, onEditPress, onChatPress }: ResponseCardProps) {
|
||||
const answerStyle = ANSWER_COLORS[response.answer];
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInRight.delay(index * 30).duration(250)}>
|
||||
<View style={styles.card}>
|
||||
{/* Subtle gradient background */}
|
||||
<LinearGradient
|
||||
colors={["rgba(22, 33, 62, 0.6)", "rgba(13, 13, 26, 0.8)"]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
{/* Top silk highlight */}
|
||||
<LinearGradient
|
||||
colors={[`${answerStyle.rgba}0.06)`, "transparent"]}
|
||||
style={styles.cardSilkHighlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.row}>
|
||||
{/* Answer badge */}
|
||||
<View
|
||||
style={[
|
||||
styles.answerBadge,
|
||||
{ backgroundColor: `${answerStyle.rgba}0.15)`, borderColor: `${answerStyle.rgba}0.3)` },
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={
|
||||
response.answer === "yes"
|
||||
? "checkmark"
|
||||
: response.answer === "no"
|
||||
? "close"
|
||||
: "help"
|
||||
}
|
||||
size={18}
|
||||
color={answerStyle.color}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.questionText} numberOfLines={2}>
|
||||
{response.question.text}
|
||||
</Text>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
{/* Answer tag */}
|
||||
<View
|
||||
style={[
|
||||
styles.tag,
|
||||
{
|
||||
backgroundColor: `${answerStyle.rgba}0.1)`,
|
||||
borderColor: `${answerStyle.rgba}0.2)`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.tagText, { color: answerStyle.color }]}>
|
||||
{answerStyle.label}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Pack name */}
|
||||
<View style={styles.packBadge}>
|
||||
<Ionicons name="layers-outline" size={10} color={colors.textTertiary} />
|
||||
<Text style={styles.packText} numberOfLines={1}>
|
||||
{response.question.pack.name}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Partner status indicator */}
|
||||
{response.partner_answered && (
|
||||
<View style={styles.partnerBadge}>
|
||||
<Ionicons name="person" size={10} color={colors.textTertiary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Right section */}
|
||||
<View style={styles.rightSection}>
|
||||
{/* Match indicator with chat link */}
|
||||
{response.has_match && onChatPress && (
|
||||
<TouchableOpacity style={styles.matchButton} onPress={onChatPress}>
|
||||
<Ionicons name="heart" size={14} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Edit button */}
|
||||
<TouchableOpacity style={styles.editButton} onPress={onEditPress}>
|
||||
<Ionicons name="pencil" size={14} color={ACCENT} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Premium border */}
|
||||
<View style={styles.cardBorder} pointerEvents="none" />
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
marginBottom: spacing.sm,
|
||||
borderRadius: radius.lg,
|
||||
overflow: "hidden",
|
||||
padding: spacing.md,
|
||||
},
|
||||
cardSilkHighlight: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 50,
|
||||
},
|
||||
cardBorder: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.08)",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
answerBadge: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
questionText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
fontWeight: "500",
|
||||
marginBottom: spacing.xs,
|
||||
lineHeight: 20,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
},
|
||||
tagText: {
|
||||
...typography.caption2,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 1,
|
||||
},
|
||||
packBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
maxWidth: 120,
|
||||
},
|
||||
packText: {
|
||||
...typography.caption2,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
partnerBadge: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
rightSection: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
matchButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "rgba(233, 69, 96, 0.15)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(233, 69, 96, 0.25)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
editButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${ACCENT_RGBA}0.2)`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ResponseCard } from "./ResponseCard";
|
||||
export { EditResponseSheet } from "./EditResponseSheet";
|
||||
export { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
|
||||
@@ -52,6 +52,13 @@ const STEPS: TutorialStep[] = [
|
||||
icon: "images",
|
||||
gradient: gradients.premiumRose as [string, string],
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
title: "Change Your Mind?",
|
||||
description: "Go to My Answers to review and edit any of your responses. Changed your mind? No problem — update your answer anytime.",
|
||||
icon: "create-outline",
|
||||
gradient: gradients.premiumGold as [string, string],
|
||||
},
|
||||
];
|
||||
|
||||
export default function MatchesTutorial({ onComplete }: Props) {
|
||||
@@ -216,6 +223,40 @@ export default function MatchesTutorial({ onComplete }: Props) {
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Edit answers demo - show on fourth step */}
|
||||
{currentStep === 3 && (
|
||||
<View style={styles.editDemo}>
|
||||
<View style={styles.answerCard}>
|
||||
<View style={styles.answerCardContent}>
|
||||
<Text style={styles.answerCardQuestion} numberOfLines={1}>
|
||||
"Try something new together"
|
||||
</Text>
|
||||
<View style={styles.answerCardBadge}>
|
||||
<Ionicons name="checkmark" size={12} color={colors.text} />
|
||||
<Text style={styles.answerCardBadgeText}>YES</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.answerCardEdit}>
|
||||
<Ionicons name="create-outline" size={18} color={colors.premium.gold} />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.answerArrow}>
|
||||
<Ionicons name="arrow-down" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
<View style={[styles.answerCard, styles.answerCardUpdated]}>
|
||||
<View style={styles.answerCardContent}>
|
||||
<Text style={styles.answerCardQuestion} numberOfLines={1}>
|
||||
"Try something new together"
|
||||
</Text>
|
||||
<View style={[styles.answerCardBadge, styles.answerCardBadgeMaybe]}>
|
||||
<Ionicons name="help" size={12} color={colors.warning} />
|
||||
<Text style={[styles.answerCardBadgeText, styles.answerCardBadgeTextMaybe]}>MAYBE</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
@@ -425,6 +466,68 @@ const styles = StyleSheet.create({
|
||||
color: colors.success,
|
||||
fontWeight: "600",
|
||||
},
|
||||
editDemo: {
|
||||
marginTop: spacing.lg,
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
answerCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.glass.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
borderRadius: radius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
width: 260,
|
||||
},
|
||||
answerCardUpdated: {
|
||||
borderColor: colors.premium.gold,
|
||||
backgroundColor: "rgba(212, 175, 55, 0.08)",
|
||||
},
|
||||
answerCardContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: spacing.sm,
|
||||
},
|
||||
answerCardQuestion: {
|
||||
...typography.footnote,
|
||||
color: colors.text,
|
||||
fontStyle: "italic",
|
||||
flex: 1,
|
||||
},
|
||||
answerCardBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: colors.success,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
gap: 2,
|
||||
},
|
||||
answerCardBadgeMaybe: {
|
||||
backgroundColor: "rgba(243, 156, 18, 0.2)",
|
||||
borderWidth: 1,
|
||||
borderColor: colors.warning,
|
||||
},
|
||||
answerCardBadgeText: {
|
||||
...typography.caption2,
|
||||
fontWeight: "700",
|
||||
color: colors.text,
|
||||
},
|
||||
answerCardBadgeTextMaybe: {
|
||||
color: colors.warning,
|
||||
},
|
||||
answerCardEdit: {
|
||||
marginLeft: spacing.sm,
|
||||
padding: spacing.xs,
|
||||
},
|
||||
answerArrow: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
textContainer: {
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.xl,
|
||||
|
||||
@@ -137,51 +137,64 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
marginVertical={spacing.md}
|
||||
/>
|
||||
|
||||
{/* Question Text */}
|
||||
<Text style={styles.matchQuestionText}>
|
||||
"{match.question.text}"
|
||||
</Text>
|
||||
{match.question.partner_text && (
|
||||
<Text style={styles.matchQuestionPartnerText}>
|
||||
"{match.question.partner_text}"
|
||||
</Text>
|
||||
)}
|
||||
{/* Question and Responses - Show who answered what */}
|
||||
{match.responses && match.responses.length > 0 ? (
|
||||
<View style={styles.responsesContainer}>
|
||||
{(() => {
|
||||
// Sort responses by created_at to determine who answered first
|
||||
const sortedResponses = [...match.responses].sort(
|
||||
(a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
const isTwoPart = !!match.question.partner_text;
|
||||
|
||||
{/* Response Pills */}
|
||||
{match.responses && match.responses.length > 0 && (
|
||||
<View style={styles.responsePillsRow}>
|
||||
{match.responses.map((response: any) => {
|
||||
return sortedResponses.map((response: any, index: number) => {
|
||||
const isUser = response.user_id === user?.id;
|
||||
const isYes = response.answer === 'yes';
|
||||
const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner');
|
||||
|
||||
// First responder sees main text, second sees partner_text
|
||||
const questionText = isTwoPart && index === 1
|
||||
? match.question.partner_text
|
||||
: match.question.text;
|
||||
|
||||
return (
|
||||
<View key={response.user_id} style={styles.responsePillWrapper}>
|
||||
<View key={response.user_id} style={styles.responseCard}>
|
||||
{/* Name and Answer Badge */}
|
||||
<View style={styles.responseHeader}>
|
||||
<Text style={styles.responseName}>{name}</Text>
|
||||
{isYes ? (
|
||||
<LinearGradient
|
||||
colors={isYesYes
|
||||
? gradients.premiumGold as [string, string]
|
||||
: [colors.success, '#27ae60']
|
||||
}
|
||||
style={styles.responsePill}
|
||||
style={styles.answerBadge}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="checkmark" size={12} color={colors.text} />
|
||||
<Text style={styles.responsePillText}>
|
||||
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
|
||||
</Text>
|
||||
<Ionicons name="checkmark" size={10} color={colors.text} />
|
||||
<Text style={styles.answerBadgeText}>YES</Text>
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<View style={styles.responsePillMaybe}>
|
||||
<Ionicons name="help" size={12} color={colors.warning} />
|
||||
<Text style={styles.responsePillTextMaybe}>
|
||||
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
|
||||
</Text>
|
||||
<View style={styles.answerBadgeMaybe}>
|
||||
<Ionicons name="help" size={10} color={colors.warning} />
|
||||
<Text style={styles.answerBadgeTextMaybe}>MAYBE</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{/* Question they saw */}
|
||||
<Text style={styles.responseQuestionText}>
|
||||
"{questionText}"
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.matchQuestionText}>
|
||||
"{match.question.text}"
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Premium border */}
|
||||
@@ -322,50 +335,66 @@ const styles = StyleSheet.create({
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 20,
|
||||
},
|
||||
matchQuestionPartnerText: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
marginTop: 2,
|
||||
},
|
||||
responsePillsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
responsesContainer: {
|
||||
width: '100%',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
responsePillWrapper: {
|
||||
overflow: 'hidden',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
responsePill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
gap: 4,
|
||||
},
|
||||
responsePillText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
},
|
||||
responsePillMaybe: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
gap: 4,
|
||||
backgroundColor: `rgba(243, 156, 18, 0.15)`,
|
||||
responseCard: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
borderRadius: radius.md,
|
||||
padding: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: `rgba(243, 156, 18, 0.3)`,
|
||||
borderColor: 'rgba(255, 255, 255, 0.06)',
|
||||
},
|
||||
responseHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
responseName: {
|
||||
...typography.caption1,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
answerBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
gap: 3,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
responsePillTextMaybe: {
|
||||
answerBadgeText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
answerBadgeMaybe: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
gap: 3,
|
||||
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
answerBadgeTextMaybe: {
|
||||
...typography.caption2,
|
||||
fontWeight: '700',
|
||||
color: colors.warning,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
responseQuestionText: {
|
||||
...typography.footnote,
|
||||
color: colors.text,
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 18,
|
||||
},
|
||||
matchCardBorder: {
|
||||
position: 'absolute',
|
||||
|
||||
@@ -4,3 +4,5 @@ export { useMatchStore } from "./matchStore";
|
||||
export { usePacksStore } from "./packsStore";
|
||||
export { useMessageStore } from "./messageStore";
|
||||
export { useSubscriptionStore } from "./subscriptionStore";
|
||||
export { useResponsesStore, groupResponses } from "./responsesStore";
|
||||
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption } from "./responsesStore";
|
||||
@@ -6,36 +6,60 @@ import { useAuthStore } from "./authStore";
|
||||
interface MatchState {
|
||||
matches: Match[];
|
||||
newMatchesCount: number;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
fetchMatches: () => Promise<void>;
|
||||
markAsSeen: (matchId: string) => Promise<void>;
|
||||
markAllAsSeen: () => Promise<void>;
|
||||
addMatch: (match: Match) => void;
|
||||
updateMatchUnreadCount: (matchId: string, delta: number) => void;
|
||||
clearMatches: () => void;
|
||||
}
|
||||
|
||||
export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
matches: [],
|
||||
newMatchesCount: 0,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchMatches: async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
|
||||
const { data: matches } = await supabase
|
||||
// Early return if no couple - user isn't paired yet
|
||||
if (!coupleId) {
|
||||
set({ matches: [], newMatchesCount: 0, isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const { data: matches, error: matchError } = await supabase
|
||||
.from("matches")
|
||||
.select(`
|
||||
*,
|
||||
question:questions(*)
|
||||
`)
|
||||
.eq("couple_id", coupleId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (!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
|
||||
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")
|
||||
.select("*")
|
||||
.in("question_id", questionIds);
|
||||
responses = data ?? [];
|
||||
}
|
||||
|
||||
// Fetch unread message counts per match
|
||||
const matchIds = matches.map(m => m.id);
|
||||
@@ -61,7 +85,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
|
||||
const data = matches.map(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
|
||||
}));
|
||||
|
||||
@@ -76,7 +100,11 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -115,7 +143,17 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
updateMatchUnreadCount: (matchId, delta) => {
|
||||
set((state) => ({
|
||||
matches: state.matches.map((m) =>
|
||||
m.id === matchId
|
||||
? { ...m, unreadCount: Math.max(0, (m.unreadCount || 0) + delta) }
|
||||
: m
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearMatches: () => {
|
||||
set({ matches: [], newMatchesCount: 0 });
|
||||
set({ matches: [], newMatchesCount: 0, isLoading: false, error: null });
|
||||
},
|
||||
}));
|
||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import type { Database } from "@/types/supabase";
|
||||
import { useAuthStore } from "./authStore";
|
||||
import { useMatchStore } from "./matchStore";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
@@ -72,6 +73,14 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
// Get count of unread messages before marking them read
|
||||
const { count: unreadInMatch } = await supabase
|
||||
.from("messages")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("match_id", matchId)
|
||||
.neq("user_id", userId)
|
||||
.is("read_at", null);
|
||||
|
||||
await supabase
|
||||
.from("messages")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
@@ -79,8 +88,13 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
.neq("user_id", userId)
|
||||
.is("read_at", null);
|
||||
|
||||
// Refetch unread count
|
||||
// Refetch global unread count
|
||||
await get().fetchUnreadCount();
|
||||
|
||||
// Sync per-match unread count in matchStore
|
||||
if (unreadInMatch && unreadInMatch > 0) {
|
||||
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
|
||||
}
|
||||
},
|
||||
|
||||
clearMessages: () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ interface PacksState {
|
||||
isLoading: boolean;
|
||||
fetchPacks: () => Promise<void>;
|
||||
fetchEnabledPacks: () => Promise<void>;
|
||||
ensureEnabledPacksLoaded: () => Promise<void>;
|
||||
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
|
||||
clearPacks: () => void;
|
||||
}
|
||||
@@ -74,6 +75,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
set({ enabledPackIds: ids });
|
||||
},
|
||||
|
||||
ensureEnabledPacksLoaded: async () => {
|
||||
// Only fetch if not already loaded - lightweight check for swipe screen
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
if (!coupleId) return;
|
||||
|
||||
if (get().enabledPackIds.length === 0) {
|
||||
await get().fetchEnabledPacks();
|
||||
}
|
||||
},
|
||||
|
||||
togglePack: async (packId: string): Promise<{ success: boolean; reason?: string }> => {
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
if (!coupleId) {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { create } from "zustand";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { invokeWithAuthRetry } from "../lib/authErrorHandler";
|
||||
import type { Question, QuestionPack, AnswerType } from "@/types";
|
||||
import { useAuthStore } from "./authStore";
|
||||
import { useMatchStore } from "./matchStore";
|
||||
|
||||
// Response with joined question and pack data
|
||||
export interface ResponseWithQuestion {
|
||||
id: string;
|
||||
question_id: string;
|
||||
answer: AnswerType;
|
||||
created_at: string;
|
||||
question: Question & {
|
||||
pack: Pick<QuestionPack, "id" | "name" | "icon">;
|
||||
};
|
||||
has_match: boolean;
|
||||
match_id?: string;
|
||||
partner_answered: boolean;
|
||||
}
|
||||
|
||||
// Result from update-response edge function
|
||||
export interface UpdateResponseResult {
|
||||
success: boolean;
|
||||
requires_confirmation?: boolean;
|
||||
match_id?: string;
|
||||
message_count?: number;
|
||||
new_match?: Record<string, unknown> | null;
|
||||
match_deleted?: boolean;
|
||||
match_type_updated?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type GroupByOption = "pack" | "date" | "answer";
|
||||
|
||||
interface ResponsesState {
|
||||
responses: ResponseWithQuestion[];
|
||||
isLoading: boolean;
|
||||
groupBy: GroupByOption;
|
||||
fetchResponses: () => Promise<void>;
|
||||
updateResponse: (
|
||||
questionId: string,
|
||||
newAnswer: AnswerType,
|
||||
confirmDelete?: boolean
|
||||
) => Promise<UpdateResponseResult>;
|
||||
setGroupBy: (groupBy: GroupByOption) => void;
|
||||
clearResponses: () => void;
|
||||
}
|
||||
|
||||
export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
responses: [],
|
||||
isLoading: false,
|
||||
groupBy: "pack",
|
||||
|
||||
fetchResponses: async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
|
||||
if (!userId || !coupleId) return;
|
||||
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
// Fetch all user's responses with questions and packs
|
||||
const { data: responses, error: responsesError } = await supabase
|
||||
.from("responses")
|
||||
.select(`
|
||||
id,
|
||||
question_id,
|
||||
answer,
|
||||
created_at,
|
||||
question:questions(
|
||||
id,
|
||||
text,
|
||||
partner_text,
|
||||
intensity,
|
||||
pack_id,
|
||||
pack:question_packs(id, name, icon)
|
||||
)
|
||||
`)
|
||||
.eq("user_id", userId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (responsesError) {
|
||||
console.error("Error fetching responses:", responsesError);
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!responses) {
|
||||
set({ responses: [], isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get question IDs for match lookup
|
||||
const questionIds = responses.map((r) => r.question_id);
|
||||
|
||||
// Fetch matches for these questions
|
||||
const { data: matches } = await supabase
|
||||
.from("matches")
|
||||
.select("id, question_id")
|
||||
.eq("couple_id", coupleId)
|
||||
.in("question_id", questionIds);
|
||||
|
||||
// Create a map of question_id -> match_id
|
||||
const matchMap = new Map<string, string>();
|
||||
matches?.forEach((m) => matchMap.set(m.question_id, m.id));
|
||||
|
||||
// Fetch partner's responses to check if they've answered
|
||||
const { data: partnerResponses } = await supabase
|
||||
.from("responses")
|
||||
.select("question_id")
|
||||
.eq("couple_id", coupleId)
|
||||
.neq("user_id", userId)
|
||||
.in("question_id", questionIds);
|
||||
|
||||
// Create a set of question IDs that partner has answered
|
||||
const partnerAnsweredSet = new Set<string>(
|
||||
partnerResponses?.map((r) => r.question_id) || []
|
||||
);
|
||||
|
||||
// Transform responses with match and partner info
|
||||
const transformedResponses: ResponseWithQuestion[] = responses
|
||||
.filter((r) => {
|
||||
// Supabase returns single relations as objects, but TypeScript may infer arrays
|
||||
const question = r.question as any;
|
||||
return question && question.pack;
|
||||
})
|
||||
.map((r) => {
|
||||
// Cast to any to handle Supabase's nested select typing
|
||||
const question = r.question as any;
|
||||
const pack = question.pack as any;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
question_id: r.question_id,
|
||||
answer: r.answer as AnswerType,
|
||||
created_at: r.created_at,
|
||||
question: {
|
||||
id: question.id,
|
||||
text: question.text,
|
||||
partner_text: question.partner_text,
|
||||
intensity: question.intensity,
|
||||
pack_id: question.pack_id,
|
||||
created_at: question.created_at || "",
|
||||
pack: {
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
icon: pack.icon,
|
||||
},
|
||||
},
|
||||
has_match: matchMap.has(r.question_id),
|
||||
match_id: matchMap.get(r.question_id),
|
||||
partner_answered: partnerAnsweredSet.has(r.question_id),
|
||||
};
|
||||
});
|
||||
|
||||
set({ responses: transformedResponses, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error("Error in fetchResponses:", error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateResponse: async (
|
||||
questionId: string,
|
||||
newAnswer: AnswerType,
|
||||
confirmDelete = false
|
||||
): Promise<UpdateResponseResult> => {
|
||||
const { data, error } = await invokeWithAuthRetry("update-response", {
|
||||
body: {
|
||||
question_id: questionId,
|
||||
new_answer: newAnswer,
|
||||
confirm_delete_match: confirmDelete,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Error updating response:", error);
|
||||
return { success: false, error: error.message || "Failed to update response" };
|
||||
}
|
||||
|
||||
const result = data as UpdateResponseResult;
|
||||
|
||||
// If update was successful (not just requiring confirmation), update local state
|
||||
if (result.success && !result.requires_confirmation) {
|
||||
// Update local response state
|
||||
set((state) => ({
|
||||
responses: state.responses.map((r) => {
|
||||
if (r.question_id === questionId) {
|
||||
return {
|
||||
...r,
|
||||
answer: newAnswer,
|
||||
// Update match status based on result
|
||||
has_match: result.match_deleted
|
||||
? false
|
||||
: result.new_match
|
||||
? true
|
||||
: r.has_match,
|
||||
match_id: result.match_deleted
|
||||
? undefined
|
||||
: result.new_match
|
||||
? (result.new_match.id as string)
|
||||
: r.match_id,
|
||||
};
|
||||
}
|
||||
return r;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Refresh matches store if match was created, deleted, or type updated
|
||||
if (result.match_deleted || result.new_match || result.match_type_updated) {
|
||||
useMatchStore.getState().fetchMatches();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
setGroupBy: (groupBy: GroupByOption) => {
|
||||
set({ groupBy });
|
||||
},
|
||||
|
||||
clearResponses: () => {
|
||||
set({ responses: [], isLoading: false, groupBy: "pack" });
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper function to group responses
|
||||
export function groupResponses(
|
||||
responses: ResponseWithQuestion[],
|
||||
groupBy: GroupByOption
|
||||
): { title: string; data: ResponseWithQuestion[] }[] {
|
||||
if (groupBy === "pack") {
|
||||
// Group by pack name
|
||||
const groups = new Map<string, ResponseWithQuestion[]>();
|
||||
responses.forEach((r) => {
|
||||
const packName = r.question.pack.name;
|
||||
if (!groups.has(packName)) {
|
||||
groups.set(packName, []);
|
||||
}
|
||||
groups.get(packName)!.push(r);
|
||||
});
|
||||
return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
|
||||
}
|
||||
|
||||
if (groupBy === "answer") {
|
||||
// Group by answer type
|
||||
const yesResponses = responses.filter((r) => r.answer === "yes");
|
||||
const maybeResponses = responses.filter((r) => r.answer === "maybe");
|
||||
const noResponses = responses.filter((r) => r.answer === "no");
|
||||
|
||||
const sections = [];
|
||||
if (yesResponses.length > 0) sections.push({ title: "Yes", data: yesResponses });
|
||||
if (maybeResponses.length > 0) sections.push({ title: "Maybe", data: maybeResponses });
|
||||
if (noResponses.length > 0) sections.push({ title: "No", data: noResponses });
|
||||
return sections;
|
||||
}
|
||||
|
||||
if (groupBy === "date") {
|
||||
// Group by date (day)
|
||||
const groups = new Map<string, ResponseWithQuestion[]>();
|
||||
responses.forEach((r) => {
|
||||
const date = new Date(r.created_at).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
if (!groups.has(date)) {
|
||||
groups.set(date, []);
|
||||
}
|
||||
groups.get(date)!.push(r);
|
||||
});
|
||||
return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
|
||||
}
|
||||
|
||||
return [{ title: "All Responses", data: responses }];
|
||||
}
|
||||
@@ -87,6 +87,7 @@ export interface Match {
|
||||
match_type: MatchType;
|
||||
is_new: boolean;
|
||||
created_at: string;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
// 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