Move match card from header to message list

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
jjbrunton committed 2026-01-04 20:26:54 +00:00
1 parent 474bbb6829
commit 36cc748d84
5 files changed
+317 -320

No files matched your search

@@ -288,6 +288,7 @@ export const ChatScreen: React.FC = () => {
<ChatMessages <ChatMessages
messages={messages} messages={messages}
userId={user?.id} userId={user?.id}
match={match}
uploadStatus={uploadStatus} uploadStatus={uploadStatus}
partnerTyping={partnerTyping} partnerTyping={partnerTyping}
onImagePress={setFullScreenImage} onImagePress={setFullScreenImage}
@@ -3,18 +3,10 @@ import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import Animated, { import Animated, { FadeIn } from 'react-native-reanimated';
FadeIn,
FadeInDown,
useAnimatedKeyboard,
useAnimatedStyle,
interpolate,
Extrapolation
} from 'react-native-reanimated';
import { EdgeInsets } from 'react-native-safe-area-context'; import { EdgeInsets } from 'react-native-safe-area-context';
import { DecorativeSeparator } from '../../../components/ui'; import { colors, gradients, spacing, typography } from '../../../theme';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
import { Profile } from '../../../types'; import { Profile } from '../../../types';
// Premium color palette for Chat // Premium color palette for Chat
@@ -24,219 +16,81 @@ const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface ChatHeaderProps { interface ChatHeaderProps {
partner: Profile | null; partner: Profile | null;
user: { id: string } | null; user: { id: string } | null;
match: any; // Using any for match as it has joined properties (question, responses) match: any; // Kept for compatibility but unused
insets: EdgeInsets; insets: EdgeInsets;
onBack: () => void; onBack: () => void;
} }
export const ChatHeader: React.FC<ChatHeaderProps> = ({ export const ChatHeader: React.FC<ChatHeaderProps> = ({
partner, partner,
user,
match,
insets, insets,
onBack, onBack,
}) => { }) => {
const isYesYes = match?.match_type === 'yes_yes';
// Modern reanimated keyboard hook
const keyboard = useAnimatedKeyboard();
const containerAnimatedStyle = useAnimatedStyle(() => {
// Collapse when keyboard height exceeds 50px
const animationValue = interpolate(
keyboard.height.value,
[0, 60],
[0, 1],
Extrapolation.CLAMP
);
return {
opacity: interpolate(animationValue, [0, 1], [1, 0]),
maxHeight: interpolate(animationValue, [0, 1], [500, 0]), // Assuming card max height
marginTop: interpolate(animationValue, [0, 1], [spacing.sm, 0]),
transform: [
{ scale: interpolate(animationValue, [0, 1], [1, 0.95]) }
],
overflow: 'hidden',
};
});
return ( return (
<> <Animated.View
<Animated.View entering={FadeIn.duration(300)}
entering={FadeIn.duration(300)} style={[styles.header, { paddingTop: insets.top + spacing.sm }]}
style={[styles.header, { paddingTop: insets.top + spacing.sm }]} >
{/* Gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.6)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.1)`, 'transparent']}
style={styles.headerSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<TouchableOpacity
onPress={onBack}
style={styles.backButton}
activeOpacity={0.7}
> >
{/* Gradient background */} <View style={styles.backButtonInner}>
<LinearGradient <Ionicons name="chevron-back" size={20} color={ACCENT} />
colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.6)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.1)`, 'transparent']}
style={styles.headerSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<TouchableOpacity
onPress={onBack}
style={styles.backButton}
activeOpacity={0.7}
>
<View style={styles.backButtonInner}>
<Ionicons name="chevron-back" size={20} color={ACCENT} />
</View>
</TouchableOpacity>
<View style={styles.headerCenter}>
{partner?.avatar_url ? (
<View style={styles.headerAvatarContainer}>
<Image
source={{ uri: partner.avatar_url }}
style={styles.headerAvatar}
cachePolicy="disk"
transition={200}
/>
{/* Avatar ring */}
<View style={styles.avatarRing} />
</View>
) : (
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.headerAvatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.headerAvatarText}>
{partner?.name?.[0]?.toUpperCase() || "P"}
</Text>
</LinearGradient>
)}
<View style={styles.headerTextContainer}>
<Text style={styles.headerLabel}>CONVERSATION WITH</Text>
<Text style={styles.headerTitle}>{partner?.name || "Partner"}</Text>
</View>
</View> </View>
</TouchableOpacity>
<View style={styles.headerSpacer} /> <View style={styles.headerCenter}>
{partner?.avatar_url ? (
{/* Bottom border */} <View style={styles.headerAvatarContainer}>
<View style={styles.headerBorderBottom} /> <Image
</Animated.View> source={{ uri: partner.avatar_url }}
style={styles.headerAvatar}
{/* Premium Match Card */} cachePolicy="disk"
{match?.question && ( transition={200}
<Animated.View
entering={FadeInDown.delay(100).duration(400).springify()}
style={[styles.matchCardContainer, containerAnimatedStyle]}
>
<View style={styles.matchCard}>
{/* Card gradient background */}
<LinearGradient
colors={isYesYes
? ['rgba(212, 175, 55, 0.12)', 'rgba(184, 134, 11, 0.08)']
: ['rgba(232, 164, 174, 0.1)', 'rgba(22, 33, 62, 0.6)']
}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/> />
{/* Avatar ring */}
{/* Top glow for YES+YES */} <View style={styles.avatarRing} />
{isYesYes && (
<LinearGradient
colors={[`${ACCENT_RGBA}0.15)`, 'transparent']}
style={styles.matchCardGlow}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
)}
{/* Match Label */}
<Text style={[styles.matchLabel, isYesYes && styles.matchLabelYesYes]}>
{isYesYes ? "PERFECT MATCH" : "SOFT MATCH"}
</Text>
{/* Decorative Separator */}
<DecorativeSeparator
variant={isYesYes ? "gold" : "rose"}
width="60%"
marginVertical={spacing.md}
/>
{/* Question and Responses - Show who answered what */}
{match.responses && match.responses.length > 0 ? (
<View style={styles.responsesContainer}>
{(() => {
// Sort responses by created_at to determine who answered first
const sortedResponses = [...match.responses].sort(
(a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
const isTwoPart = !!match.question.partner_text;
return sortedResponses.map((response: any, index: number) => {
const isUser = response.user_id === user?.id;
const isYes = response.answer === 'yes';
const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner');
// First responder sees main text, second sees partner_text
const questionText = isTwoPart && index === 1
? match.question.partner_text
: match.question.text;
return (
<View key={response.user_id} style={styles.responseCard}>
{/* Name and Answer Badge */}
<View style={styles.responseHeader}>
<Text style={styles.responseName}>{name}</Text>
{isYes ? (
<LinearGradient
colors={isYesYes
? gradients.premiumGold as [string, string]
: [colors.success, '#27ae60']
}
style={styles.answerBadge}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="checkmark" size={10} color={colors.text} />
<Text style={styles.answerBadgeText}>YES</Text>
</LinearGradient>
) : (
<View style={styles.answerBadgeMaybe}>
<Ionicons name="help" size={10} color={colors.warning} />
<Text style={styles.answerBadgeTextMaybe}>MAYBE</Text>
</View>
)}
</View>
{/* Question they saw */}
<Text style={styles.responseQuestionText}>
"{questionText}"
</Text>
</View>
);
});
})()}
</View>
) : (
<Text style={styles.matchQuestionText}>
"{match.question.text}"
</Text>
)}
{/* Premium border */}
<View style={[
styles.matchCardBorder,
isYesYes && styles.matchCardBorderYesYes
]} pointerEvents="none" />
</View> </View>
</Animated.View> ) : (
)} <LinearGradient
</> colors={gradients.primary as [string, string]}
style={styles.headerAvatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.headerAvatarText}>
{partner?.name?.[0]?.toUpperCase() || "P"}
</Text>
</LinearGradient>
)}
<View style={styles.headerTextContainer}>
<Text style={styles.headerLabel}>CONVERSATION WITH</Text>
<Text style={styles.headerTitle}>{partner?.name || "Partner"}</Text>
</View>
</View>
<View style={styles.headerSpacer} />
{/* Bottom border */}
<View style={styles.headerBorderBottom} />
</Animated.View>
); );
}; };
@@ -333,116 +187,4 @@ const styles = StyleSheet.create({
height: 1, height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`, backgroundColor: `${ACCENT_RGBA}0.15)`,
}, },
matchCardContainer: {
marginHorizontal: spacing.md,
// Vertical margins are handled by animation
borderRadius: radius.lg,
overflow: 'hidden',
},
matchCard: {
// Margins moved to container
borderRadius: radius.lg,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
alignItems: 'center',
overflow: 'hidden',
},
matchCardGlow: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
matchLabel: {
...typography.caption2,
fontWeight: '700',
letterSpacing: 2,
color: colors.premium.rose,
marginBottom: spacing.xs,
},
matchLabelYesYes: {
color: ACCENT,
},
matchQuestionText: {
...typography.subhead,
color: colors.text,
textAlign: 'center',
fontStyle: 'italic',
lineHeight: 20,
},
responsesContainer: {
width: '100%',
gap: spacing.sm,
},
responseCard: {
backgroundColor: 'rgba(255, 255, 255, 0.03)',
borderRadius: radius.md,
padding: spacing.sm,
borderWidth: 1,
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,
},
answerBadgeText: {
...typography.caption2,
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',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: `rgba(232, 164, 174, 0.2)`,
},
matchCardBorderYesYes: {
borderColor: `${ACCENT_RGBA}0.25)`,
},
}); });
@@ -8,6 +8,7 @@ import { MessageContent } from './MessageContent';
import { DateSeparator } from './DateSeparator'; import { DateSeparator } from './DateSeparator';
import { UploadProgress } from './UploadProgress'; import { UploadProgress } from './UploadProgress';
import { TypingIndicator } from './TypingIndicator'; import { TypingIndicator } from './TypingIndicator';
import { MatchCard } from './MatchCard';
import { DecorativeSeparator } from '../../../components/ui'; import { DecorativeSeparator } from '../../../components/ui';
import { colors, spacing, typography } from '../../../theme'; import { colors, spacing, typography } from '../../../theme';
import { Database } from '../../../types/supabase'; import { Database } from '../../../types/supabase';
@@ -18,6 +19,7 @@ type Message = Database['public']['Tables']['messages']['Row'];
interface ChatMessagesProps { interface ChatMessagesProps {
messages: Message[]; messages: Message[];
userId: string | undefined; userId: string | undefined;
match: any;
uploadStatus: UploadStatus | null; uploadStatus: UploadStatus | null;
partnerTyping: boolean; partnerTyping: boolean;
onImagePress: (uri: string) => void; onImagePress: (uri: string) => void;
@@ -44,6 +46,7 @@ function isSameDay(date1: Date, date2: Date): boolean {
export const ChatMessages: React.FC<ChatMessagesProps> = ({ export const ChatMessages: React.FC<ChatMessagesProps> = ({
messages, messages,
userId, userId,
match,
uploadStatus, uploadStatus,
partnerTyping, partnerTyping,
onImagePress, onImagePress,
@@ -107,6 +110,11 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
{partnerTyping && <TypingIndicator />} {partnerTyping && <TypingIndicator />}
</> </>
} }
ListFooterComponent={
<View style={{ paddingBottom: spacing.md }}>
<MatchCard match={match} user={{ id: userId || '' }} />
</View>
}
ListEmptyComponent={ ListEmptyComponent={
<Animated.View <Animated.View
entering={FadeIn.delay(200).duration(400)} entering={FadeIn.delay(200).duration(400)}
@@ -0,0 +1,245 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface MatchCardProps {
match: any;
user: { id: string } | null;
}
export const MatchCard: React.FC<MatchCardProps> = ({ match, user }) => {
if (!match?.question) return null;
const isYesYes = match?.match_type === 'yes_yes';
return (
<Animated.View
entering={FadeInDown.delay(100).duration(400).springify()}
style={styles.matchCardContainer}
>
<View style={styles.matchCard}>
{/* Card gradient background */}
<LinearGradient
colors={isYesYes
? ['rgba(212, 175, 55, 0.12)', 'rgba(184, 134, 11, 0.08)']
: ['rgba(232, 164, 174, 0.1)', 'rgba(22, 33, 62, 0.6)']
}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Top glow for YES+YES */}
{isYesYes && (
<LinearGradient
colors={[`${ACCENT_RGBA}0.15)`, 'transparent']}
style={styles.matchCardGlow}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
)}
{/* Match Label */}
<Text style={[styles.matchLabel, isYesYes && styles.matchLabelYesYes]}>
{isYesYes ? "PERFECT MATCH" : "SOFT MATCH"}
</Text>
{/* Decorative Separator */}
<DecorativeSeparator
variant={isYesYes ? "gold" : "rose"}
width="60%"
marginVertical={spacing.md}
/>
{/* Question and Responses - Show who answered what */}
{match.responses && match.responses.length > 0 ? (
<View style={styles.responsesContainer}>
{(() => {
// Sort responses by created_at to determine who answered first
const sortedResponses = [...match.responses].sort(
(a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
const isTwoPart = !!match.question.partner_text;
return sortedResponses.map((response: any, index: number) => {
const isUser = response.user_id === user?.id;
const isYes = response.answer === 'yes';
const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner');
// First responder sees main text, second sees partner_text
const questionText = isTwoPart && index === 1
? match.question.partner_text
: match.question.text;
return (
<View key={response.user_id} style={styles.responseCard}>
{/* Name and Answer Badge */}
<View style={styles.responseHeader}>
<Text style={styles.responseName}>{name}</Text>
{isYes ? (
<LinearGradient
colors={isYesYes
? gradients.premiumGold as [string, string]
: [colors.success, '#27ae60']
}
style={styles.answerBadge}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="checkmark" size={10} color={colors.text} />
<Text style={styles.answerBadgeText}>YES</Text>
</LinearGradient>
) : (
<View style={styles.answerBadgeMaybe}>
<Ionicons name="help" size={10} color={colors.warning} />
<Text style={styles.answerBadgeTextMaybe}>MAYBE</Text>
</View>
)}
</View>
{/* Question they saw */}
<Text style={styles.responseQuestionText}>
"{questionText}"
</Text>
</View>
);
});
})()}
</View>
) : (
<Text style={styles.matchQuestionText}>
"{match.question.text}"
</Text>
)}
{/* Premium border */}
<View style={[
styles.matchCardBorder,
isYesYes && styles.matchCardBorderYesYes
]} pointerEvents="none" />
</View>
</Animated.View>
);
};
const styles = StyleSheet.create({
matchCardContainer: {
marginHorizontal: spacing.md,
marginTop: spacing.md,
borderRadius: radius.lg,
overflow: 'hidden',
},
matchCard: {
borderRadius: radius.lg,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
alignItems: 'center',
overflow: 'hidden',
},
matchCardGlow: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
matchLabel: {
...typography.caption2,
fontWeight: '700',
letterSpacing: 2,
color: colors.premium.rose,
marginBottom: spacing.xs,
},
matchLabelYesYes: {
color: ACCENT,
},
matchQuestionText: {
...typography.subhead,
color: colors.text,
textAlign: 'center',
fontStyle: 'italic',
lineHeight: 20,
},
responsesContainer: {
width: '100%',
gap: spacing.sm,
},
responseCard: {
backgroundColor: 'rgba(255, 255, 255, 0.03)',
borderRadius: radius.md,
padding: spacing.sm,
borderWidth: 1,
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,
},
answerBadgeText: {
...typography.caption2,
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',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: `rgba(232, 164, 174, 0.2)`,
},
matchCardBorderYesYes: {
borderColor: `${ACCENT_RGBA}0.25)`,
},
});
@@ -14,3 +14,4 @@ export { MessageBubble } from './MessageBubble';
export type { MessageBubbleProps } from './MessageBubble'; export type { MessageBubbleProps } from './MessageBubble';
export { ChatHeader } from './ChatHeader'; export { ChatHeader } from './ChatHeader';
export { ChatMessages } from './ChatMessages'; export { ChatMessages } from './ChatMessages';
export { MatchCard } from './MatchCard';