mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
- Extract ProfileScreen and components to features/profile - Move shared hooks and stores to top-level - Add RLS policies for admin permissions - Add RELEASING.md guide - Update .gitignore
577 lines
20 KiB
TypeScript
Executable File
577 lines
20 KiB
TypeScript
Executable File
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
|
|
import { useMatchStore, useAuthStore } from "../../src/store";
|
|
import { useEffect, useCallback, useState } from "react";
|
|
import { useFocusEffect } from "expo-router";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { useRouter } from "expo-router";
|
|
import { LinearGradient } from "expo-linear-gradient";
|
|
import Animated, {
|
|
FadeIn,
|
|
FadeInDown,
|
|
FadeInRight,
|
|
FadeInUp,
|
|
} from "react-native-reanimated";
|
|
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
|
import { useAmbientOrbAnimation } from "../../src/hooks";
|
|
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
|
import { MatchesTutorial } from "../../src/components/tutorials";
|
|
import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
|
|
|
|
// 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;
|
|
|
|
export default function MatchesScreen() {
|
|
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
|
|
const { user } = useAuthStore();
|
|
const router = useRouter();
|
|
const { width } = useWindowDimensions();
|
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
|
const [showTutorial, setShowTutorial] = useState(false);
|
|
|
|
// Ambient orb breathing animations
|
|
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
|
|
|
// Check if tutorial should be shown when screen is focused or matches change
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
const checkTutorial = async () => {
|
|
if (matches.length > 0) {
|
|
const seen = await hasSeenMatchesTutorial();
|
|
if (!seen) {
|
|
setShowTutorial(true);
|
|
}
|
|
}
|
|
};
|
|
checkTutorial();
|
|
}, [matches.length])
|
|
);
|
|
|
|
const handleTutorialComplete = async () => {
|
|
await markMatchesTutorialSeen();
|
|
setShowTutorial(false);
|
|
};
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
fetchMatches();
|
|
}, [])
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (matches.length > 0) {
|
|
markAllAsSeen();
|
|
}
|
|
}, [matches.length]);
|
|
|
|
const renderItem = ({ item, index }: { item: any; index: number }) => {
|
|
const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
|
|
const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
|
|
|
|
let userText = item.question.text;
|
|
let partnerText = item.question.partner_text;
|
|
|
|
if (item.question.partner_text && userResponse && partnerResponse) {
|
|
const userTime = new Date(userResponse.created_at).getTime();
|
|
const partnerTime = new Date(partnerResponse.created_at).getTime();
|
|
|
|
if (userTime > partnerTime) {
|
|
userText = item.question.partner_text;
|
|
partnerText = item.question.text;
|
|
}
|
|
}
|
|
|
|
const isYesYes = item.match_type === "yes_yes";
|
|
|
|
return (
|
|
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
|
<TouchableOpacity
|
|
onPress={() => router.push(`/chat/${item.id}`)}
|
|
activeOpacity={0.8}
|
|
>
|
|
<View style={styles.matchCardPremium}>
|
|
{/* 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={[`${ACCENT_RGBA}0.06)`, 'transparent']}
|
|
style={styles.cardSilkHighlight}
|
|
start={{ x: 0.5, y: 0 }}
|
|
end={{ x: 0.5, y: 1 }}
|
|
/>
|
|
|
|
<View style={styles.matchRow}>
|
|
{/* Premium icon container */}
|
|
<View style={[
|
|
styles.iconContainerPremium,
|
|
isYesYes && styles.iconContainerYesYes
|
|
]}>
|
|
<Ionicons
|
|
name={isYesYes ? "heart" : "heart-half"}
|
|
size={20}
|
|
color={isYesYes ? ACCENT : ROSE}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.content}>
|
|
<Text style={styles.questionTextPremium} numberOfLines={2}>
|
|
{userText}
|
|
</Text>
|
|
{item.question.partner_text && (
|
|
<Text style={styles.partnerTextPremium} numberOfLines={1}>
|
|
Partner: {partnerText}
|
|
</Text>
|
|
)}
|
|
<View style={styles.metaRow}>
|
|
<View style={[
|
|
styles.tagPremium,
|
|
isYesYes && styles.tagPremiumHighlight
|
|
]}>
|
|
<Text style={[
|
|
styles.tagTextPremium,
|
|
isYesYes && styles.tagTextPremiumHighlight
|
|
]}>
|
|
{isYesYes ? "YES + YES" : "YES + MAYBE"}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.datePremium}>
|
|
{new Date(item.created_at).toLocaleDateString()}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.rightSection}>
|
|
{item.unreadCount > 0 && (
|
|
<View style={styles.unreadBadgePremium}>
|
|
<Ionicons name="chatbubble" size={10} color={colors.text} />
|
|
</View>
|
|
)}
|
|
<View style={styles.chevronContainerPremium}>
|
|
<Ionicons name="chevron-forward" size={16} color={`${ACCENT_RGBA}0.6)`} />
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Premium border */}
|
|
<View style={styles.cardPremiumBorder} pointerEvents="none" />
|
|
</View>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
if (!user) {
|
|
return (
|
|
<GradientBackground>
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator color={colors.primary} size="large" />
|
|
</View>
|
|
</GradientBackground>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<GradientBackground>
|
|
{/* Ambient Orbs - Premium gold/rose */}
|
|
<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}>
|
|
{/* Premium Header */}
|
|
<Animated.View
|
|
entering={FadeIn.duration(400)}
|
|
style={[styles.header, isWideScreen && styles.headerWide]}
|
|
>
|
|
<View style={styles.headerContent}>
|
|
{/* Premium label */}
|
|
<Text style={styles.headerLabel}>DISCOVER</Text>
|
|
<Text style={styles.headerTitle}>Matches</Text>
|
|
|
|
{/* Decorative separator */}
|
|
<DecorativeSeparator variant="gold" />
|
|
|
|
{/* Count badge */}
|
|
<View style={styles.countBadgePremium}>
|
|
<Ionicons name="heart" size={12} color={ACCENT} />
|
|
<Text style={styles.countTextPremium}>{matches.length} {matches.length === 1 ? 'MATCH' : 'MATCHES'}</Text>
|
|
</View>
|
|
</View>
|
|
</Animated.View>
|
|
|
|
<FlatList
|
|
data={matches}
|
|
renderItem={renderItem}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
|
showsVerticalScrollIndicator={false}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={false}
|
|
onRefresh={fetchMatches}
|
|
tintColor={colors.primary}
|
|
colors={[colors.primary]}
|
|
/>
|
|
}
|
|
ListEmptyComponent={
|
|
<Animated.View
|
|
entering={FadeInUp.duration(600).springify()}
|
|
style={styles.emptyContent}
|
|
>
|
|
{/* Premium icon */}
|
|
<View style={styles.emptyIconContainerPremium}>
|
|
<Ionicons name="heart-outline" size={36} color={ROSE} />
|
|
</View>
|
|
|
|
{/* Title section */}
|
|
<Text style={styles.emptyLabel}>BEGIN</Text>
|
|
<Text style={styles.emptyTitlePremium}>No Matches Yet</Text>
|
|
|
|
{/* Decorative separator */}
|
|
<DecorativeSeparator variant="rose" />
|
|
|
|
{/* Status badge */}
|
|
<Animated.View
|
|
entering={FadeIn.delay(300).duration(400)}
|
|
style={styles.emptyBadge}
|
|
>
|
|
<Text style={styles.emptyBadgeText}>START EXPLORING</Text>
|
|
</Animated.View>
|
|
|
|
{/* Description */}
|
|
<Text style={styles.emptyDescription}>
|
|
Answer questions together to discover what you both enjoy. Matches appear when you agree!
|
|
</Text>
|
|
|
|
{/* Feature hints */}
|
|
<View style={styles.emptyFeatures}>
|
|
<View style={styles.emptyFeatureItem}>
|
|
<Ionicons name="heart" size={16} color={ROSE} />
|
|
<Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
|
|
</View>
|
|
<View style={styles.emptyFeatureItem}>
|
|
<Ionicons name="sparkles" size={16} color={ROSE} />
|
|
<Text style={styles.emptyFeatureText}>Match when you both agree</Text>
|
|
</View>
|
|
<View style={styles.emptyFeatureItem}>
|
|
<Ionicons name="chatbubbles-outline" size={16} color={ROSE} />
|
|
<Text style={styles.emptyFeatureText}>Chat about your matches</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Bottom teaser */}
|
|
<Text style={styles.emptyTeaser}>Your first match is just a swipe away</Text>
|
|
|
|
<GlassButton
|
|
onPress={() => router.push("/(app)/swipe")}
|
|
style={{ marginTop: spacing.lg }}
|
|
>
|
|
Start Swiping
|
|
</GlassButton>
|
|
</Animated.View>
|
|
}
|
|
/>
|
|
</View>
|
|
|
|
{/* Matches Tutorial Overlay */}
|
|
{showTutorial && (
|
|
<MatchesTutorial onComplete={handleTutorialComplete} />
|
|
)}
|
|
</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,
|
|
},
|
|
// Premium Header
|
|
header: {
|
|
paddingTop: 60,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingBottom: spacing.md,
|
|
},
|
|
headerWide: {
|
|
alignSelf: 'center',
|
|
width: '100%',
|
|
maxWidth: MAX_CONTENT_WIDTH,
|
|
},
|
|
headerContent: {
|
|
alignItems: 'center',
|
|
},
|
|
headerLabel: {
|
|
...typography.caption2,
|
|
fontWeight: '600',
|
|
letterSpacing: 3,
|
|
color: ACCENT,
|
|
marginBottom: spacing.xs,
|
|
},
|
|
headerTitle: {
|
|
...typography.largeTitle,
|
|
color: colors.text,
|
|
textAlign: 'center',
|
|
},
|
|
countBadgePremium: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderRadius: radius.full,
|
|
borderWidth: 1,
|
|
borderColor: `${ACCENT_RGBA}0.2)`,
|
|
gap: spacing.xs,
|
|
},
|
|
countTextPremium: {
|
|
...typography.caption2,
|
|
fontWeight: '600',
|
|
letterSpacing: 2,
|
|
color: ACCENT,
|
|
},
|
|
// List
|
|
list: {
|
|
padding: spacing.lg,
|
|
paddingTop: spacing.sm,
|
|
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
|
},
|
|
listWide: {
|
|
alignSelf: 'center',
|
|
width: '100%',
|
|
maxWidth: MAX_CONTENT_WIDTH,
|
|
},
|
|
// Premium Match Card
|
|
matchCardPremium: {
|
|
marginBottom: spacing.sm,
|
|
borderRadius: radius.lg,
|
|
overflow: 'hidden',
|
|
padding: spacing.md,
|
|
},
|
|
cardSilkHighlight: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 60,
|
|
},
|
|
cardPremiumBorder: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
borderRadius: radius.lg,
|
|
borderWidth: 1,
|
|
borderColor: `${ACCENT_RGBA}0.15)`,
|
|
},
|
|
matchRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
iconContainerPremium: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 22,
|
|
backgroundColor: `${ROSE_RGBA}0.1)`,
|
|
borderWidth: 1,
|
|
borderColor: `${ROSE_RGBA}0.2)`,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
marginRight: spacing.md,
|
|
},
|
|
iconContainerYesYes: {
|
|
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
|
borderColor: `${ACCENT_RGBA}0.2)`,
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
},
|
|
questionTextPremium: {
|
|
...typography.subhead,
|
|
color: colors.text,
|
|
fontWeight: "600",
|
|
marginBottom: spacing.xs,
|
|
lineHeight: 20,
|
|
},
|
|
partnerTextPremium: {
|
|
...typography.caption1,
|
|
color: colors.textSecondary,
|
|
fontStyle: "italic",
|
|
marginBottom: spacing.sm,
|
|
},
|
|
metaRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
},
|
|
tagPremium: {
|
|
backgroundColor: `${ROSE_RGBA}0.1)`,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 3,
|
|
borderRadius: radius.sm,
|
|
borderWidth: 1,
|
|
borderColor: `${ROSE_RGBA}0.15)`,
|
|
},
|
|
tagPremiumHighlight: {
|
|
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
|
borderColor: `${ACCENT_RGBA}0.2)`,
|
|
},
|
|
tagTextPremium: {
|
|
...typography.caption2,
|
|
color: ROSE,
|
|
fontWeight: "600",
|
|
letterSpacing: 1,
|
|
},
|
|
tagTextPremiumHighlight: {
|
|
color: ACCENT,
|
|
},
|
|
datePremium: {
|
|
...typography.caption2,
|
|
color: colors.textTertiary,
|
|
fontStyle: 'italic',
|
|
},
|
|
rightSection: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
marginLeft: spacing.sm,
|
|
},
|
|
unreadBadgePremium: {
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 11,
|
|
backgroundColor: ACCENT,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
chevronContainerPremium: {
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 14,
|
|
backgroundColor: `${ACCENT_RGBA}0.08)`,
|
|
borderWidth: 1,
|
|
borderColor: `${ACCENT_RGBA}0.15)`,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
// Premium Empty State
|
|
emptyContent: {
|
|
width: '100%',
|
|
alignItems: 'center',
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.xl,
|
|
},
|
|
emptyIconContainerPremium: {
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: 36,
|
|
backgroundColor: `${ROSE_RGBA}0.1)`,
|
|
borderWidth: 1,
|
|
borderColor: `${ROSE_RGBA}0.2)`,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginBottom: spacing.xl,
|
|
},
|
|
emptyLabel: {
|
|
...typography.caption1,
|
|
fontWeight: '600',
|
|
letterSpacing: 3,
|
|
color: ROSE,
|
|
textAlign: 'center',
|
|
marginBottom: spacing.xs,
|
|
},
|
|
emptyTitlePremium: {
|
|
...typography.largeTitle,
|
|
color: colors.text,
|
|
textAlign: 'center',
|
|
},
|
|
emptyBadge: {
|
|
backgroundColor: `${ROSE_RGBA}0.1)`,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm,
|
|
borderRadius: radius.full,
|
|
borderWidth: 1,
|
|
borderColor: `${ROSE_RGBA}0.2)`,
|
|
marginBottom: spacing.xl,
|
|
},
|
|
emptyBadgeText: {
|
|
...typography.caption2,
|
|
fontWeight: '600',
|
|
letterSpacing: 2,
|
|
color: ROSE,
|
|
},
|
|
emptyDescription: {
|
|
...typography.body,
|
|
color: colors.textSecondary,
|
|
textAlign: 'center',
|
|
marginBottom: spacing.xl,
|
|
paddingHorizontal: spacing.md,
|
|
},
|
|
emptyFeatures: {
|
|
marginBottom: spacing.xl,
|
|
},
|
|
emptyFeatureItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: spacing.sm,
|
|
},
|
|
emptyFeatureText: {
|
|
...typography.subhead,
|
|
color: colors.text,
|
|
marginLeft: spacing.sm,
|
|
},
|
|
emptyTeaser: {
|
|
...typography.footnote,
|
|
fontStyle: 'italic',
|
|
color: colors.textTertiary,
|
|
textAlign: 'center',
|
|
},
|
|
});
|