mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
- New discovery components (ContentTile, ContentRow, CircularProgress) - Refactor home screen with compact header and content tiles - Add hide_nsfw profile setting for content filtering - Add color column to categories table - Track pack progress (answered/total questions) - Update related stores and tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
544 lines
19 KiB
TypeScript
544 lines
19 KiB
TypeScript
/**
|
|
* MatchNotificationModal - Premium celebration modal for new matches
|
|
*
|
|
* Features:
|
|
* - Glass-morphism styling following boutique design aesthetic
|
|
* - Animated floating hearts celebration effect
|
|
* - Shows what each partner answered (You / Partner)
|
|
* - Uses brand gradients and premium styling
|
|
*/
|
|
import React, { useEffect, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
Pressable,
|
|
Animated,
|
|
Keyboard,
|
|
Platform,
|
|
} from 'react-native';
|
|
import * as Haptics from 'expo-haptics';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { BlurView } from 'expo-blur';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { colors, gradients, spacing, radius, typography, shadows } from '../theme';
|
|
import { DecorativeSeparator } from './ui';
|
|
import type { MatchType, AnswerType } from '../types';
|
|
|
|
export interface MatchNotificationData {
|
|
id: string;
|
|
match_type: MatchType;
|
|
question: {
|
|
id: string;
|
|
text: string;
|
|
partner_text?: string | null;
|
|
};
|
|
userResponse?: AnswerType;
|
|
partnerResponse?: AnswerType;
|
|
partnerName?: string | null;
|
|
}
|
|
|
|
interface Props {
|
|
visible: boolean;
|
|
match: MatchNotificationData | null;
|
|
onDismiss: () => void;
|
|
onChat: () => void;
|
|
}
|
|
|
|
// Helper to get response styling
|
|
const getResponseStyle = (response: AnswerType, isPartner: boolean = false) => {
|
|
switch (response) {
|
|
case 'yes':
|
|
return {
|
|
bg: isPartner ? 'rgba(46, 204, 113, 0.15)' : 'rgba(233, 69, 96, 0.15)',
|
|
border: isPartner ? 'rgba(46, 204, 113, 0.3)' : 'rgba(233, 69, 96, 0.3)',
|
|
color: isPartner ? colors.success : colors.primary,
|
|
text: 'YES',
|
|
icon: 'heart' as const,
|
|
};
|
|
case 'maybe':
|
|
return {
|
|
bg: 'rgba(243, 156, 18, 0.15)',
|
|
border: 'rgba(243, 156, 18, 0.3)',
|
|
color: colors.warning,
|
|
text: 'MAYBE',
|
|
icon: 'help-circle' as const,
|
|
};
|
|
default:
|
|
return {
|
|
bg: 'rgba(255, 255, 255, 0.1)',
|
|
border: 'rgba(255, 255, 255, 0.2)',
|
|
color: colors.textSecondary,
|
|
text: response.toUpperCase(),
|
|
icon: 'ellipse' as const,
|
|
};
|
|
}
|
|
};
|
|
|
|
// Compact response badge (for two-part questions)
|
|
const ResponseBadge: React.FC<{
|
|
response: AnswerType;
|
|
isPartner?: boolean;
|
|
}> = ({ response, isPartner = false }) => {
|
|
const style = getResponseStyle(response, isPartner);
|
|
|
|
return (
|
|
<View
|
|
style={[
|
|
styles.responseBadgeCompact,
|
|
{ backgroundColor: style.bg, borderColor: style.border },
|
|
]}
|
|
>
|
|
<Ionicons name={style.icon} size={14} color={style.color} />
|
|
<Text style={[styles.responseBadgeTextCompact, { color: style.color }]}>
|
|
{style.text}
|
|
</Text>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export const MatchNotificationModal: React.FC<Props> = ({
|
|
visible,
|
|
match,
|
|
onDismiss,
|
|
onChat,
|
|
}) => {
|
|
const modalScale = useRef(new Animated.Value(0.8)).current;
|
|
const modalOpacity = useRef(new Animated.Value(0)).current;
|
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
// Dismiss keyboard so it doesn't block the modal buttons
|
|
Keyboard.dismiss();
|
|
|
|
// Trigger celebration haptic pattern
|
|
if (Platform.OS !== 'web') {
|
|
// Success notification followed by a heavy impact for extra punch
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
setTimeout(() => {
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
|
}, 150);
|
|
}
|
|
|
|
// Reset animations
|
|
modalScale.setValue(0.8);
|
|
modalOpacity.setValue(0);
|
|
overlayOpacity.setValue(0);
|
|
|
|
// Animate in
|
|
Animated.parallel([
|
|
Animated.timing(overlayOpacity, {
|
|
toValue: 1,
|
|
duration: 250,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.spring(modalScale, {
|
|
toValue: 1,
|
|
tension: 80,
|
|
friction: 10,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(modalOpacity, {
|
|
toValue: 1,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
}
|
|
}, [visible]);
|
|
|
|
const handleDismiss = () => {
|
|
Animated.parallel([
|
|
Animated.timing(overlayOpacity, {
|
|
toValue: 0,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(modalScale, {
|
|
toValue: 0.8,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(modalOpacity, {
|
|
toValue: 0,
|
|
duration: 150,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start(() => onDismiss());
|
|
};
|
|
|
|
const handleChat = () => {
|
|
Animated.parallel([
|
|
Animated.timing(overlayOpacity, {
|
|
toValue: 0,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(modalScale, {
|
|
toValue: 0.8,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(modalOpacity, {
|
|
toValue: 0,
|
|
duration: 150,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start(() => onChat());
|
|
};
|
|
|
|
if (!visible || !match || !match.question) return null;
|
|
|
|
// Determine responses based on match_type if not explicitly provided
|
|
const getUserResponse = (): AnswerType => {
|
|
if (match.userResponse) return match.userResponse;
|
|
// Infer from match_type (user typically triggers the match)
|
|
if (match.match_type === 'yes_yes') return 'yes';
|
|
if (match.match_type === 'maybe_maybe') return 'maybe';
|
|
// yes_maybe - user could be either
|
|
return 'yes';
|
|
};
|
|
|
|
const getPartnerResponse = (): AnswerType => {
|
|
if (match.partnerResponse) return match.partnerResponse;
|
|
// Infer from match_type
|
|
if (match.match_type === 'yes_yes') return 'yes';
|
|
if (match.match_type === 'maybe_maybe') return 'maybe';
|
|
// yes_maybe - partner could be either
|
|
return 'maybe';
|
|
};
|
|
|
|
const userResponse = getUserResponse();
|
|
const partnerResponse = getPartnerResponse();
|
|
|
|
// Get match type styling
|
|
const getMatchTypeLabel = () => {
|
|
switch (match.match_type) {
|
|
case 'yes_yes':
|
|
return { text: 'Perfect Match', color: colors.primary };
|
|
case 'yes_maybe':
|
|
return { text: 'Curious Match', color: colors.warning };
|
|
case 'maybe_maybe':
|
|
return { text: 'Exploring Together', color: colors.premium.rose };
|
|
default:
|
|
return { text: 'Match', color: colors.primary };
|
|
}
|
|
};
|
|
|
|
const matchTypeInfo = getMatchTypeLabel();
|
|
|
|
// Use partner's name or fallback to "Partner"
|
|
const partnerDisplayName = match.partnerName || 'Partner';
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[styles.overlay, { opacity: overlayOpacity }]}
|
|
>
|
|
{/* Background press to dismiss */}
|
|
<Pressable style={StyleSheet.absoluteFill} onPress={handleDismiss} />
|
|
|
|
{/* Modal card with glow */}
|
|
<Animated.View
|
|
style={[
|
|
styles.glowContainer,
|
|
{
|
|
transform: [{ scale: modalScale }],
|
|
opacity: modalOpacity,
|
|
},
|
|
]}
|
|
>
|
|
{/* Outer glow effect */}
|
|
<View style={styles.glowOuter} />
|
|
|
|
<View style={styles.modalContainer}>
|
|
{/* Glass background */}
|
|
<BlurView intensity={40} tint="dark" style={styles.blurContainer}>
|
|
<LinearGradient
|
|
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
|
|
style={styles.gradientOverlay}
|
|
/>
|
|
</BlurView>
|
|
|
|
{/* Content */}
|
|
<View style={styles.content}>
|
|
{/* Title section */}
|
|
<Text style={styles.title}>It's a Match!</Text>
|
|
|
|
{/* Match type badge */}
|
|
<View style={[styles.matchTypeBadge, { borderColor: `${matchTypeInfo.color}30` }]}>
|
|
<Text style={[styles.matchTypeText, { color: matchTypeInfo.color }]}>
|
|
{matchTypeInfo.text}
|
|
</Text>
|
|
</View>
|
|
|
|
<DecorativeSeparator variant="muted" width={80} marginVertical={spacing.md} />
|
|
|
|
{/* Question text and responses */}
|
|
{match.question.partner_text ? (
|
|
// Two-part question - show what each person saw
|
|
// The current user triggered the match (answered second), so they saw partner_text
|
|
// The partner answered first, so they saw text
|
|
<View style={styles.twoPartContainer}>
|
|
<View style={styles.responseSection}>
|
|
<View style={styles.responseLabelRow}>
|
|
<Text style={styles.responsePersonLabel}>You said</Text>
|
|
<ResponseBadge response={userResponse} />
|
|
</View>
|
|
<Text style={styles.questionTextSmall}>"{match.question.partner_text}"</Text>
|
|
</View>
|
|
|
|
<View style={styles.matchConnector}>
|
|
<View style={styles.connectorLine} />
|
|
<View style={styles.connectorHeart}>
|
|
<Ionicons name="heart" size={16} color={colors.primary} />
|
|
</View>
|
|
<View style={styles.connectorLine} />
|
|
</View>
|
|
|
|
<View style={styles.responseSection}>
|
|
<View style={styles.responseLabelRow}>
|
|
<Text style={styles.responsePersonLabel}>{partnerDisplayName} said</Text>
|
|
<ResponseBadge response={partnerResponse} isPartner />
|
|
</View>
|
|
<Text style={styles.questionTextSmall}>"{match.question.text}"</Text>
|
|
</View>
|
|
</View>
|
|
) : (
|
|
// Single question - show shared text with inline responses
|
|
<View style={styles.questionContainer}>
|
|
<Text style={styles.questionText}>"{match.question.text}"</Text>
|
|
<View style={styles.responsesInline}>
|
|
<Text style={styles.responseInlineText}>You said </Text>
|
|
<Text style={[styles.responseInlineAnswer, { color: userResponse === 'yes' ? colors.primary : colors.warning }]}>
|
|
{userResponse.toUpperCase()}
|
|
</Text>
|
|
<Text style={styles.responseInlineText}> · {partnerDisplayName} said </Text>
|
|
<Text style={[styles.responseInlineAnswer, { color: partnerResponse === 'yes' ? colors.success : colors.warning }]}>
|
|
{partnerResponse.toUpperCase()}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
<DecorativeSeparator variant="muted" width={100} marginVertical={spacing.md} />
|
|
|
|
{/* Action buttons */}
|
|
<View style={styles.buttonRow}>
|
|
<Pressable onPress={handleChat} style={styles.chatButton}>
|
|
<LinearGradient
|
|
colors={gradients.primary as [string, string]}
|
|
start={{ x: 0, y: 0 }}
|
|
end={{ x: 1, y: 0 }}
|
|
style={styles.chatButtonGradient}
|
|
>
|
|
<Ionicons name="chatbubble" size={16} color={colors.text} />
|
|
<Text style={styles.chatButtonText}>Chat</Text>
|
|
</LinearGradient>
|
|
</Pressable>
|
|
|
|
<Pressable onPress={handleDismiss} style={styles.dismissButton}>
|
|
<Text style={styles.dismissButtonText}>Continue</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Border */}
|
|
<View style={styles.border} pointerEvents="none" />
|
|
</View>
|
|
</Animated.View>
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
overlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
zIndex: 9999,
|
|
},
|
|
glowContainer: {
|
|
width: '88%',
|
|
maxWidth: 360,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
glowOuter: {
|
|
position: 'absolute',
|
|
top: -3,
|
|
left: -3,
|
|
right: -3,
|
|
bottom: -3,
|
|
borderRadius: radius.xl + 3,
|
|
backgroundColor: 'transparent',
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(233, 69, 96, 0.15)',
|
|
shadowColor: colors.primary,
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: 0.2,
|
|
shadowRadius: 20,
|
|
elevation: 8,
|
|
},
|
|
modalContainer: {
|
|
width: '100%',
|
|
borderRadius: radius.xl,
|
|
overflow: 'hidden',
|
|
},
|
|
blurContainer: {
|
|
...StyleSheet.absoluteFillObject,
|
|
},
|
|
gradientOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
},
|
|
content: {
|
|
padding: spacing.xl,
|
|
paddingTop: spacing.lg,
|
|
alignItems: 'center',
|
|
},
|
|
title: {
|
|
...typography.title2,
|
|
color: colors.text,
|
|
textAlign: 'center',
|
|
marginBottom: spacing.sm,
|
|
},
|
|
matchTypeBadge: {
|
|
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm,
|
|
borderRadius: radius.full,
|
|
borderWidth: 1,
|
|
marginBottom: spacing.lg,
|
|
},
|
|
matchTypeText: {
|
|
...typography.caption1,
|
|
fontWeight: '600',
|
|
letterSpacing: 1,
|
|
},
|
|
questionContainer: {
|
|
alignItems: 'center',
|
|
},
|
|
questionText: {
|
|
...typography.body,
|
|
color: colors.text,
|
|
textAlign: 'center',
|
|
fontStyle: 'italic',
|
|
lineHeight: 24,
|
|
paddingHorizontal: spacing.sm,
|
|
marginBottom: spacing.md,
|
|
},
|
|
responsesInline: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
responseInlineText: {
|
|
...typography.caption1,
|
|
color: colors.textSecondary,
|
|
},
|
|
responseInlineAnswer: {
|
|
...typography.caption1,
|
|
fontWeight: '700',
|
|
},
|
|
responseBadgeCompact: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: spacing.xs,
|
|
borderRadius: radius.md,
|
|
borderWidth: 1,
|
|
},
|
|
responseBadgeTextCompact: {
|
|
...typography.caption1,
|
|
fontWeight: '600',
|
|
letterSpacing: 0.5,
|
|
},
|
|
// Two-part question styles
|
|
twoPartContainer: {
|
|
width: '100%',
|
|
marginBottom: spacing.md,
|
|
},
|
|
responseSection: {
|
|
alignItems: 'center',
|
|
paddingHorizontal: spacing.sm,
|
|
},
|
|
responseLabelRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
marginBottom: spacing.xs,
|
|
},
|
|
responsePersonLabel: {
|
|
...typography.caption1,
|
|
color: colors.textSecondary,
|
|
fontWeight: '500',
|
|
},
|
|
questionTextSmall: {
|
|
...typography.subhead,
|
|
color: colors.text,
|
|
textAlign: 'center',
|
|
fontStyle: 'italic',
|
|
lineHeight: 20,
|
|
},
|
|
matchConnector: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginVertical: spacing.md,
|
|
paddingHorizontal: spacing.xl,
|
|
},
|
|
connectorLine: {
|
|
flex: 1,
|
|
height: 1,
|
|
backgroundColor: 'rgba(233, 69, 96, 0.2)',
|
|
},
|
|
connectorHeart: {
|
|
marginHorizontal: spacing.sm,
|
|
opacity: 0.8,
|
|
},
|
|
buttonRow: {
|
|
flexDirection: 'row',
|
|
gap: spacing.md,
|
|
width: '100%',
|
|
},
|
|
chatButton: {
|
|
flex: 1,
|
|
borderRadius: radius.lg,
|
|
overflow: 'hidden',
|
|
...shadows.md,
|
|
},
|
|
chatButtonGradient: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: spacing.sm,
|
|
paddingVertical: spacing.md,
|
|
},
|
|
chatButtonText: {
|
|
...typography.headline,
|
|
color: colors.text,
|
|
},
|
|
dismissButton: {
|
|
flex: 1,
|
|
borderRadius: radius.lg,
|
|
backgroundColor: colors.glass.background,
|
|
borderWidth: 1,
|
|
borderColor: colors.glass.border,
|
|
paddingVertical: spacing.md,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
dismissButtonText: {
|
|
...typography.headline,
|
|
color: colors.textSecondary,
|
|
},
|
|
border: {
|
|
...StyleSheet.absoluteFillObject,
|
|
borderRadius: radius.xl,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(233, 69, 96, 0.2)',
|
|
},
|
|
});
|