mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add discovery UI, hide NSFW setting, and pack progress
- 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>
This commit is contained in:
1 parent
bc38e41572
commit
69ac0940d3
29 files changed
+1932
-1058
No files matched your search
+127
-568
@@ -1,72 +1,93 @@
|
|||||||
import { useEffect, useCallback } from "react";
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native";
|
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
|
||||||
import Animated, {
|
import { useFocusEffect } from 'expo-router';
|
||||||
FadeInDown,
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
FadeInRight,
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
FadeIn,
|
import { useAuthStore, usePacksStore } from '../../src/store';
|
||||||
useSharedValue,
|
import { GradientBackground } from '../../src/components/ui';
|
||||||
useAnimatedStyle,
|
import { Paywall } from '../../src/components/paywall';
|
||||||
withRepeat,
|
import { CompactHeader, ContentRow } from '../../src/components/discovery';
|
||||||
withTiming,
|
import { colors, spacing, typography, radius } from '../../src/theme';
|
||||||
Easing,
|
import type { QuestionPack, Category } from '../../src/types';
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
|
||||||
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { router, useFocusEffect } from "expo-router";
|
|
||||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
|
||||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
|
||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 500;
|
const MAX_CONTENT_WIDTH = 600;
|
||||||
const ACCENT_COLOR = colors.primary;
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
// Group packs by category
|
||||||
|
function groupPacksByCategory(
|
||||||
|
packs: QuestionPack[],
|
||||||
|
categories: Category[]
|
||||||
|
): { category: Category; packs: QuestionPack[] }[] {
|
||||||
|
const categoryMap = new Map<string, QuestionPack[]>();
|
||||||
|
const uncategorized: QuestionPack[] = [];
|
||||||
|
|
||||||
|
// Initialize map with empty arrays for each category
|
||||||
|
categories.forEach((cat) => {
|
||||||
|
categoryMap.set(cat.id, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group packs
|
||||||
|
packs.forEach((pack) => {
|
||||||
|
if (pack.category_id && categoryMap.has(pack.category_id)) {
|
||||||
|
categoryMap.get(pack.category_id)!.push(pack);
|
||||||
|
} else {
|
||||||
|
uncategorized.push(pack);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build result array maintaining category sort order
|
||||||
|
const result: { category: Category; packs: QuestionPack[] }[] = [];
|
||||||
|
|
||||||
|
categories.forEach((cat) => {
|
||||||
|
const categoryPacks = categoryMap.get(cat.id) || [];
|
||||||
|
if (categoryPacks.length > 0) {
|
||||||
|
result.push({ category: cat, packs: categoryPacks });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add uncategorized at the end if any
|
||||||
|
if (uncategorized.length > 0) {
|
||||||
|
result.push({
|
||||||
|
category: {
|
||||||
|
id: 'uncategorized',
|
||||||
|
name: 'More',
|
||||||
|
description: null,
|
||||||
|
icon: 'ellipsis-horizontal',
|
||||||
|
color: null,
|
||||||
|
sort_order: 999,
|
||||||
|
created_at: '',
|
||||||
|
is_public: true,
|
||||||
|
},
|
||||||
|
packs: uncategorized,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiscoveryScreen() {
|
||||||
const { user, partner, couple } = useAuthStore();
|
const { user, partner, couple } = useAuthStore();
|
||||||
const { matches, newMatchesCount, totalCount, fetchMatches } = useMatchStore();
|
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
|
||||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
|
const [showPaywall, setShowPaywall] = useState(false);
|
||||||
|
|
||||||
// Refresh data when screen gains focus for freshness
|
const isPremiumUser = user?.is_premium ?? false;
|
||||||
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
|
||||||
|
// Refresh data when screen gains focus
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
fetchMatches();
|
|
||||||
fetchPacks();
|
fetchPacks();
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const topMatches = matches.slice(0, 3);
|
// Group packs by category
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const packsByCategory = useMemo(
|
||||||
const enabledPacksCount = enabledPackIds.length;
|
() => groupPacksByCategory(packs, categories),
|
||||||
|
[packs, categories]
|
||||||
// Subtle pulsating animation for the CTA icon
|
|
||||||
const pulseScale = useSharedValue(1);
|
|
||||||
const glowOpacity = useSharedValue(0.3);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const startPulse = () => {
|
|
||||||
pulseScale.value = 1;
|
|
||||||
pulseScale.value = withRepeat(
|
|
||||||
withTiming(1.1, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
|
|
||||||
-1,
|
|
||||||
true
|
|
||||||
);
|
);
|
||||||
glowOpacity.value = withRepeat(
|
|
||||||
withTiming(0.6, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
|
|
||||||
-1,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const timeout = setTimeout(startPulse, 100);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const pulseAnimatedStyle = useAnimatedStyle(() => ({
|
// Check if user has no packs enabled (empty state)
|
||||||
transform: [{ scale: pulseScale.value }],
|
const hasNoPacks = packs.length === 0;
|
||||||
}));
|
|
||||||
|
|
||||||
const glowAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: glowOpacity.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
@@ -79,255 +100,52 @@ export default function HomeScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
|
||||||
{/* Premium Header */}
|
{/* Compact Header */}
|
||||||
<Animated.View
|
<CompactHeader user={user} partner={partner} couple={couple} />
|
||||||
entering={FadeInDown.delay(100).duration(600).springify()}
|
|
||||||
style={styles.header}
|
|
||||||
>
|
|
||||||
{/* Greeting section */}
|
|
||||||
<Text style={styles.label}>WELCOME BACK</Text>
|
|
||||||
<Text style={styles.greeting}>
|
|
||||||
{user?.name || "Beautiful"}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Decorative separator */}
|
{/* Content Rows by Category */}
|
||||||
<View style={styles.separator}>
|
{packsByCategory.map((group, index) => (
|
||||||
<View style={styles.separatorLine} />
|
<ContentRow
|
||||||
<View style={styles.separatorDiamond} />
|
key={group.category.id}
|
||||||
<View style={styles.separatorLine} />
|
title={group.category.name}
|
||||||
</View>
|
packs={group.packs}
|
||||||
|
isPremiumUser={isPremiumUser}
|
||||||
{/* Partner badge */}
|
categoryId={group.category.id}
|
||||||
{partner ? (
|
delay={200 + index * 100}
|
||||||
<Animated.View
|
getPackProgress={getPackProgress}
|
||||||
entering={FadeIn.delay(300).duration(400)}
|
onShowPaywall={() => setShowPaywall(true)}
|
||||||
style={styles.partnerBadge}
|
|
||||||
>
|
|
||||||
<Ionicons name="heart" size={12} color={ACCENT_COLOR} />
|
|
||||||
<Text style={styles.partnerText}>
|
|
||||||
Connected with {partner.name || partner.email?.split('@')[0] || 'Partner'}
|
|
||||||
</Text>
|
|
||||||
</Animated.View>
|
|
||||||
) : couple ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.waitingBadge}
|
|
||||||
onPress={() => router.push("/(app)/pairing")}
|
|
||||||
>
|
|
||||||
<Ionicons name="hourglass-outline" size={12} color={ACCENT_COLOR} />
|
|
||||||
<Text style={styles.waitingText}>Waiting for partner</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.pairBadge}
|
|
||||||
onPress={() => router.push("/(app)/pairing")}
|
|
||||||
>
|
|
||||||
<Ionicons name="add-circle-outline" size={12} color={colors.textTertiary} />
|
|
||||||
<Text style={styles.pairText}>Connect with your partner</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Stats Row */}
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeInDown.delay(200).duration(500)}
|
|
||||||
style={styles.statsContainer}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.statCardWrapper}
|
|
||||||
onPress={() => router.push("/(app)/matches")}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={[styles.statCard, matches.length > 0 && styles.statCardActive]}>
|
|
||||||
<View style={[styles.statIcon, matches.length > 0 && styles.statIconActive]}>
|
|
||||||
<Ionicons
|
|
||||||
name="heart"
|
|
||||||
size={18}
|
|
||||||
color={matches.length > 0 ? colors.text : ACCENT_COLOR}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
<Text style={styles.statNumber}>{totalCount ?? matches.length}</Text>
|
|
||||||
<Text style={styles.statLabel}>Matches</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.statCardWrapper}
|
|
||||||
onPress={() => router.push("/(app)/packs")}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={[styles.statCard, enabledPacksCount > 0 && styles.statCardActive]}>
|
|
||||||
<View style={[styles.statIcon, enabledPacksCount > 0 && styles.statIconActive]}>
|
|
||||||
<Ionicons
|
|
||||||
name="layers"
|
|
||||||
size={18}
|
|
||||||
color={enabledPacksCount > 0 ? colors.text : ACCENT_COLOR}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.statNumber}>{enabledPacksCount}</Text>
|
|
||||||
<Text style={styles.statLabel}>Packs</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.statCardWrapper}
|
|
||||||
onPress={() => router.push("/(app)/matches")}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={[styles.statCard, newMatchesCount > 0 && styles.statCardActive]}>
|
|
||||||
<View style={[styles.statIcon, newMatchesCount > 0 && styles.statIconActive]}>
|
|
||||||
<Ionicons
|
|
||||||
name="sparkles"
|
|
||||||
size={18}
|
|
||||||
color={newMatchesCount > 0 ? colors.text : ACCENT_COLOR}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.statNumber}>{newMatchesCount}</Text>
|
|
||||||
<Text style={styles.statLabel}>New</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Play CTA */}
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeInDown.delay(300).duration(500)}
|
|
||||||
style={styles.ctaSection}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push("/(app)/swipe")}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
<View style={styles.ctaCard}>
|
|
||||||
{/* Glow effect */}
|
|
||||||
<Animated.View style={[styles.ctaGlow, glowAnimatedStyle]} />
|
|
||||||
|
|
||||||
<LinearGradient
|
|
||||||
colors={gradients.primary as [string, string]}
|
|
||||||
style={styles.ctaGradient}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
>
|
|
||||||
<View style={styles.ctaContent}>
|
|
||||||
<Text style={styles.ctaLabel}>EXPLORE</Text>
|
|
||||||
<Text style={styles.ctaTitle}>Ready to explore?</Text>
|
|
||||||
<Text style={styles.ctaSubtitle}>
|
|
||||||
Discover new experiences
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Animated.View style={[styles.ctaIconContainer, pulseAnimatedStyle]}>
|
|
||||||
<Ionicons name="flame" size={28} color={colors.text} />
|
|
||||||
</Animated.View>
|
|
||||||
</LinearGradient>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Top Matches (sorted by unread first, then recent) */}
|
|
||||||
{topMatches.length > 0 && (
|
|
||||||
<Animated.View
|
|
||||||
entering={FadeInDown.delay(400).duration(500)}
|
|
||||||
style={styles.section}
|
|
||||||
>
|
|
||||||
<View style={styles.sectionHeader}>
|
|
||||||
<View style={styles.sectionTitleRow}>
|
|
||||||
<View style={styles.sectionIcon}>
|
|
||||||
<Ionicons name="heart" size={14} color={ACCENT_COLOR} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.sectionTitle}>Top Matches</Text>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push("/(app)/matches")}
|
|
||||||
style={styles.seeAllButton}
|
|
||||||
>
|
|
||||||
<Text style={styles.seeAllText}>See all</Text>
|
|
||||||
<Ionicons name="chevron-forward" size={14} color={ACCENT_COLOR} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
{topMatches.map((match, index) => (
|
|
||||||
<Animated.View
|
|
||||||
key={match.id}
|
|
||||||
entering={FadeInRight.delay(450 + index * 100).duration(400)}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push(`/chat/${match.id}`)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={styles.matchCard}>
|
|
||||||
<View style={styles.matchRow}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={gradients.primary as [string, string]}
|
|
||||||
style={styles.matchIcon}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
>
|
|
||||||
<Ionicons name="heart" size={14} color={colors.text} />
|
|
||||||
</LinearGradient>
|
|
||||||
<Text style={styles.matchText} numberOfLines={1}>
|
|
||||||
{(match as any).question?.text || "A new match!"}
|
|
||||||
</Text>
|
|
||||||
{match.is_new && <View style={styles.newBadge} />}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
))}
|
))}
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tips when no matches */}
|
{/* Empty state when no packs */}
|
||||||
{topMatches.length === 0 && (
|
{hasNoPacks && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(400).duration(500)}
|
entering={FadeInDown.delay(300).duration(500).springify()}
|
||||||
style={styles.section}
|
style={styles.emptyState}
|
||||||
>
|
>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.emptyIcon}>
|
||||||
<View style={styles.sectionTitleRow}>
|
<Ionicons name="layers-outline" size={48} color={colors.primary} />
|
||||||
<View style={styles.sectionIcon}>
|
|
||||||
<Ionicons name="compass" size={14} color={ACCENT_COLOR} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.sectionTitle}>Getting Started</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.tipCard}>
|
|
||||||
<View style={styles.tipRow}>
|
|
||||||
<View style={styles.tipIcon}>
|
|
||||||
<Ionicons name="layers-outline" size={18} color={ACCENT_COLOR} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.tipContent}>
|
|
||||||
<Text style={styles.tipText}>Enable question packs</Text>
|
|
||||||
<Text style={styles.tipSubtext}>Choose topics to explore together</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.tipCard, styles.tipCardSpaced]}>
|
|
||||||
<View style={styles.tipRow}>
|
|
||||||
<View style={styles.tipIcon}>
|
|
||||||
<Ionicons name="swap-horizontal-outline" size={18} color={ACCENT_COLOR} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.tipContent}>
|
|
||||||
<Text style={styles.tipText}>Swipe on questions</Text>
|
|
||||||
<Text style={styles.tipSubtext}>Answer yes, no, or maybe</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.tipCard, styles.tipCardSpaced]}>
|
|
||||||
<View style={styles.tipRow}>
|
|
||||||
<View style={styles.tipIcon}>
|
|
||||||
<Ionicons name="heart-outline" size={18} color={ACCENT_COLOR} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.tipContent}>
|
|
||||||
<Text style={styles.tipText}>Discover matches</Text>
|
|
||||||
<Text style={styles.tipSubtext}>See where you both agree</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
<Text style={styles.emptyTitle}>No packs available</Text>
|
||||||
|
<Text style={styles.emptySubtitle}>
|
||||||
|
Check back soon for new content
|
||||||
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.bottomSpacer} />
|
<View style={styles.bottomSpacer} />
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* Paywall Modal */}
|
||||||
|
<Paywall
|
||||||
|
visible={showPaywall}
|
||||||
|
onClose={() => setShowPaywall(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowPaywall(false);
|
||||||
|
fetchPacks(); // Refresh packs to update lock status
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -350,290 +168,31 @@ const styles = StyleSheet.create({
|
|||||||
innerContainerWide: {
|
innerContainerWide: {
|
||||||
maxWidth: MAX_CONTENT_WIDTH,
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
},
|
},
|
||||||
header: {
|
emptyState: {
|
||||||
paddingTop: 60,
|
flex: 1,
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingBottom: spacing.xl,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
...typography.caption2,
|
|
||||||
fontWeight: '600',
|
|
||||||
letterSpacing: 2.5,
|
|
||||||
color: ACCENT_COLOR,
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
greeting: {
|
|
||||||
...typography.largeTitle,
|
|
||||||
color: colors.text,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
separator: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginVertical: spacing.md,
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
separatorLine: {
|
|
||||||
flex: 1,
|
|
||||||
height: 1,
|
|
||||||
backgroundColor: 'rgba(233, 69, 96, 0.3)',
|
|
||||||
},
|
|
||||||
separatorDiamond: {
|
|
||||||
width: 5,
|
|
||||||
height: 5,
|
|
||||||
backgroundColor: ACCENT_COLOR,
|
|
||||||
transform: [{ rotate: '45deg' }],
|
|
||||||
marginHorizontal: spacing.sm,
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
partnerBadge: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
paddingHorizontal: spacing.xl,
|
||||||
paddingHorizontal: spacing.md,
|
paddingVertical: spacing.xxl,
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: radius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(233, 69, 96, 0.2)',
|
|
||||||
gap: spacing.xs,
|
|
||||||
},
|
},
|
||||||
partnerText: {
|
emptyIcon: {
|
||||||
...typography.caption1,
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
...typography.title3,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
pairBadge: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.glass.background,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: radius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.glass.border,
|
|
||||||
gap: spacing.xs,
|
|
||||||
},
|
|
||||||
pairText: {
|
|
||||||
...typography.caption1,
|
|
||||||
color: colors.textTertiary,
|
|
||||||
},
|
|
||||||
waitingBadge: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: radius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(233, 69, 96, 0.2)',
|
|
||||||
gap: spacing.xs,
|
|
||||||
},
|
|
||||||
waitingText: {
|
|
||||||
...typography.caption1,
|
|
||||||
color: colors.text,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
statsContainer: {
|
|
||||||
flexDirection: "row",
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
statCardWrapper: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
statCard: {
|
|
||||||
alignItems: "center",
|
|
||||||
paddingVertical: spacing.md,
|
|
||||||
backgroundColor: 'rgba(233, 69, 96, 0.05)',
|
|
||||||
borderRadius: radius.lg,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(233, 69, 96, 0.1)',
|
|
||||||
},
|
|
||||||
statCardActive: {
|
|
||||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
|
||||||
borderColor: 'rgba(233, 69, 96, 0.2)',
|
|
||||||
},
|
|
||||||
statIcon: {
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: 18,
|
|
||||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
statIconActive: {
|
emptySubtitle: {
|
||||||
backgroundColor: ACCENT_COLOR,
|
|
||||||
},
|
|
||||||
statNumber: {
|
|
||||||
...typography.title2,
|
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
statLabel: {
|
|
||||||
...typography.caption2,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
ctaSection: {
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
marginTop: spacing.xl,
|
|
||||||
},
|
|
||||||
ctaCard: {
|
|
||||||
position: 'relative',
|
|
||||||
borderRadius: radius.xl,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
ctaGlow: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: -20,
|
|
||||||
left: -20,
|
|
||||||
right: -20,
|
|
||||||
bottom: -20,
|
|
||||||
backgroundColor: ACCENT_COLOR,
|
|
||||||
borderRadius: radius.xl + 20,
|
|
||||||
},
|
|
||||||
ctaGradient: {
|
|
||||||
borderRadius: radius.xl,
|
|
||||||
padding: spacing.lg,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
...shadows.lg,
|
|
||||||
},
|
|
||||||
ctaContent: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
ctaLabel: {
|
|
||||||
...typography.caption2,
|
|
||||||
fontWeight: '600',
|
|
||||||
letterSpacing: 2,
|
|
||||||
color: 'rgba(255,255,255,0.8)',
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
ctaTitle: {
|
|
||||||
...typography.title2,
|
|
||||||
color: colors.text,
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
ctaSubtitle: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: "rgba(255,255,255,0.8)",
|
|
||||||
},
|
|
||||||
ctaIconContainer: {
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 28,
|
|
||||||
backgroundColor: "rgba(255,255,255,0.2)",
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.3)',
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
marginLeft: spacing.md,
|
|
||||||
},
|
|
||||||
section: {
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
marginTop: spacing.xl,
|
|
||||||
},
|
|
||||||
sectionHeader: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: spacing.md,
|
|
||||||
},
|
|
||||||
sectionTitleRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
sectionIcon: {
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
...typography.headline,
|
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
seeAllButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 2,
|
|
||||||
},
|
|
||||||
seeAllText: {
|
|
||||||
...typography.subhead,
|
|
||||||
color: ACCENT_COLOR,
|
|
||||||
},
|
|
||||||
matchCard: {
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
backgroundColor: 'rgba(233, 69, 96, 0.05)',
|
|
||||||
borderRadius: radius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(233, 69, 96, 0.1)',
|
|
||||||
padding: spacing.md,
|
|
||||||
},
|
|
||||||
matchRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
matchIcon: {
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: 14,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
marginRight: spacing.md,
|
|
||||||
},
|
|
||||||
matchText: {
|
|
||||||
flex: 1,
|
|
||||||
...typography.body,
|
...typography.body,
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
newBadge: {
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: 4,
|
|
||||||
backgroundColor: ACCENT_COLOR,
|
|
||||||
},
|
|
||||||
tipCard: {
|
|
||||||
backgroundColor: 'rgba(233, 69, 96, 0.05)',
|
|
||||||
borderRadius: radius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(233, 69, 96, 0.1)',
|
|
||||||
padding: spacing.md,
|
|
||||||
},
|
|
||||||
tipCardSpaced: {
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
},
|
|
||||||
tipRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
tipIcon: {
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: 18,
|
|
||||||
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginRight: spacing.md,
|
|
||||||
},
|
|
||||||
tipContent: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
tipText: {
|
|
||||||
...typography.body,
|
|
||||||
color: colors.text,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
tipSubtext: {
|
|
||||||
...typography.caption1,
|
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
marginTop: 2,
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
bottomSpacer: {
|
bottomSpacer: {
|
||||||
height: spacing.lg,
|
height: spacing.lg,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
|
import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
|
||||||
import { useMatchStore, useAuthStore } from "../../src/store";
|
import { useMatchStore, useAuthStore, type PendingQuestion } from "../../src/store";
|
||||||
import { useEffect, useCallback, useState } from "react";
|
import { useEffect, useCallback, useState } from "react";
|
||||||
import { useFocusEffect } from "expo-router";
|
import { useFocusEffect } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
@@ -49,13 +49,17 @@ export default function MatchesScreen() {
|
|||||||
// Archive state and methods
|
// Archive state and methods
|
||||||
archivedMatches,
|
archivedMatches,
|
||||||
showArchived,
|
showArchived,
|
||||||
toggleShowArchived,
|
|
||||||
archiveMatch,
|
archiveMatch,
|
||||||
unarchiveMatch,
|
unarchiveMatch,
|
||||||
isLoadingArchived,
|
isLoadingArchived,
|
||||||
fetchArchivedMatches,
|
// Pending state and methods
|
||||||
|
pendingQuestions,
|
||||||
|
isLoadingPending,
|
||||||
|
currentView,
|
||||||
|
setCurrentView,
|
||||||
|
fetchPendingQuestions,
|
||||||
} = useMatchStore();
|
} = useMatchStore();
|
||||||
const { user } = useAuthStore();
|
const { user, couple, partner } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
@@ -132,6 +136,8 @@ export default function MatchesScreen() {
|
|||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
fetchMatches(true);
|
fetchMatches(true);
|
||||||
|
// Also refresh pending questions when focused
|
||||||
|
fetchPendingQuestions();
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -267,6 +273,96 @@ export default function MatchesScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderPendingItem = ({ item, index }: { item: PendingQuestion; index: number }) => {
|
||||||
|
if (!item.question) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packName = item.question.pack?.name ?? 'Unknown Pack';
|
||||||
|
const timeSince = getTimeSince(item.partnerAnsweredAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push({
|
||||||
|
pathname: "/(app)/swipe",
|
||||||
|
params: { mode: 'pending', startQuestionId: item.question.id }
|
||||||
|
})}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.matchCardPremium}>
|
||||||
|
{/* Subtle gradient background - slightly different hue for pending */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={['rgba(33, 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={[`${ROSE_RGBA}0.06)`, 'transparent']}
|
||||||
|
style={styles.cardSilkHighlight}
|
||||||
|
start={{ x: 0.5, y: 0 }}
|
||||||
|
end={{ x: 0.5, y: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.matchRow}>
|
||||||
|
{/* Pending icon container - hourglass */}
|
||||||
|
<View style={[styles.iconContainerPremium, styles.iconContainerPending]}>
|
||||||
|
<Ionicons
|
||||||
|
name="hourglass-outline"
|
||||||
|
size={20}
|
||||||
|
color={ROSE}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.questionTextPremium} numberOfLines={2}>
|
||||||
|
{item.question.text}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<View style={styles.tagPremium}>
|
||||||
|
<Text style={styles.tagTextPremium}>
|
||||||
|
{packName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.datePremium}>
|
||||||
|
{timeSince}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.rightSection}>
|
||||||
|
<View style={styles.chevronContainerPremium}>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color={`${ROSE_RGBA}0.6)`} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Premium border */}
|
||||||
|
<View style={[styles.cardPremiumBorder, styles.cardPendingBorder]} pointerEvents="none" />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get time since partner answered
|
||||||
|
const getTimeSince = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
const renderFooter = () => {
|
const renderFooter = () => {
|
||||||
if (!isLoadingMore) return null;
|
if (!isLoadingMore) return null;
|
||||||
return (
|
return (
|
||||||
@@ -286,6 +382,91 @@ export default function MatchesScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gate matches behind pairing - user must have a partner to view matches
|
||||||
|
if (!partner) {
|
||||||
|
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.pairingGateContainer}>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInUp.duration(600).springify()}
|
||||||
|
style={styles.pairingGateContent}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<View style={styles.pairingGateIconContainer}>
|
||||||
|
<Ionicons name="heart" size={36} color={ROSE} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Title section */}
|
||||||
|
<Text style={styles.pairingGateLabel}>{couple ? "ALMOST THERE" : "CONNECT"}</Text>
|
||||||
|
<Text style={styles.pairingGateTitle}>{couple ? "Waiting" : "Pair Up"}</Text>
|
||||||
|
|
||||||
|
<DecorativeSeparator variant="rose" />
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.delay(300).duration(400)}
|
||||||
|
style={styles.pairingGateBadge}
|
||||||
|
>
|
||||||
|
<Text style={styles.pairingGateBadgeText}>{couple ? "INVITE SENT" : "MADE FOR TWO"}</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Text style={styles.pairingGateDescription}>
|
||||||
|
{couple
|
||||||
|
? "Share your invite code so they can join you. Once paired, you'll discover your matches here!"
|
||||||
|
: "Sauci is made for two! Connect with your partner to start discovering what you agree on."
|
||||||
|
}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Feature hints */}
|
||||||
|
<View style={styles.pairingGateFeatures}>
|
||||||
|
<View style={styles.pairingGateFeatureItem}>
|
||||||
|
<Ionicons name="heart" size={16} color={ACCENT} />
|
||||||
|
<Text style={styles.pairingGateFeatureText}>See when you both agree</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.pairingGateFeatureItem}>
|
||||||
|
<Ionicons name="sparkles" size={16} color={ACCENT} />
|
||||||
|
<Text style={styles.pairingGateFeatureText}>Unlock hidden connections</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.pairingGateFeatureItem}>
|
||||||
|
<Ionicons name="chatbubbles-outline" size={16} color={ACCENT} />
|
||||||
|
<Text style={styles.pairingGateFeatureText}>Chat about your matches</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom teaser */}
|
||||||
|
<Text style={styles.pairingGateTeaser}>{couple ? "Your partner is just a code away" : "Begin your journey together"}</Text>
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
onPress={() => router.push("/pairing")}
|
||||||
|
style={{ marginTop: spacing.lg }}
|
||||||
|
>
|
||||||
|
{couple ? "View Invite Code" : "Pair Now"}
|
||||||
|
</GlassButton>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
{/* Ambient Orbs - Premium gold/rose */}
|
{/* Ambient Orbs - Premium gold/rose */}
|
||||||
@@ -331,8 +512,8 @@ export default function MatchesScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<AnimatedFlatList
|
<AnimatedFlatList
|
||||||
data={showArchived ? archivedMatches : matches}
|
data={currentView === 'archived' ? archivedMatches : currentView === 'pending' ? pendingQuestions : matches}
|
||||||
renderItem={renderItem}
|
renderItem={currentView === 'pending' ? renderPendingItem as any : renderItem}
|
||||||
keyExtractor={(item: any) => item.id}
|
keyExtractor={(item: any) => item.id}
|
||||||
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
@@ -340,16 +521,22 @@ export default function MatchesScreen() {
|
|||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={isLoading}
|
refreshing={currentView === 'pending' ? isLoadingPending : isLoading}
|
||||||
onRefresh={() => fetchMatches(true)}
|
onRefresh={() => {
|
||||||
|
if (currentView === 'pending') {
|
||||||
|
fetchPendingQuestions();
|
||||||
|
} else {
|
||||||
|
fetchMatches(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
colors={[colors.primary]}
|
colors={[colors.primary]}
|
||||||
progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT}
|
progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onEndReached={handleLoadMore}
|
onEndReached={currentView === 'pending' ? undefined : handleLoadMore}
|
||||||
onEndReachedThreshold={2}
|
onEndReachedThreshold={2}
|
||||||
ListFooterComponent={renderFooter}
|
ListFooterComponent={currentView === 'pending' ? null : renderFooter}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeIn.duration(400)}
|
entering={FadeIn.duration(400)}
|
||||||
@@ -366,26 +553,47 @@ export default function MatchesScreen() {
|
|||||||
{/* Filter toggle */}
|
{/* Filter toggle */}
|
||||||
<View style={styles.filterContainer}>
|
<View style={styles.filterContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.filterTab, !showArchived && styles.filterTabActive]}
|
style={[styles.filterTab, currentView === 'pending' && styles.filterTabActive]}
|
||||||
onPress={() => showArchived && toggleShowArchived()}
|
onPress={() => currentView !== 'pending' && setCurrentView('pending')}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isLoadingPending ? (
|
||||||
|
<ActivityIndicator size="small" color={ACCENT} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons
|
||||||
|
name="hourglass-outline"
|
||||||
|
size={14}
|
||||||
|
color={currentView === 'pending' ? ACCENT : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.filterTabText, currentView === 'pending' && styles.filterTabTextActive]}>
|
||||||
|
Your Turn
|
||||||
|
</Text>
|
||||||
|
{pendingQuestions.length > 0 && currentView !== 'pending' && (
|
||||||
|
<View style={styles.pendingBadge}>
|
||||||
|
<Text style={styles.pendingBadgeText}>{pendingQuestions.length}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.filterTab, currentView === 'active' && styles.filterTabActive]}
|
||||||
|
onPress={() => currentView !== 'active' && setCurrentView('active')}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="heart"
|
name="heart"
|
||||||
size={14}
|
size={14}
|
||||||
color={!showArchived ? ACCENT : colors.textTertiary}
|
color={currentView === 'active' ? ACCENT : colors.textTertiary}
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.filterTabText, !showArchived && styles.filterTabTextActive]}>
|
<Text style={[styles.filterTabText, currentView === 'active' && styles.filterTabTextActive]}>
|
||||||
Active
|
Complete
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.filterTab, showArchived && styles.filterTabActive]}
|
style={[styles.filterTab, currentView === 'archived' && styles.filterTabActive]}
|
||||||
onPress={() => {
|
onPress={() => currentView !== 'archived' && setCurrentView('archived')}
|
||||||
if (!showArchived) {
|
|
||||||
toggleShowArchived();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
{isLoadingArchived ? (
|
{isLoadingArchived ? (
|
||||||
@@ -395,9 +603,9 @@ export default function MatchesScreen() {
|
|||||||
<Ionicons
|
<Ionicons
|
||||||
name="archive-outline"
|
name="archive-outline"
|
||||||
size={14}
|
size={14}
|
||||||
color={showArchived ? ACCENT : colors.textTertiary}
|
color={currentView === 'archived' ? ACCENT : colors.textTertiary}
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.filterTabText, showArchived && styles.filterTabTextActive]}>
|
<Text style={[styles.filterTabText, currentView === 'archived' && styles.filterTabTextActive]}>
|
||||||
Archived
|
Archived
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
@@ -407,11 +615,17 @@ export default function MatchesScreen() {
|
|||||||
|
|
||||||
{/* Count badge */}
|
{/* Count badge */}
|
||||||
<View style={styles.countBadgePremium}>
|
<View style={styles.countBadgePremium}>
|
||||||
<Ionicons name={showArchived ? "archive" : "heart"} size={12} color={ACCENT} />
|
<Ionicons
|
||||||
|
name={currentView === 'archived' ? "archive" : currentView === 'pending' ? "hourglass" : "heart"}
|
||||||
|
size={12}
|
||||||
|
color={ACCENT}
|
||||||
|
/>
|
||||||
<Text style={styles.countTextPremium}>
|
<Text style={styles.countTextPremium}>
|
||||||
{showArchived
|
{currentView === 'archived'
|
||||||
? `${archivedMatches.length} ARCHIVED`
|
? `${archivedMatches.length} ARCHIVED`
|
||||||
: `${totalCount ?? matches.length} ${(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}`
|
: currentView === 'pending'
|
||||||
|
? `${pendingQuestions.length} WAITING`
|
||||||
|
: `${totalCount ?? matches.length} COMPLETE`
|
||||||
}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -419,7 +633,7 @@ export default function MatchesScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
showArchived ? (
|
currentView === 'archived' ? (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInUp.duration(600).springify()}
|
entering={FadeInUp.duration(600).springify()}
|
||||||
style={styles.emptyContent}
|
style={styles.emptyContent}
|
||||||
@@ -436,12 +650,35 @@ export default function MatchesScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<GlassButton
|
<GlassButton
|
||||||
onPress={() => toggleShowArchived()}
|
onPress={() => setCurrentView('active')}
|
||||||
style={{ marginTop: spacing.lg }}
|
style={{ marginTop: spacing.lg }}
|
||||||
>
|
>
|
||||||
View Active Matches
|
View Active Matches
|
||||||
</GlassButton>
|
</GlassButton>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
) : currentView === 'pending' ? (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInUp.duration(600).springify()}
|
||||||
|
style={styles.emptyContent}
|
||||||
|
>
|
||||||
|
{/* Pending icon */}
|
||||||
|
<View style={[styles.emptyIconContainer, { backgroundColor: `${ROSE_RGBA}0.1)`, borderColor: `${ROSE_RGBA}0.2)` }]}>
|
||||||
|
<Ionicons name="checkmark-circle-outline" size={48} color={ROSE} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<Text style={styles.emptyTitle}>All Caught Up!</Text>
|
||||||
|
<Text style={styles.emptyDescription}>
|
||||||
|
You've answered all the questions your partner has swiped on. Keep swiping to discover more together!
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
onPress={() => router.push("/(app)/swipe")}
|
||||||
|
style={{ marginTop: spacing.lg }}
|
||||||
|
>
|
||||||
|
Keep Swiping
|
||||||
|
</GlassButton>
|
||||||
|
</Animated.View>
|
||||||
) : (
|
) : (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInUp.duration(600).springify()}
|
entering={FadeInUp.duration(600).springify()}
|
||||||
@@ -615,6 +852,22 @@ const styles = StyleSheet.create({
|
|||||||
color: ACCENT,
|
color: ACCENT,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
pendingBadge: {
|
||||||
|
backgroundColor: ROSE,
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: 18,
|
||||||
|
height: 18,
|
||||||
|
paddingHorizontal: 5,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
pendingBadgeText: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
countBadgePremium: {
|
countBadgePremium: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -668,6 +921,9 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: `${ACCENT_RGBA}0.15)`,
|
borderColor: `${ACCENT_RGBA}0.15)`,
|
||||||
},
|
},
|
||||||
|
cardPendingBorder: {
|
||||||
|
borderColor: `${ROSE_RGBA}0.2)`,
|
||||||
|
},
|
||||||
matchRow: {
|
matchRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -687,6 +943,10 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||||
borderColor: `${ACCENT_RGBA}0.2)`,
|
borderColor: `${ACCENT_RGBA}0.2)`,
|
||||||
},
|
},
|
||||||
|
iconContainerPending: {
|
||||||
|
backgroundColor: `${ROSE_RGBA}0.15)`,
|
||||||
|
borderColor: `${ROSE_RGBA}0.25)`,
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
@@ -813,4 +1073,83 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
// Pairing gate styles
|
||||||
|
pairingGateContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
pairingGateContent: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
pairingGateIconContainer: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: `${ROSE_RGBA}0.1)`,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: `${ROSE_RGBA}0.2)`,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
pairingGateLabel: {
|
||||||
|
...typography.caption2,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 3,
|
||||||
|
color: ROSE,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
pairingGateTitle: {
|
||||||
|
...typography.largeTitle,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
pairingGateBadge: {
|
||||||
|
backgroundColor: `${ROSE_RGBA}0.1)`,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: `${ROSE_RGBA}0.2)`,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
pairingGateBadgeText: {
|
||||||
|
...typography.caption2,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: ROSE,
|
||||||
|
},
|
||||||
|
pairingGateDescription: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
pairingGateFeatures: {
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
pairingGateFeatureItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
pairingGateFeatureText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
},
|
||||||
|
pairingGateTeaser: {
|
||||||
|
...typography.footnote,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
color: colors.textTertiary,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -24,7 +24,7 @@ import Animated, {
|
|||||||
import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
||||||
import { GlassCard } from '../../src/components/ui/GlassCard';
|
import { GlassCard } from '../../src/components/ui/GlassCard';
|
||||||
import { GlassButton } from '../../src/components/ui/GlassButton';
|
import { GlassButton } from '../../src/components/ui/GlassButton';
|
||||||
import { IntensitySlider } from '../../src/components/ui/IntensitySlider';
|
import { GlassToggle } from '../../src/components/ui/GlassToggle';
|
||||||
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
|
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
|
||||||
import { useAuthStore } from '../../src/store';
|
import { useAuthStore } from '../../src/store';
|
||||||
import { supabase } from '../../src/lib/supabase';
|
import { supabase } from '../../src/lib/supabase';
|
||||||
@@ -32,7 +32,7 @@ import { getProfileError } from '../../src/lib/errors';
|
|||||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||||
import { Events } from '../../src/lib/analytics';
|
import { Events } from '../../src/lib/analytics';
|
||||||
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
|
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
|
||||||
import type { Gender, IntensityLevel } from '../../src/types';
|
import type { Gender } from '../../src/types';
|
||||||
|
|
||||||
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||||
{ value: 'male', label: 'Male', icon: 'male' },
|
{ value: 'male', label: 'Male', icon: 'male' },
|
||||||
@@ -51,20 +51,13 @@ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
|
|||||||
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
|
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_MAX_INTENSITY: IntensityLevel = 2;
|
// Intensity thresholds for backwards compatibility
|
||||||
|
// When hide_nsfw=true, max_intensity=2 (mild content only)
|
||||||
|
// When hide_nsfw=false, max_intensity=5 (all content)
|
||||||
|
const NSFW_OFF_INTENSITY = 2;
|
||||||
|
const NSFW_ON_INTENSITY = 5;
|
||||||
|
|
||||||
const normalizeIntensity = (value: number | null | undefined): IntensityLevel | null => {
|
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'content' | 'notifications';
|
||||||
if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) return value;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getInitialIntensity = (profile?: { max_intensity?: number | null; show_explicit_content?: boolean | null } | null): IntensityLevel => {
|
|
||||||
const normalized = normalizeIntensity(profile?.max_intensity);
|
|
||||||
if (normalized) return normalized;
|
|
||||||
return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
|
|
||||||
|
|
||||||
export default function OnboardingScreen() {
|
export default function OnboardingScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -73,7 +66,7 @@ export default function OnboardingScreen() {
|
|||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.name || '');
|
||||||
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
||||||
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
||||||
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getInitialIntensity(user));
|
const [hideNsfw, setHideNsfw] = useState(user?.hide_nsfw ?? false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
@@ -132,13 +125,13 @@ export default function OnboardingScreen() {
|
|||||||
setError('Please select what brings you to Sauci');
|
setError('Please select what brings you to Sauci');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStage('explicit');
|
setStage('content');
|
||||||
Events.onboardingStageComplete('purpose');
|
Events.onboardingStageComplete('purpose');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleIntensityContinue = () => {
|
const handleContentContinue = () => {
|
||||||
setStage('notifications');
|
setStage('notifications');
|
||||||
Events.onboardingStageComplete('explicit');
|
Events.onboardingStageComplete('content');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEnableNotifications = async () => {
|
const handleEnableNotifications = async () => {
|
||||||
@@ -177,8 +170,10 @@ export default function OnboardingScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showExplicitContent = maxIntensity >= 3;
|
// Derive intensity from hide_nsfw for backwards compatibility
|
||||||
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"];
|
const maxIntensity = hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
|
||||||
|
const showExplicitContent = !hideNsfw;
|
||||||
|
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
|
||||||
|
|
||||||
const updateData: Record<string, any> = {
|
const updateData: Record<string, any> = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
@@ -186,6 +181,7 @@ export default function OnboardingScreen() {
|
|||||||
usage_reason: usageReason,
|
usage_reason: usageReason,
|
||||||
max_intensity: maxIntensity,
|
max_intensity: maxIntensity,
|
||||||
show_explicit_content: showExplicitContent,
|
show_explicit_content: showExplicitContent,
|
||||||
|
hide_nsfw: hideNsfw,
|
||||||
onboarding_completed: true,
|
onboarding_completed: true,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -476,10 +472,10 @@ export default function OnboardingScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'explicit':
|
case 'content':
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
key="explicit"
|
key="content"
|
||||||
entering={SlideInRight.duration(400)}
|
entering={SlideInRight.duration(400)}
|
||||||
exiting={FadeOut.duration(200)}
|
exiting={FadeOut.duration(200)}
|
||||||
style={styles.stageContainer}
|
style={styles.stageContainer}
|
||||||
@@ -489,22 +485,40 @@ export default function OnboardingScreen() {
|
|||||||
colors={gradients.primary as [string, string]}
|
colors={gradients.primary as [string, string]}
|
||||||
style={styles.iconContainer}
|
style={styles.iconContainer}
|
||||||
>
|
>
|
||||||
<Ionicons name="heart-circle" size={44} color={colors.text} />
|
<Ionicons name="flame" size={44} color={colors.text} />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.title}>Set Your Comfort Zone</Text>
|
<Text style={styles.title}>Content Preferences</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
Choose the intimacy level you're comfortable with
|
Choose what type of content you'd like to see
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<GlassCard style={styles.card}>
|
<GlassCard style={styles.card}>
|
||||||
<IntensitySlider
|
<View style={styles.contentToggleRow}>
|
||||||
value={maxIntensity}
|
<View style={styles.contentToggleLeft}>
|
||||||
onValueChange={setMaxIntensity}
|
<LinearGradient
|
||||||
/>
|
colors={gradients.primary as [string, string]}
|
||||||
<Text style={styles.intensityNote}>
|
style={styles.contentToggleIcon}
|
||||||
Start where you're comfortable — you can always adjust later
|
>
|
||||||
|
<Ionicons name="eye-off" size={24} color={colors.text} />
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.contentToggleText}>
|
||||||
|
<Text style={styles.contentToggleLabel}>Hide Adult Content</Text>
|
||||||
|
<Text style={styles.contentToggleDescription}>
|
||||||
|
Only show mild, family-friendly question packs
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<GlassToggle
|
||||||
|
value={hideNsfw}
|
||||||
|
onValueChange={setHideNsfw}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.contentNote}>
|
||||||
|
You can change this anytime in settings
|
||||||
|
</Text>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
<Ionicons name="alert-circle" size={16} color={colors.error} />
|
||||||
@@ -515,7 +529,7 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<GlassButton
|
<GlassButton
|
||||||
onPress={handleIntensityContinue}
|
onPress={handleContentContinue}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -613,8 +627,8 @@ export default function OnboardingScreen() {
|
|||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (stage === 'notifications') setStage('explicit');
|
if (stage === 'notifications') setStage('content');
|
||||||
else if (stage === 'explicit') setStage('purpose');
|
else if (stage === 'content') setStage('purpose');
|
||||||
else if (stage === 'purpose') setStage('gender');
|
else if (stage === 'purpose') setStage('gender');
|
||||||
else if (stage === 'gender') setStage('name');
|
else if (stage === 'gender') setStage('name');
|
||||||
else if (stage === 'name') setStage('avatar');
|
else if (stage === 'name') setStage('avatar');
|
||||||
@@ -630,7 +644,7 @@ export default function OnboardingScreen() {
|
|||||||
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'explicit' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, stage === 'content' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.backButtonPlaceholder} />
|
<View style={styles.backButtonPlaceholder} />
|
||||||
@@ -866,11 +880,42 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.text,
|
color: colors.text,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
intensityNote: {
|
contentToggleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
contentToggleLeft: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: spacing.md,
|
||||||
|
},
|
||||||
|
contentToggleIcon: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: spacing.md,
|
||||||
|
},
|
||||||
|
contentToggleText: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
contentToggleLabel: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
contentToggleDescription: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
contentNote: {
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
marginTop: spacing.md,
|
marginTop: spacing.lg,
|
||||||
},
|
},
|
||||||
radioOuter: {
|
radioOuter: {
|
||||||
width: 24,
|
width: 24,
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ export default function QuestionPacksScreen() {
|
|||||||
name: "Other",
|
name: "Other",
|
||||||
description: null,
|
description: null,
|
||||||
icon: "📁",
|
icon: "📁",
|
||||||
|
color: null,
|
||||||
sort_order: 999,
|
sort_order: 999,
|
||||||
created_at: "",
|
created_at: "",
|
||||||
is_public: true
|
is_public: true
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default function PairingScreen() {
|
|||||||
if (couple?.invite_code) {
|
if (couple?.invite_code) {
|
||||||
try {
|
try {
|
||||||
await Share.share({
|
await Share.share({
|
||||||
message: `Join me on Sauci! Use my invite code to pair up: ${couple.invite_code}`,
|
message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`,
|
||||||
});
|
});
|
||||||
Events.codeShared();
|
Events.codeShared();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+247
-12
@@ -40,6 +40,10 @@ export default function SwipeScreen() {
|
|||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
// Normalize packId - useLocalSearchParams can return string | string[]
|
// Normalize packId - useLocalSearchParams can return string | string[]
|
||||||
const packId = Array.isArray(params.packId) ? params.packId[0] : params.packId;
|
const packId = Array.isArray(params.packId) ? params.packId[0] : params.packId;
|
||||||
|
// Mode can be 'pending' to show only questions partner has answered
|
||||||
|
const mode = Array.isArray(params.mode) ? params.mode[0] : params.mode;
|
||||||
|
// Start question ID - when user taps a specific pending question, start there
|
||||||
|
const startQuestionId = Array.isArray(params.startQuestionId) ? params.startQuestionId[0] : params.startQuestionId;
|
||||||
const [questions, setQuestions] = useState<any[]>([]);
|
const [questions, setQuestions] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
@@ -49,10 +53,12 @@ export default function SwipeScreen() {
|
|||||||
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
||||||
const [dailyLimitInfo, setDailyLimitInfo] = useState<DailyLimitInfo | null>(null);
|
const [dailyLimitInfo, setDailyLimitInfo] = useState<DailyLimitInfo | null>(null);
|
||||||
const [showPaywall, setShowPaywall] = useState(false);
|
const [showPaywall, setShowPaywall] = useState(false);
|
||||||
|
const [packContext, setPackContext] = useState<{ name: string; icon: string } | null>(null);
|
||||||
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
|
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
|
||||||
const { partner, couple } = useAuthStore();
|
const { partner, couple } = useAuthStore();
|
||||||
const hasTrackedExhausted = useRef(false);
|
const hasTrackedExhausted = useRef(false);
|
||||||
const fetchRequestId = useRef(0);
|
const fetchRequestId = useRef(0);
|
||||||
|
const lastFetchedMode = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
// Ambient orb breathing animations
|
// Ambient orb breathing animations
|
||||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||||
@@ -112,16 +118,42 @@ export default function SwipeScreen() {
|
|||||||
const isFirstMount = useRef(true);
|
const isFirstMount = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset state when pack changes
|
// Reset state when pack or mode changes
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setIsBlocked(false);
|
setIsBlocked(false);
|
||||||
setGapInfo(null);
|
setGapInfo(null);
|
||||||
hasTrackedExhausted.current = false;
|
hasTrackedExhausted.current = false;
|
||||||
|
lastFetchedMode.current = mode;
|
||||||
|
|
||||||
// Use lightweight ensureEnabledPacksLoaded instead of full fetchPacks
|
// Use lightweight ensureEnabledPacksLoaded instead of full fetchPacks
|
||||||
ensureEnabledPacksLoaded().then(() => fetchQuestions());
|
ensureEnabledPacksLoaded().then(() => {
|
||||||
|
if (mode === 'pending') {
|
||||||
|
fetchPendingQuestions();
|
||||||
|
} else {
|
||||||
|
fetchQuestions();
|
||||||
|
}
|
||||||
|
});
|
||||||
checkTutorial();
|
checkTutorial();
|
||||||
|
}, [packId, mode]);
|
||||||
|
|
||||||
|
// Fetch pack context when packId is provided
|
||||||
|
useEffect(() => {
|
||||||
|
if (packId) {
|
||||||
|
const fetchPackContext = async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('question_packs')
|
||||||
|
.select('name, icon')
|
||||||
|
.eq('id', packId)
|
||||||
|
.single();
|
||||||
|
if (data) {
|
||||||
|
setPackContext({ name: data.name, icon: data.icon || 'layers' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchPackContext();
|
||||||
|
} else {
|
||||||
|
setPackContext(null);
|
||||||
|
}
|
||||||
}, [packId]);
|
}, [packId]);
|
||||||
|
|
||||||
// Refresh questions when screen gains focus (handles partner answering while away)
|
// Refresh questions when screen gains focus (handles partner answering while away)
|
||||||
@@ -132,9 +164,17 @@ export default function SwipeScreen() {
|
|||||||
isFirstMount.current = false;
|
isFirstMount.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Silently refetch to get updated partner_answered status
|
// Skip if mode just changed (useEffect handles that)
|
||||||
|
if (lastFetchedMode.current !== mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Silently refetch to get updated status
|
||||||
|
if (mode === 'pending') {
|
||||||
|
fetchPendingQuestions();
|
||||||
|
} else {
|
||||||
fetchQuestions();
|
fetchQuestions();
|
||||||
}, [packId])
|
}
|
||||||
|
}, [packId, mode])
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if user is too far ahead of partner (called when questions empty or after swipe)
|
// Check if user is too far ahead of partner (called when questions empty or after swipe)
|
||||||
@@ -249,8 +289,9 @@ export default function SwipeScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Filter out questions from disabled packs immediately when enabledPackIds changes
|
// Filter out questions from disabled packs immediately when enabledPackIds changes
|
||||||
|
// Skip this filter in pending mode - user should answer ALL questions partner swiped on
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!packId && enabledPackIds.length > 0 && questions.length > 0) {
|
if (!packId && mode !== 'pending' && enabledPackIds.length > 0 && questions.length > 0) {
|
||||||
const enabledSet = new Set(enabledPackIds);
|
const enabledSet = new Set(enabledPackIds);
|
||||||
setQuestions(prev => {
|
setQuestions(prev => {
|
||||||
const filtered = prev.filter(q => enabledSet.has(q.pack_id));
|
const filtered = prev.filter(q => enabledSet.has(q.pack_id));
|
||||||
@@ -261,7 +302,7 @@ export default function SwipeScreen() {
|
|||||||
return filtered;
|
return filtered;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [enabledPackIds]);
|
}, [enabledPackIds, mode]);
|
||||||
|
|
||||||
|
|
||||||
const fetchQuestions = async () => {
|
const fetchQuestions = async () => {
|
||||||
@@ -315,6 +356,119 @@ export default function SwipeScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch pending questions (partner answered, user hasn't) for "Your Turn" mode
|
||||||
|
const fetchPendingQuestions = async () => {
|
||||||
|
const currentRequestId = ++fetchRequestId.current;
|
||||||
|
const userId = useAuthStore.getState().user?.id;
|
||||||
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
|
|
||||||
|
if (!coupleId || !userId) {
|
||||||
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
|
setQuestions([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get partner's user_id
|
||||||
|
const { data: coupleProfiles } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("id")
|
||||||
|
.eq("couple_id", coupleId)
|
||||||
|
.neq("id", userId);
|
||||||
|
|
||||||
|
const partnerId = coupleProfiles?.[0]?.id;
|
||||||
|
if (!partnerId) {
|
||||||
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
|
setQuestions([]);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get questions the current user has already answered
|
||||||
|
const { data: userResponses } = await supabase
|
||||||
|
.from("responses")
|
||||||
|
.select("question_id")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("couple_id", coupleId);
|
||||||
|
|
||||||
|
const answeredQuestionIds = new Set(userResponses?.map(r => r.question_id) ?? []);
|
||||||
|
|
||||||
|
// Get partner's responses (questions they answered that user hasn't)
|
||||||
|
// Order by oldest first so user burns down the backlog
|
||||||
|
const { data: partnerResponses, error } = await supabase
|
||||||
|
.from("responses")
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
question_id,
|
||||||
|
created_at,
|
||||||
|
question:questions(
|
||||||
|
*,
|
||||||
|
pack:question_packs(id, name, icon)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq("user_id", partnerId)
|
||||||
|
.eq("couple_id", coupleId)
|
||||||
|
.order("created_at", { ascending: true }); // Oldest first
|
||||||
|
|
||||||
|
if (currentRequestId !== fetchRequestId.current) return;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching pending questions:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only questions user hasn't answered, excluding deleted questions
|
||||||
|
const pendingQuestions = (partnerResponses ?? [])
|
||||||
|
.filter(r => {
|
||||||
|
const question = r.question as any;
|
||||||
|
return !answeredQuestionIds.has(r.question_id) && question && !question.deleted_at;
|
||||||
|
})
|
||||||
|
.map(r => {
|
||||||
|
const question = r.question as any;
|
||||||
|
return {
|
||||||
|
...question,
|
||||||
|
pack_id: question.pack?.id,
|
||||||
|
pack_name: question.pack?.name,
|
||||||
|
pack_icon: question.pack?.icon,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
|
// If user tapped a specific question, reorder so it's first
|
||||||
|
let orderedQuestions = pendingQuestions;
|
||||||
|
if (startQuestionId && pendingQuestions.length > 0) {
|
||||||
|
const startIndex = pendingQuestions.findIndex(q => q.id === startQuestionId);
|
||||||
|
if (startIndex !== -1) {
|
||||||
|
// Put tapped question first, then the rest in original order
|
||||||
|
const tappedQuestion = pendingQuestions[startIndex];
|
||||||
|
const rest = pendingQuestions.filter((_, i) => i !== startIndex);
|
||||||
|
orderedQuestions = [tappedQuestion, ...rest];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuestions(orderedQuestions);
|
||||||
|
setCurrentIndex(0); // Always start at 0 now since we reordered
|
||||||
|
|
||||||
|
// Don't check gap/daily limits in pending mode - user is just catching up
|
||||||
|
setIsBlocked(false);
|
||||||
|
setGapInfo(null);
|
||||||
|
setDailyLimitInfo(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
|
console.error("Failed to fetch pending questions:", error);
|
||||||
|
setQuestions([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSwipe = async (direction: "left" | "right" | "up" | "down") => {
|
const handleSwipe = async (direction: "left" | "right" | "up" | "down") => {
|
||||||
const question = questions[currentIndex];
|
const question = questions[currentIndex];
|
||||||
|
|
||||||
@@ -342,6 +496,8 @@ export default function SwipeScreen() {
|
|||||||
} else {
|
} else {
|
||||||
Events.questionAnswered(answer, question.pack_id);
|
Events.questionAnswered(answer, question.pack_id);
|
||||||
|
|
||||||
|
// Skip limit checks in pending mode - user is just catching up
|
||||||
|
if (mode !== 'pending') {
|
||||||
// Increment local daily count if we have limit info
|
// Increment local daily count if we have limit info
|
||||||
if (dailyLimitInfo && dailyLimitInfo.limit_value > 0) {
|
if (dailyLimitInfo && dailyLimitInfo.limit_value > 0) {
|
||||||
setDailyLimitInfo(prev => prev ? {
|
setDailyLimitInfo(prev => prev ? {
|
||||||
@@ -355,6 +511,7 @@ export default function SwipeScreen() {
|
|||||||
// Check both gap and daily limit
|
// Check both gap and daily limit
|
||||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to submit response", error);
|
console.error("Failed to submit response", error);
|
||||||
}
|
}
|
||||||
@@ -682,6 +839,8 @@ export default function SwipeScreen() {
|
|||||||
|
|
||||||
if (currentIndex >= questions.length) {
|
if (currentIndex >= questions.length) {
|
||||||
const CAUGHT_UP_ACCENT = colors.premium.rose;
|
const CAUGHT_UP_ACCENT = colors.premium.rose;
|
||||||
|
const isPackMode = !!packId;
|
||||||
|
const isPendingMode = mode === 'pending';
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
<View style={styles.centerContainer}>
|
<View style={styles.centerContainer}>
|
||||||
@@ -695,8 +854,12 @@ export default function SwipeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Title section */}
|
{/* Title section */}
|
||||||
<Text style={styles.caughtUpLabel}>COMPLETE</Text>
|
<Text style={styles.caughtUpLabel}>
|
||||||
<Text style={styles.waitingTitle}>All Caught Up</Text>
|
{isPendingMode ? 'QUEUE COMPLETE' : isPackMode ? 'PACK COMPLETE' : 'COMPLETE'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.waitingTitle}>
|
||||||
|
{isPendingMode ? 'All Caught Up!' : isPackMode ? packContext?.name || 'Pack Complete' : 'All Caught Up'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<DecorativeSeparator variant="rose" />
|
<DecorativeSeparator variant="rose" />
|
||||||
|
|
||||||
@@ -705,16 +868,40 @@ export default function SwipeScreen() {
|
|||||||
entering={FadeIn.delay(300).duration(400)}
|
entering={FadeIn.delay(300).duration(400)}
|
||||||
style={styles.waitingBadge}
|
style={styles.waitingBadge}
|
||||||
>
|
>
|
||||||
<Text style={styles.waitingBadgeText}>YOU'RE AHEAD</Text>
|
<Text style={styles.waitingBadgeText}>
|
||||||
|
{isPendingMode ? 'YOUR TURN COMPLETE' : isPackMode ? 'ALL QUESTIONS ANSWERED' : "YOU'RE AHEAD"}
|
||||||
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<Text style={styles.waitingDescription}>
|
<Text style={styles.waitingDescription}>
|
||||||
You've answered all available questions. New questions are added regularly, or explore different packs.
|
{isPendingMode
|
||||||
|
? "You've answered all the questions your partner swiped on. Nice work! Check back later for more."
|
||||||
|
: isPackMode
|
||||||
|
? "You've answered all questions in this pack. Explore other packs to discover more!"
|
||||||
|
: "You've answered all available questions. New questions are added regularly, or explore different packs."
|
||||||
|
}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Feature hints */}
|
{/* Feature hints */}
|
||||||
<View style={styles.waitingFeatures}>
|
<View style={styles.waitingFeatures}>
|
||||||
|
{isPendingMode ? (
|
||||||
|
<>
|
||||||
|
<View style={styles.waitingFeatureItem}>
|
||||||
|
<Ionicons name="heart" size={16} color={CAUGHT_UP_ACCENT} />
|
||||||
|
<Text style={styles.waitingFeatureText}>Check your matches</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.waitingFeatureItem}>
|
||||||
|
<Ionicons name="chatbubbles-outline" size={16} color={CAUGHT_UP_ACCENT} />
|
||||||
|
<Text style={styles.waitingFeatureText}>Chat about discoveries</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.waitingFeatureItem}>
|
||||||
|
<Ionicons name="sparkles" size={16} color={CAUGHT_UP_ACCENT} />
|
||||||
|
<Text style={styles.waitingFeatureText}>Keep exploring together</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<View style={styles.waitingFeatureItem}>
|
<View style={styles.waitingFeatureItem}>
|
||||||
<Ionicons name="sparkles" size={16} color={CAUGHT_UP_ACCENT} />
|
<Ionicons name="sparkles" size={16} color={CAUGHT_UP_ACCENT} />
|
||||||
<Text style={styles.waitingFeatureText}>New questions weekly</Text>
|
<Text style={styles.waitingFeatureText}>New questions weekly</Text>
|
||||||
@@ -727,11 +914,34 @@ export default function SwipeScreen() {
|
|||||||
<Ionicons name="chatbubbles-outline" size={16} color={CAUGHT_UP_ACCENT} />
|
<Ionicons name="chatbubbles-outline" size={16} color={CAUGHT_UP_ACCENT} />
|
||||||
<Text style={styles.waitingFeatureText}>Chat about your matches</Text>
|
<Text style={styles.waitingFeatureText}>Chat about your matches</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Bottom teaser */}
|
{/* Bottom teaser */}
|
||||||
<Text style={styles.waitingTeaser}>More ways to connect are on the way</Text>
|
<Text style={styles.waitingTeaser}>
|
||||||
|
{isPendingMode
|
||||||
|
? 'Your partner will love that you caught up'
|
||||||
|
: isPackMode
|
||||||
|
? 'Discover more ways to connect'
|
||||||
|
: 'More ways to connect are on the way'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isPendingMode ? (
|
||||||
|
<GlassButton
|
||||||
|
onPress={() => router.push("/(app)/matches")}
|
||||||
|
style={{ marginTop: spacing.lg }}
|
||||||
|
>
|
||||||
|
View Matches
|
||||||
|
</GlassButton>
|
||||||
|
) : isPackMode ? (
|
||||||
|
<GlassButton
|
||||||
|
onPress={() => router.back()}
|
||||||
|
style={{ marginTop: spacing.lg }}
|
||||||
|
>
|
||||||
|
Explore More Packs
|
||||||
|
</GlassButton>
|
||||||
|
) : (
|
||||||
<GlassButton
|
<GlassButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={fetchQuestions}
|
onPress={fetchQuestions}
|
||||||
@@ -739,6 +949,7 @@ export default function SwipeScreen() {
|
|||||||
>
|
>
|
||||||
Refresh Questions
|
Refresh Questions
|
||||||
</GlassButton>
|
</GlassButton>
|
||||||
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
@@ -771,9 +982,18 @@ export default function SwipeScreen() {
|
|||||||
entering={FadeIn.duration(400)}
|
entering={FadeIn.duration(400)}
|
||||||
style={styles.header}
|
style={styles.header}
|
||||||
>
|
>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
{/* Back button when playing specific pack or pending mode */}
|
||||||
|
{(packId || mode === 'pending') && (
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<View style={styles.progressContainer}>
|
<View style={styles.progressContainer}>
|
||||||
{/* Premium label */}
|
{/* Premium label */}
|
||||||
<Text style={styles.progressLabel}>EXPLORE</Text>
|
<Text style={styles.progressLabel}>
|
||||||
|
{mode === 'pending' ? 'YOUR TURN' : packContext ? packContext.name.toUpperCase() : 'EXPLORE'}
|
||||||
|
</Text>
|
||||||
<Text style={styles.progressText}>
|
<Text style={styles.progressText}>
|
||||||
{currentIndex + 1} of {effectiveTotal || questions.length}
|
{currentIndex + 1} of {effectiveTotal || questions.length}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -797,6 +1017,9 @@ export default function SwipeScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
{/* Spacer to balance header when back button is shown */}
|
||||||
|
{(packId || mode === 'pending') && <View style={styles.headerSpacer} />}
|
||||||
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Card Stack */}
|
{/* Card Stack */}
|
||||||
@@ -904,6 +1127,18 @@ const styles = StyleSheet.create({
|
|||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.md,
|
paddingBottom: spacing.md,
|
||||||
},
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: spacing.sm,
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
headerSpacer: {
|
||||||
|
width: 40, // Match back button width for centering
|
||||||
|
},
|
||||||
progressContainer: {
|
progressContainer: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2183,6 +2183,8 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- Sentry/HybridSDK (= 8.48.0)
|
- Sentry/HybridSDK (= 8.48.0)
|
||||||
- Yoga
|
- Yoga
|
||||||
|
- RNSVG (15.8.0):
|
||||||
|
- React-Core
|
||||||
- SDWebImage (5.19.7):
|
- SDWebImage (5.19.7):
|
||||||
- SDWebImage/Core (= 5.19.7)
|
- SDWebImage/Core (= 5.19.7)
|
||||||
- SDWebImage/Core (5.19.7)
|
- SDWebImage/Core (5.19.7)
|
||||||
@@ -2303,6 +2305,7 @@ DEPENDENCIES:
|
|||||||
- RNReanimated (from `../../../node_modules/react-native-reanimated`)
|
- RNReanimated (from `../../../node_modules/react-native-reanimated`)
|
||||||
- RNScreens (from `../../../node_modules/react-native-screens`)
|
- RNScreens (from `../../../node_modules/react-native-screens`)
|
||||||
- "RNSentry (from `../../../node_modules/@sentry/react-native`)"
|
- "RNSentry (from `../../../node_modules/@sentry/react-native`)"
|
||||||
|
- RNSVG (from `../node_modules/react-native-svg`)
|
||||||
- Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`)
|
- Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
@@ -2543,6 +2546,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../../node_modules/react-native-screens"
|
:path: "../../../node_modules/react-native-screens"
|
||||||
RNSentry:
|
RNSentry:
|
||||||
:path: "../../../node_modules/@sentry/react-native"
|
:path: "../../../node_modules/@sentry/react-native"
|
||||||
|
RNSVG:
|
||||||
|
:path: "../node_modules/react-native-svg"
|
||||||
Yoga:
|
Yoga:
|
||||||
:path: "../../../node_modules/react-native/ReactCommon/yoga"
|
:path: "../../../node_modules/react-native/ReactCommon/yoga"
|
||||||
|
|
||||||
@@ -2670,6 +2675,7 @@ SPEC CHECKSUMS:
|
|||||||
RNReanimated: 38e033fd1790c817678bc8a075277cdc49d6817c
|
RNReanimated: 38e033fd1790c817678bc8a075277cdc49d6817c
|
||||||
RNScreens: ad2d0cfb3f15c9ed56ffb43651366ec0dcd70c36
|
RNScreens: ad2d0cfb3f15c9ed56ffb43651366ec0dcd70c36
|
||||||
RNSentry: a8f0d86b88675719ce96cb31bd9258dbd793e535
|
RNSentry: a8f0d86b88675719ce96cb31bd9258dbd793e535
|
||||||
|
RNSVG: 8542aa11770b27563714bbd8494a8436385fc85f
|
||||||
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
|
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
|
||||||
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
||||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
|
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||||
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
|
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
|
||||||
1AFB24071BC9A2F5B90C1951 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */; };
|
|
||||||
1F3D2CE02DD8CC2E2BA9F93A /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */; };
|
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||||
47690AB7B3224CD3B1655332 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */; };
|
42547B8429A622FDB6A08D1A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */; };
|
||||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
|
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
|
||||||
|
B7C05C8A003645F0BE64907E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */; };
|
||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||||
E52FFD661AC14FC08C4F239D /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31F85F9C76D34B859549F3A7 /* noop-file.swift */; };
|
C3F98487868740D8A0D61E28 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC04D93C99ED45B8A9943233 /* noop-file.swift */; };
|
||||||
|
FE422ED79FD52F74C1E29BFB /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -26,15 +26,15 @@
|
|||||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
|
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
|
||||||
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
|
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
|
||||||
27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
|
|
||||||
31F85F9C76D34B859549F3A7 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
|
|
||||||
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
|
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
|
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
A9934E72B0154500A352F0F1 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
|
8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||||
|
CA157390B5A149C0AD0B6B08 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
|
DC04D93C99ED45B8A9943233 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||||
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1F3D2CE02DD8CC2E2BA9F93A /* Pods_Sauci.framework in Frameworks */,
|
FE422ED79FD52F74C1E29BFB /* Pods_Sauci.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -61,10 +61,10 @@
|
|||||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||||
13B07FB71A68108700A75B9A /* main.m */,
|
13B07FB71A68108700A75B9A /* main.m */,
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||||
2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */,
|
635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */,
|
||||||
31F85F9C76D34B859549F3A7 /* noop-file.swift */,
|
DC04D93C99ED45B8A9943233 /* noop-file.swift */,
|
||||||
A9934E72B0154500A352F0F1 /* Sauci-Bridging-Header.h */,
|
CA157390B5A149C0AD0B6B08 /* Sauci-Bridging-Header.h */,
|
||||||
787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */,
|
6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
name = Sauci;
|
name = Sauci;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||||
27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */,
|
8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -150,15 +150,15 @@
|
|||||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
|
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
||||||
DB0F43D5EFF9CB0780FDD6A5 /* [Expo] Configure project */,
|
380EBB147031A50785146268 /* [Expo] Configure project */,
|
||||||
13B07F871A680F5B00A75B9A /* Sources */,
|
13B07F871A680F5B00A75B9A /* Sources */,
|
||||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
||||||
F051EF99F56144029D3DFD83 /* Upload Debug Symbols to Sentry */,
|
5F6BF0C2EF824875B30C4F47 /* Upload Debug Symbols to Sentry */,
|
||||||
D1E774350CB6864AD97A24B4 /* [CP] Embed Pods Frameworks */,
|
83F7C8F647047F466249E15C /* [CP] Embed Pods Frameworks */,
|
||||||
658D10E2FBC1B3A1C410A9EB /* [CP-User] [RNFB] Core Configuration */,
|
3AB0FB698F7BE8E7FEAC6EB4 /* [CP-User] [RNFB] Core Configuration */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -210,8 +210,8 @@
|
|||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||||
47690AB7B3224CD3B1655332 /* GoogleService-Info.plist in Resources */,
|
B7C05C8A003645F0BE64907E /* GoogleService-Info.plist in Resources */,
|
||||||
1AFB24071BC9A2F5B90C1951 /* PrivacyInfo.xcprivacy in Resources */,
|
42547B8429A622FDB6A08D1A /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -255,7 +255,26 @@
|
|||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
658D10E2FBC1B3A1C410A9EB /* [CP-User] [RNFB] Core Configuration */ = {
|
380EBB147031A50785146268 /* [Expo] Configure project */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "[Expo] Configure project";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
|
||||||
|
};
|
||||||
|
3AB0FB698F7BE8E7FEAC6EB4 /* [CP-User] [RNFB] Core Configuration */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -268,6 +287,20 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACLine truncated
|
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACLine truncated
|
||||||
};
|
};
|
||||||
|
5F6BF0C2EF824875B30C4F47 /* Upload Debug Symbols to Sentry */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Upload Debug Symbols to Sentry";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
|
||||||
|
};
|
||||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -340,7 +373,7 @@
|
|||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
D1E774350CB6864AD97A24B4 /* [CP] Embed Pods Frameworks */ = {
|
83F7C8F647047F466249E15C /* [CP] Embed Pods Frameworks */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
@@ -358,39 +391,6 @@
|
|||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
DB0F43D5EFF9CB0780FDD6A5 /* [Expo] Configure project */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
alwaysOutOfDate = 1;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[Expo] Configure project";
|
|
||||||
outputFileListPaths = (
|
|
||||||
);
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
|
|
||||||
};
|
|
||||||
F051EF99F56144029D3DFD83 /* Upload Debug Symbols to Sentry */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "Upload Debug Symbols to Sentry";
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
|
|
||||||
};
|
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -401,7 +401,7 @@
|
|||||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
|
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
|
||||||
13B07FC11A68108700A75B9A /* main.m in Sources */,
|
13B07FC11A68108700A75B9A /* main.m in Sources */,
|
||||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
|
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
|
||||||
E52FFD661AC14FC08C4F239D /* noop-file.swift in Sources */,
|
C3F98487868740D8A0D61E28 /* noop-file.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -432,11 +432,11 @@
|
|||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
|
||||||
PRODUCT_NAME = "Sauci";
|
PRODUCT_NAME = Sauci;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
DEVELOPMENT_TEAM = "BXVPBATDZY";
|
DEVELOPMENT_TEAM = "BXVPBATDZY";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
@@ -463,10 +463,10 @@
|
|||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
|
||||||
PRODUCT_NAME = "Sauci";
|
PRODUCT_NAME = Sauci;
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
DEVELOPMENT_TEAM = "BXVPBATDZY";
|
DEVELOPMENT_TEAM = "BXVPBATDZY";
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"react-native-reanimated": "~3.16.0",
|
"react-native-reanimated": "~3.16.0",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
|
"react-native-svg": "15.8.0",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
|
|||||||
@@ -17,23 +17,31 @@ function createThenableQuery(result: any) {
|
|||||||
describe('packsStore', () => {
|
describe('packsStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], isLoading: false } as any);
|
usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], packProgress: new Map(), isLoading: false } as any);
|
||||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', max_intensity: 2, show_explicit_content: false } } as any);
|
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: false } } as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches categories/packs and filters by max intensity', async () => {
|
it('fetches categories/packs and filters by hide_nsfw', async () => {
|
||||||
|
// Set hide_nsfw to true - should filter intensity to max 2
|
||||||
|
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: true } } as any);
|
||||||
|
|
||||||
const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] });
|
const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] });
|
||||||
const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] });
|
const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] });
|
||||||
const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] });
|
const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] });
|
||||||
|
const responsesQuery = createThenableQuery({ data: [] });
|
||||||
|
|
||||||
(supabase.from as jest.Mock)
|
(supabase.from as jest.Mock)
|
||||||
.mockReturnValueOnce(categoriesQuery)
|
.mockReturnValueOnce(categoriesQuery)
|
||||||
.mockReturnValueOnce(packsQuery)
|
.mockReturnValueOnce(packsQuery)
|
||||||
.mockReturnValueOnce(enabledQuery);
|
.mockReturnValueOnce(enabledQuery)
|
||||||
|
.mockReturnValueOnce(responsesQuery);
|
||||||
|
|
||||||
await usePacksStore.getState().fetchPacks();
|
await usePacksStore.getState().fetchPacks();
|
||||||
|
|
||||||
|
// When hide_nsfw=true, max_intensity=2, so or filter is called
|
||||||
expect(packsQuery.or).toHaveBeenCalledWith('max_intensity.is.null,max_intensity.lte.2');
|
expect(packsQuery.or).toHaveBeenCalledWith('max_intensity.is.null,max_intensity.lte.2');
|
||||||
|
// When hide_nsfw=true, explicit packs are filtered out
|
||||||
|
expect(packsQuery.eq).toHaveBeenCalledWith('is_explicit', false);
|
||||||
|
|
||||||
const state = usePacksStore.getState();
|
const state = usePacksStore.getState();
|
||||||
expect(state.categories).toEqual([{ id: 'cat1' }]);
|
expect(state.categories).toEqual([{ id: 'cat1' }]);
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
Animated,
|
Animated,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -110,6 +112,15 @@ export const MatchNotificationModal: React.FC<Props> = ({
|
|||||||
// Dismiss keyboard so it doesn't block the modal buttons
|
// Dismiss keyboard so it doesn't block the modal buttons
|
||||||
Keyboard.dismiss();
|
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
|
// Reset animations
|
||||||
modalScale.setValue(0.8);
|
modalScale.setValue(0.8);
|
||||||
modalOpacity.setValue(0);
|
modalOpacity.setValue(0);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, StyleSheet } from 'react-native';
|
||||||
|
import Svg, { Circle } from 'react-native-svg';
|
||||||
|
|
||||||
|
interface CircularProgressProps {
|
||||||
|
progress: number; // 0 to 1
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
progressColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CircularProgress({
|
||||||
|
progress,
|
||||||
|
size = 28,
|
||||||
|
strokeWidth = 3,
|
||||||
|
backgroundColor = 'rgba(255, 255, 255, 0.2)',
|
||||||
|
progressColor = 'rgba(255, 255, 255, 0.9)',
|
||||||
|
}: CircularProgressProps) {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = radius * 2 * Math.PI;
|
||||||
|
const clampedProgress = Math.min(Math.max(progress, 0), 1);
|
||||||
|
const strokeDashoffset = circumference * (1 - clampedProgress);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { width: size, height: size }]}>
|
||||||
|
<Svg width={size} height={size} style={styles.svg}>
|
||||||
|
{/* Background circle */}
|
||||||
|
<Circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke={backgroundColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="transparent"
|
||||||
|
/>
|
||||||
|
{/* Progress circle */}
|
||||||
|
{clampedProgress > 0 && (
|
||||||
|
<Circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke={progressColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="transparent"
|
||||||
|
strokeDasharray={`${circumference} ${circumference}`}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
svg: {
|
||||||
|
transform: [{ rotateZ: '0deg' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CircularProgress;
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
import { colors, spacing, typography, radius } from '../../theme';
|
||||||
|
import type { Profile, Couple } from '@/types';
|
||||||
|
|
||||||
|
interface CompactHeaderProps {
|
||||||
|
user: Profile | null;
|
||||||
|
partner: Profile | null;
|
||||||
|
couple: Couple | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompactHeader({ user, partner, couple }: CompactHeaderProps) {
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(100).duration(500).springify()}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
{/* Greeting */}
|
||||||
|
<View style={styles.greetingRow}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.welcomeLabel}>Discover</Text>
|
||||||
|
<Text style={styles.greeting}>
|
||||||
|
Hey, {user?.name || 'Beautiful'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Partner badge */}
|
||||||
|
{partner ? (
|
||||||
|
<View style={styles.partnerBadge}>
|
||||||
|
<Ionicons name="heart" size={12} color={colors.primary} />
|
||||||
|
<Text style={styles.partnerText} numberOfLines={1}>
|
||||||
|
{partner.name || partner.email?.split('@')[0] || 'Partner'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : couple ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.waitingBadge}
|
||||||
|
onPress={() => router.push('/(app)/pairing')}
|
||||||
|
>
|
||||||
|
<Ionicons name="hourglass-outline" size={12} color={colors.primary} />
|
||||||
|
<Text style={styles.waitingText}>Waiting</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.pairBadge}
|
||||||
|
onPress={() => router.push('/(app)/pairing')}
|
||||||
|
>
|
||||||
|
<Ionicons name="add-circle-outline" size={14} color={colors.textTertiary} />
|
||||||
|
<Text style={styles.pairText}>Connect</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
paddingTop: 60,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
greetingRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
welcomeLabel: {
|
||||||
|
...typography.caption2,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
color: colors.primary,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
greeting: {
|
||||||
|
...typography.title2,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
partnerBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(233, 69, 96, 0.2)',
|
||||||
|
gap: spacing.xs,
|
||||||
|
maxWidth: 140,
|
||||||
|
},
|
||||||
|
partnerText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
pairBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
pairText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
waitingBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(233, 69, 96, 0.1)',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(233, 69, 96, 0.2)',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
waitingText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CompactHeader;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, View, Text, FlatList, TouchableOpacity } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
import { ContentTile, TILE_WIDTH } from './ContentTile';
|
||||||
|
import { colors, spacing, typography } from '../../theme';
|
||||||
|
import type { QuestionPack } from '@/types';
|
||||||
|
import type { PackProgressData } from '../../store/packsStore';
|
||||||
|
|
||||||
|
interface ContentRowProps {
|
||||||
|
title: string;
|
||||||
|
packs: QuestionPack[];
|
||||||
|
isPremiumUser: boolean;
|
||||||
|
delay?: number;
|
||||||
|
showSeeAll?: boolean;
|
||||||
|
categoryId?: string;
|
||||||
|
getPackProgress?: (packId: string) => PackProgressData | undefined;
|
||||||
|
onShowPaywall?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentRow({
|
||||||
|
title,
|
||||||
|
packs,
|
||||||
|
isPremiumUser,
|
||||||
|
delay = 0,
|
||||||
|
showSeeAll = true,
|
||||||
|
categoryId,
|
||||||
|
getPackProgress,
|
||||||
|
onShowPaywall,
|
||||||
|
}: ContentRowProps) {
|
||||||
|
const handleSeeAll = () => {
|
||||||
|
// Navigate to packs screen with category filter
|
||||||
|
router.push({
|
||||||
|
pathname: '/(app)/packs',
|
||||||
|
params: categoryId ? { category: categoryId } : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTilePress = (pack: QuestionPack) => {
|
||||||
|
// Locked packs show paywall immediately
|
||||||
|
const isLocked = pack.is_premium && !isPremiumUser;
|
||||||
|
if (isLocked) {
|
||||||
|
onShowPaywall?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Unlocked packs start playing immediately
|
||||||
|
router.push({
|
||||||
|
pathname: '/(app)/swipe',
|
||||||
|
params: { packId: pack.id },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (packs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(delay).duration(500).springify()}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
{showSeeAll && (
|
||||||
|
<TouchableOpacity onPress={handleSeeAll} style={styles.seeAllButton}>
|
||||||
|
<Text style={styles.seeAllText}>See all</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Horizontal scroll */}
|
||||||
|
<FlatList
|
||||||
|
data={packs}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
snapToInterval={TILE_WIDTH + spacing.sm}
|
||||||
|
decelerationRate="fast"
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ContentTile
|
||||||
|
pack={item}
|
||||||
|
isLocked={item.is_premium && !isPremiumUser}
|
||||||
|
progress={getPackProgress?.(item.id)}
|
||||||
|
onPress={() => handleTilePress(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...typography.callout,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
seeAllButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
seeAllText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
width: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ContentRow;
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, View, Text, TouchableOpacity, Dimensions } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { colors, spacing, radius, typography, shadows, getCategoryColor } from '../../theme';
|
||||||
|
import { CircularProgress } from './CircularProgress';
|
||||||
|
import type { QuestionPack } from '@/types';
|
||||||
|
import type { PackProgressData } from '../../store/packsStore';
|
||||||
|
|
||||||
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
|
const TILE_GAP = spacing.sm;
|
||||||
|
const HORIZONTAL_PADDING = spacing.lg;
|
||||||
|
// Calculate tile width: 2 tiles per screen with gap and padding, plus peek of next
|
||||||
|
export const TILE_WIDTH = (SCREEN_WIDTH - HORIZONTAL_PADDING * 2 - TILE_GAP) / 2 + 8;
|
||||||
|
export const TILE_HEIGHT = 170;
|
||||||
|
|
||||||
|
interface ContentTileProps {
|
||||||
|
pack: QuestionPack;
|
||||||
|
isLocked: boolean;
|
||||||
|
isNew?: boolean;
|
||||||
|
progress?: PackProgressData;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }: ContentTileProps) {
|
||||||
|
const handlePress = () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
onPress();
|
||||||
|
};
|
||||||
|
const backgroundColor = getCategoryColor(pack.category);
|
||||||
|
const icon = pack.icon || pack.category?.icon || 'layers';
|
||||||
|
const questionCount = pack.questions?.[0]?.count ?? 0;
|
||||||
|
|
||||||
|
// Calculate progress percentage
|
||||||
|
const progressPercent = progress && progress.totalQuestions > 0
|
||||||
|
? progress.answeredQuestions / progress.totalQuestions
|
||||||
|
: 0;
|
||||||
|
// Show progress ring for packs with questions (even at 0%)
|
||||||
|
const showProgress = progress && progress.totalQuestions > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
style={styles.container}
|
||||||
|
>
|
||||||
|
<View style={[styles.tile, { backgroundColor }, shadows.md]}>
|
||||||
|
{/* Top row: icon and badges */}
|
||||||
|
<View style={styles.topRow}>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name={icon as any}
|
||||||
|
size={20}
|
||||||
|
color="rgba(255, 255, 255, 0.9)"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.badges}>
|
||||||
|
{isNew && (
|
||||||
|
<View style={styles.newBadge}>
|
||||||
|
<Text style={styles.newBadgeText}>NEW</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isLocked && (
|
||||||
|
<View style={styles.lockBadge}>
|
||||||
|
<Ionicons name="lock-closed" size={12} color={colors.premium.gold} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{showProgress && (
|
||||||
|
<CircularProgress
|
||||||
|
progress={progressPercent}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={styles.content}>
|
||||||
|
{pack.category?.name && (
|
||||||
|
<Text style={styles.categoryText} numberOfLines={1}>
|
||||||
|
{pack.category.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text style={styles.titleText} numberOfLines={2}>
|
||||||
|
{pack.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom info */}
|
||||||
|
<View style={styles.bottomRow}>
|
||||||
|
<Text style={styles.countText}>
|
||||||
|
{questionCount} {questionCount === 1 ? 'question' : 'questions'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Decorative overlay for depth */}
|
||||||
|
<View style={styles.overlay} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: TILE_WIDTH,
|
||||||
|
height: TILE_HEIGHT,
|
||||||
|
},
|
||||||
|
tile: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
topRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
badges: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
newBadge: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: radius.xs,
|
||||||
|
},
|
||||||
|
newBadgeText: {
|
||||||
|
...typography.caption2,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.background,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
lockBadge: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
categoryText: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
titleText: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
bottomRow: {
|
||||||
|
marginTop: 'auto',
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ContentTile;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile';
|
||||||
|
export { ContentRow } from './ContentRow';
|
||||||
|
export { CompactHeader } from './CompactHeader';
|
||||||
|
export { CircularProgress } from './CircularProgress';
|
||||||
@@ -1,88 +1,41 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform, ScrollView } from 'react-native';
|
import { View, StyleSheet } from 'react-native';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
|
||||||
import { BlurView } from 'expo-blur';
|
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
|
||||||
import { SettingsSection } from './SettingsSection';
|
import { SettingsSection } from './SettingsSection';
|
||||||
import { SwitchItem } from './SwitchItem';
|
import { SwitchItem } from './SwitchItem';
|
||||||
import { IntensitySlider } from '../../../components/ui/IntensitySlider';
|
import { colors, spacing } from '../../../theme';
|
||||||
import { GlassButton } from '../../../components/ui';
|
|
||||||
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
|
|
||||||
import type { IntensityLevel } from '../../../types';
|
|
||||||
|
|
||||||
interface PrivacySettingsProps {
|
interface PrivacySettingsProps {
|
||||||
maxIntensity: IntensityLevel;
|
|
||||||
isUpdatingIntensity: boolean;
|
|
||||||
onIntensityChange: (value: IntensityLevel) => void;
|
|
||||||
biometricAvailable: boolean;
|
biometricAvailable: boolean;
|
||||||
biometricEnabled: boolean;
|
biometricEnabled: boolean;
|
||||||
biometricType: string;
|
biometricType: string;
|
||||||
isUpdatingBiometric: boolean;
|
isUpdatingBiometric: boolean;
|
||||||
onBiometricToggle: (value: boolean) => void;
|
onBiometricToggle: (value: boolean) => void;
|
||||||
// Partner info for slider comparison
|
hideNsfw: boolean;
|
||||||
partnerIntensity?: IntensityLevel | null;
|
isUpdatingHideNsfw: boolean;
|
||||||
partnerName?: string;
|
onHideNsfwToggle: (value: boolean) => void;
|
||||||
partnerAvatar?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
|
||||||
|
|
||||||
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||||
maxIntensity,
|
|
||||||
isUpdatingIntensity,
|
|
||||||
onIntensityChange,
|
|
||||||
biometricAvailable,
|
biometricAvailable,
|
||||||
biometricEnabled,
|
biometricEnabled,
|
||||||
biometricType,
|
biometricType,
|
||||||
isUpdatingBiometric,
|
isUpdatingBiometric,
|
||||||
onBiometricToggle,
|
onBiometricToggle,
|
||||||
partnerIntensity,
|
hideNsfw,
|
||||||
partnerName,
|
isUpdatingHideNsfw,
|
||||||
partnerAvatar,
|
onHideNsfwToggle,
|
||||||
}) => {
|
}) => {
|
||||||
const [showHelpModal, setShowHelpModal] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<SettingsSection title="Preferences" delay={375}>
|
<SettingsSection title="Preferences" delay={375}>
|
||||||
{/* Comfort Zone Header */}
|
{/* Hide Adult Content Toggle */}
|
||||||
<View style={styles.comfortHeader}>
|
<SwitchItem
|
||||||
<LinearGradient
|
icon="eye-off-outline"
|
||||||
colors={ACCENT_GRADIENT}
|
label="Hide Adult Content"
|
||||||
style={styles.comfortIcon}
|
description="Only show mild, family-friendly question packs"
|
||||||
start={{ x: 0, y: 0 }}
|
value={hideNsfw}
|
||||||
end={{ x: 1, y: 1 }}
|
onValueChange={onHideNsfwToggle}
|
||||||
>
|
disabled={isUpdatingHideNsfw}
|
||||||
<Ionicons name="heart-circle" size={22} color={colors.text} />
|
|
||||||
</LinearGradient>
|
|
||||||
<View style={styles.comfortText}>
|
|
||||||
<View style={styles.comfortLabelRow}>
|
|
||||||
<Text style={styles.comfortLabel}>Comfort Zone</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setShowHelpModal(true)}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Ionicons name="help-circle-outline" size={20} color={colors.textSecondary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.comfortDescription}>
|
|
||||||
Set the intimacy level you're comfortable with
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Intensity Slider */}
|
|
||||||
<View style={styles.sliderContainer}>
|
|
||||||
<IntensitySlider
|
|
||||||
value={maxIntensity}
|
|
||||||
onValueChange={onIntensityChange}
|
|
||||||
disabled={isUpdatingIntensity}
|
|
||||||
partnerValue={partnerIntensity}
|
|
||||||
partnerName={partnerName}
|
|
||||||
partnerAvatar={partnerAvatar}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Biometric Toggle */}
|
{/* Biometric Toggle */}
|
||||||
{biometricAvailable && (
|
{biometricAvailable && (
|
||||||
@@ -99,207 +52,13 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Help Modal */}
|
|
||||||
<Modal
|
|
||||||
visible={showHelpModal}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={() => setShowHelpModal(false)}
|
|
||||||
>
|
|
||||||
<BlurView
|
|
||||||
intensity={Platform.OS === 'ios' ? 20 : 0}
|
|
||||||
tint="dark"
|
|
||||||
style={StyleSheet.absoluteFill}
|
|
||||||
>
|
|
||||||
<View style={styles.modalOverlay}>
|
|
||||||
<View style={styles.modalContent}>
|
|
||||||
<View style={styles.modalHeader}>
|
|
||||||
<Text style={styles.modalTitle}>How Comfort Zone Works</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setShowHelpModal(false)}
|
|
||||||
style={styles.closeButton}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
style={styles.helpScrollView}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
<View style={styles.helpSection}>
|
|
||||||
<View style={styles.helpItem}>
|
|
||||||
<Ionicons name="layers-outline" size={20} color={colors.primary} style={styles.helpIcon} />
|
|
||||||
<View style={styles.helpTextContainer}>
|
|
||||||
<Text style={styles.helpItemTitle}>Intensity Levels</Text>
|
|
||||||
<Text style={styles.helpItemDescription}>
|
|
||||||
Questions range from 1 (mild) to 5 (spicy). You'll only see questions at or below your comfort level.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.helpItem}>
|
|
||||||
<Ionicons name="albums-outline" size={20} color={colors.primary} style={styles.helpIcon} />
|
|
||||||
<View style={styles.helpTextContainer}>
|
|
||||||
<Text style={styles.helpItemTitle}>Hidden Packs</Text>
|
|
||||||
<Text style={styles.helpItemDescription}>
|
|
||||||
Some question packs are designed for higher intensity levels. These packs won't appear until you raise your comfort zone.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.helpItem}>
|
|
||||||
<Ionicons name="people-outline" size={20} color={colors.primary} style={styles.helpIcon} />
|
|
||||||
<View style={styles.helpTextContainer}>
|
|
||||||
<Text style={styles.helpItemTitle}>Comfort Zone Gate</Text>
|
|
||||||
<Text style={styles.helpItemDescription}>
|
|
||||||
You both see the same questions based on the lower comfort zone between you. If you're at 5 and your partner is at 3, you'll both only see questions up to level 3.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.helpItem}>
|
|
||||||
<Ionicons name="eye-outline" size={20} color={colors.primary} style={styles.helpIcon} />
|
|
||||||
<View style={styles.helpTextContainer}>
|
|
||||||
<Text style={styles.helpItemTitle}>Visible to Partner</Text>
|
|
||||||
<Text style={styles.helpItemDescription}>
|
|
||||||
Your partner can see the comfort zone level you choose so you can stay aligned. This doesn’t reveal your individual swipes.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[styles.helpItem, styles.helpItemLast]}>
|
|
||||||
<Ionicons name="refresh-outline" size={20} color={colors.primary} style={styles.helpIcon} />
|
|
||||||
<View style={styles.helpTextContainer}>
|
|
||||||
<Text style={styles.helpItemTitle}>Change Anytime</Text>
|
|
||||||
<Text style={styles.helpItemDescription}>
|
|
||||||
Adjust whenever you want. New questions and packs will appear or hide based on your new setting.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<GlassButton
|
|
||||||
onPress={() => setShowHelpModal(false)}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
Got it
|
|
||||||
</GlassButton>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</BlurView>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
comfortHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
},
|
|
||||||
comfortIcon: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginRight: spacing.md,
|
|
||||||
},
|
|
||||||
comfortText: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
comfortLabelRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: spacing.xs,
|
|
||||||
},
|
|
||||||
comfortLabel: {
|
|
||||||
...typography.headline,
|
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
comfortDescription: {
|
|
||||||
...typography.caption1,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
sliderContainer: {
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
divider: {
|
divider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: colors.glass.border,
|
backgroundColor: colors.glass.border,
|
||||||
marginVertical: spacing.lg,
|
marginVertical: spacing.lg,
|
||||||
},
|
},
|
||||||
// Modal styles
|
|
||||||
modalOverlay: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: spacing.lg,
|
|
||||||
},
|
|
||||||
modalContent: {
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: 400,
|
|
||||||
maxHeight: '80%',
|
|
||||||
backgroundColor: colors.backgroundLight,
|
|
||||||
borderRadius: radius.xl,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.glass.border,
|
|
||||||
padding: spacing.lg,
|
|
||||||
},
|
|
||||||
modalHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginBottom: spacing.md,
|
|
||||||
paddingBottom: spacing.md,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: colors.glass.border,
|
|
||||||
},
|
|
||||||
modalTitle: {
|
|
||||||
...typography.headline,
|
|
||||||
color: colors.text,
|
|
||||||
},
|
|
||||||
closeButton: {
|
|
||||||
padding: spacing.xs,
|
|
||||||
},
|
|
||||||
helpScrollView: {
|
|
||||||
flexGrow: 0,
|
|
||||||
marginBottom: spacing.md,
|
|
||||||
},
|
|
||||||
helpSection: {
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
helpItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
},
|
|
||||||
helpItemLast: {
|
|
||||||
paddingBottom: 0,
|
|
||||||
},
|
|
||||||
helpIcon: {
|
|
||||||
marginRight: spacing.sm,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
helpTextContainer: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
helpItemTitle: {
|
|
||||||
...typography.subhead,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text,
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
helpItemDescription: {
|
|
||||||
...typography.caption1,
|
|
||||||
color: colors.textSecondary,
|
|
||||||
lineHeight: 18,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -4,7 +4,6 @@ import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../stor
|
|||||||
import { supabase } from '../../../lib/supabase';
|
import { supabase } from '../../../lib/supabase';
|
||||||
import { Events } from '../../../lib/analytics';
|
import { Events } from '../../../lib/analytics';
|
||||||
import { useAvatarPicker } from '../../../hooks/useAvatarPicker';
|
import { useAvatarPicker } from '../../../hooks/useAvatarPicker';
|
||||||
import type { IntensityLevel } from '../../../types';
|
|
||||||
import {
|
import {
|
||||||
registerForPushNotificationsAsync,
|
registerForPushNotificationsAsync,
|
||||||
savePushToken,
|
savePushToken,
|
||||||
@@ -17,27 +16,17 @@ import {
|
|||||||
getBiometricType,
|
getBiometricType,
|
||||||
} from '../../../lib/biometricAuth';
|
} from '../../../lib/biometricAuth';
|
||||||
|
|
||||||
const DEFAULT_MAX_INTENSITY: IntensityLevel = 2;
|
// Intensity thresholds for backwards compatibility
|
||||||
|
// When hide_nsfw=true, max_intensity=2 (mild content only)
|
||||||
const normalizeIntensity = (value: number | null | undefined): IntensityLevel | null => {
|
// When hide_nsfw=false, max_intensity=5 (all content)
|
||||||
if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) return value;
|
const NSFW_OFF_INTENSITY = 2;
|
||||||
return null;
|
const NSFW_ON_INTENSITY = 5;
|
||||||
};
|
|
||||||
|
|
||||||
const getProfileMaxIntensity = (profile?: { max_intensity?: number | null; show_explicit_content?: boolean | null } | null): IntensityLevel => {
|
|
||||||
const normalized = normalizeIntensity(profile?.max_intensity);
|
|
||||||
if (normalized) return normalized;
|
|
||||||
return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useProfileSettings() {
|
export function useProfileSettings() {
|
||||||
const { user, partner, fetchUser } = useAuthStore();
|
const { user, fetchUser } = useAuthStore();
|
||||||
const { fetchPacks } = usePacksStore();
|
const { fetchPacks } = usePacksStore();
|
||||||
const { restorePurchases, isPurchasing } = useSubscriptionStore();
|
const { restorePurchases, isPurchasing } = useSubscriptionStore();
|
||||||
|
|
||||||
// Partner's intensity level (for showing on slider)
|
|
||||||
const partnerIntensity = partner ? getProfileMaxIntensity(partner) : null;
|
|
||||||
|
|
||||||
// Name Editing
|
// Name Editing
|
||||||
const [isEditingName, setIsEditingName] = useState(false);
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
const [newName, setNewName] = useState(user?.name || "");
|
const [newName, setNewName] = useState(user?.name || "");
|
||||||
@@ -80,9 +69,9 @@ export function useProfileSettings() {
|
|||||||
onUploadError: handleAvatarUploadError,
|
onUploadError: handleAvatarUploadError,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Preferences
|
// Hide NSFW
|
||||||
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getProfileMaxIntensity(user));
|
const [hideNsfw, setHideNsfw] = useState(!!user?.hide_nsfw);
|
||||||
const [isUpdatingIntensity, setIsUpdatingIntensity] = useState(false);
|
const [isUpdatingHideNsfw, setIsUpdatingHideNsfw] = useState(false);
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
|
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
|
||||||
@@ -102,8 +91,8 @@ export function useProfileSettings() {
|
|||||||
if (user?.name) {
|
if (user?.name) {
|
||||||
setNewName(user.name);
|
setNewName(user.name);
|
||||||
}
|
}
|
||||||
setMaxIntensity(getProfileMaxIntensity(user));
|
|
||||||
setPushEnabled(!!user?.push_token);
|
setPushEnabled(!!user?.push_token);
|
||||||
|
setHideNsfw(!!user?.hide_nsfw);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
// Check biometric availability
|
// Check biometric availability
|
||||||
@@ -168,41 +157,6 @@ export function useProfileSettings() {
|
|||||||
setIsEditingName(false);
|
setIsEditingName(false);
|
||||||
}, [user?.name]);
|
}, [user?.name]);
|
||||||
|
|
||||||
// Preference Actions
|
|
||||||
const handleIntensityChange = async (value: IntensityLevel) => {
|
|
||||||
if (!user?.id) return;
|
|
||||||
|
|
||||||
const previous = maxIntensity;
|
|
||||||
setMaxIntensity(value);
|
|
||||||
setIsUpdatingIntensity(true);
|
|
||||||
|
|
||||||
const showExplicitContent = value >= 3;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.update({
|
|
||||||
max_intensity: value,
|
|
||||||
show_explicit_content: showExplicitContent,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq('id', user.id);
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
// Refresh user data and packs to reflect the change
|
|
||||||
await fetchUser();
|
|
||||||
await fetchPacks();
|
|
||||||
Events.profileUpdated(["max_intensity", "show_explicit_content"]);
|
|
||||||
} catch (error) {
|
|
||||||
// Revert on error
|
|
||||||
setMaxIntensity(previous);
|
|
||||||
Alert.alert("Error", "Failed to update preference. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setIsUpdatingIntensity(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePushToggle = async (value: boolean) => {
|
const handlePushToggle = async (value: boolean) => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|
||||||
@@ -275,6 +229,43 @@ export function useProfileSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleHideNsfwToggle = async (value: boolean) => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
const previous = hideNsfw;
|
||||||
|
setHideNsfw(value);
|
||||||
|
setIsUpdatingHideNsfw(true);
|
||||||
|
|
||||||
|
// Derive intensity from hide_nsfw for backwards compatibility
|
||||||
|
const maxIntensity = value ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
|
||||||
|
const showExplicitContent = !value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update({
|
||||||
|
hide_nsfw: value,
|
||||||
|
max_intensity: maxIntensity,
|
||||||
|
show_explicit_content: showExplicitContent,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', user.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Refresh user data and packs to reflect the change
|
||||||
|
await fetchUser();
|
||||||
|
await fetchPacks();
|
||||||
|
Events.profileUpdated(["hide_nsfw", "max_intensity", "show_explicit_content"]);
|
||||||
|
} catch (error) {
|
||||||
|
// Revert on error
|
||||||
|
setHideNsfw(previous);
|
||||||
|
Alert.alert("Error", "Failed to update preference. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingHideNsfw(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Subscription Actions
|
// Subscription Actions
|
||||||
const handleRestorePurchases = async () => {
|
const handleRestorePurchases = async () => {
|
||||||
const restored = await restorePurchases();
|
const restored = await restorePurchases();
|
||||||
@@ -297,30 +288,25 @@ export function useProfileSettings() {
|
|||||||
setNewName,
|
setNewName,
|
||||||
isUpdatingName,
|
isUpdatingName,
|
||||||
isUploadingAvatar,
|
isUploadingAvatar,
|
||||||
maxIntensity,
|
|
||||||
isUpdatingIntensity,
|
|
||||||
pushEnabled,
|
pushEnabled,
|
||||||
isUpdatingPush,
|
isUpdatingPush,
|
||||||
biometricAvailable,
|
biometricAvailable,
|
||||||
biometricEnabled: biometricEnabledState,
|
biometricEnabled: biometricEnabledState,
|
||||||
biometricType,
|
biometricType,
|
||||||
isUpdatingBiometric,
|
isUpdatingBiometric,
|
||||||
|
hideNsfw,
|
||||||
|
isUpdatingHideNsfw,
|
||||||
showPaywall,
|
showPaywall,
|
||||||
setShowPaywall,
|
setShowPaywall,
|
||||||
isPurchasing,
|
isPurchasing,
|
||||||
|
|
||||||
// Partner info for comfort zone comparison
|
|
||||||
partnerIntensity,
|
|
||||||
partnerName: partner?.name ?? undefined,
|
|
||||||
partnerAvatar: partner?.avatar_url ?? undefined,
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
handleUpdateName,
|
handleUpdateName,
|
||||||
handleCancelEditName,
|
handleCancelEditName,
|
||||||
handleAvatarPress,
|
handleAvatarPress,
|
||||||
handleIntensityChange,
|
|
||||||
handlePushToggle,
|
handlePushToggle,
|
||||||
handleBiometricToggle,
|
handleBiometricToggle,
|
||||||
|
handleHideNsfwToggle,
|
||||||
handleRestorePurchases,
|
handleRestorePurchases,
|
||||||
handleManageSubscription,
|
handleManageSubscription,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useProfileSettings } from '../hooks';
|
|||||||
import { ScreenHeader, PrivacySettings } from '../components';
|
import { ScreenHeader, PrivacySettings } from '../components';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preferences sub-screen for comfort zone and biometric settings.
|
* Preferences sub-screen for content and biometric settings.
|
||||||
*/
|
*/
|
||||||
export function PreferencesScreen() {
|
export function PreferencesScreen() {
|
||||||
const settings = useProfileSettings();
|
const settings = useProfileSettings();
|
||||||
@@ -21,17 +21,14 @@ export function PreferencesScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<PrivacySettings
|
<PrivacySettings
|
||||||
maxIntensity={settings.maxIntensity}
|
|
||||||
isUpdatingIntensity={settings.isUpdatingIntensity}
|
|
||||||
onIntensityChange={settings.handleIntensityChange}
|
|
||||||
biometricAvailable={settings.biometricAvailable}
|
biometricAvailable={settings.biometricAvailable}
|
||||||
biometricEnabled={settings.biometricEnabled}
|
biometricEnabled={settings.biometricEnabled}
|
||||||
biometricType={settings.biometricType}
|
biometricType={settings.biometricType}
|
||||||
isUpdatingBiometric={settings.isUpdatingBiometric}
|
isUpdatingBiometric={settings.isUpdatingBiometric}
|
||||||
onBiometricToggle={settings.handleBiometricToggle}
|
onBiometricToggle={settings.handleBiometricToggle}
|
||||||
partnerIntensity={settings.partnerIntensity}
|
hideNsfw={settings.hideNsfw}
|
||||||
partnerName={settings.partnerName}
|
isUpdatingHideNsfw={settings.isUpdatingHideNsfw}
|
||||||
partnerAvatar={settings.partnerAvatar}
|
onHideNsfwToggle={settings.handleHideNsfwToggle}
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Re-export all stores for convenient imports
|
// Re-export all stores for convenient imports
|
||||||
export { useAuthStore } from "./authStore";
|
export { useAuthStore } from "./authStore";
|
||||||
export { useMatchStore } from "./matchStore";
|
export { useMatchStore } from "./matchStore";
|
||||||
|
export type { PendingQuestion } from "./matchStore";
|
||||||
export { usePacksStore } from "./packsStore";
|
export { usePacksStore } from "./packsStore";
|
||||||
export { useMessageStore } from "./messageStore";
|
export { useMessageStore } from "./messageStore";
|
||||||
export { useSubscriptionStore } from "./subscriptionStore";
|
export { useSubscriptionStore } from "./subscriptionStore";
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { supabase } from "../lib/supabase";
|
import { supabase } from "../lib/supabase";
|
||||||
import type { Match } from "@/types";
|
import type { Match, Question, QuestionPack, AnswerType } from "@/types";
|
||||||
import { useAuthStore } from "./authStore";
|
import { useAuthStore } from "./authStore";
|
||||||
|
|
||||||
const BATCH_SIZE = 20;
|
const BATCH_SIZE = 20;
|
||||||
|
|
||||||
|
// Question waiting for user's response (partner has answered)
|
||||||
|
export interface PendingQuestion {
|
||||||
|
id: string; // Partner's response ID
|
||||||
|
question: Question & { pack?: Pick<QuestionPack, "id" | "name" | "icon"> };
|
||||||
|
partnerAnsweredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchViewType = 'active' | 'archived' | 'pending';
|
||||||
|
|
||||||
interface MatchState {
|
interface MatchState {
|
||||||
matches: Match[];
|
matches: Match[];
|
||||||
newMatchesCount: number;
|
newMatchesCount: number;
|
||||||
@@ -19,6 +28,10 @@ interface MatchState {
|
|||||||
archivedMatchIds: Set<string>;
|
archivedMatchIds: Set<string>;
|
||||||
showArchived: boolean;
|
showArchived: boolean;
|
||||||
isLoadingArchived: boolean;
|
isLoadingArchived: boolean;
|
||||||
|
// Pending (Your Turn) state
|
||||||
|
pendingQuestions: PendingQuestion[];
|
||||||
|
isLoadingPending: boolean;
|
||||||
|
currentView: MatchViewType;
|
||||||
// Methods
|
// Methods
|
||||||
fetchMatches: (refresh?: boolean) => Promise<void>;
|
fetchMatches: (refresh?: boolean) => Promise<void>;
|
||||||
markAsSeen: (matchId: string) => Promise<void>;
|
markAsSeen: (matchId: string) => Promise<void>;
|
||||||
@@ -32,6 +45,9 @@ interface MatchState {
|
|||||||
fetchArchivedMatches: () => Promise<void>;
|
fetchArchivedMatches: () => Promise<void>;
|
||||||
toggleShowArchived: () => void;
|
toggleShowArchived: () => void;
|
||||||
isMatchArchived: (matchId: string) => boolean;
|
isMatchArchived: (matchId: string) => boolean;
|
||||||
|
// Pending methods
|
||||||
|
fetchPendingQuestions: () => Promise<void>;
|
||||||
|
setCurrentView: (view: MatchViewType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMatchStore = create<MatchState>((set, get) => ({
|
export const useMatchStore = create<MatchState>((set, get) => ({
|
||||||
@@ -48,6 +64,10 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
archivedMatchIds: new Set<string>(),
|
archivedMatchIds: new Set<string>(),
|
||||||
showArchived: false,
|
showArchived: false,
|
||||||
isLoadingArchived: false,
|
isLoadingArchived: false,
|
||||||
|
// Pending (Your Turn) state
|
||||||
|
pendingQuestions: [],
|
||||||
|
isLoadingPending: false,
|
||||||
|
currentView: 'pending' as MatchViewType,
|
||||||
|
|
||||||
fetchMatches: async (refresh = false) => {
|
fetchMatches: async (refresh = false) => {
|
||||||
const userId = useAuthStore.getState().user?.id;
|
const userId = useAuthStore.getState().user?.id;
|
||||||
@@ -269,6 +289,9 @@ clearMatches: () => {
|
|||||||
archivedMatchIds: new Set<string>(),
|
archivedMatchIds: new Set<string>(),
|
||||||
showArchived: false,
|
showArchived: false,
|
||||||
isLoadingArchived: false,
|
isLoadingArchived: false,
|
||||||
|
pendingQuestions: [],
|
||||||
|
isLoadingPending: false,
|
||||||
|
currentView: 'pending',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -447,4 +470,95 @@ clearMatches: () => {
|
|||||||
isMatchArchived: (matchId: string) => {
|
isMatchArchived: (matchId: string) => {
|
||||||
return get().archivedMatchIds.has(matchId);
|
return get().archivedMatchIds.has(matchId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Pending (Your Turn) methods
|
||||||
|
fetchPendingQuestions: async () => {
|
||||||
|
const userId = useAuthStore.getState().user?.id;
|
||||||
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
|
|
||||||
|
if (!coupleId || !userId) {
|
||||||
|
set({ pendingQuestions: [], isLoadingPending: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = get();
|
||||||
|
if (state.isLoadingPending) return;
|
||||||
|
|
||||||
|
set({ isLoadingPending: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get partner's user_id
|
||||||
|
const { data: coupleProfiles } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("id")
|
||||||
|
.eq("couple_id", coupleId)
|
||||||
|
.neq("id", userId);
|
||||||
|
|
||||||
|
const partnerId = coupleProfiles?.[0]?.id;
|
||||||
|
if (!partnerId) {
|
||||||
|
set({ pendingQuestions: [], isLoadingPending: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get questions the current user has already answered
|
||||||
|
const { data: userResponses } = await supabase
|
||||||
|
.from("responses")
|
||||||
|
.select("question_id")
|
||||||
|
.eq("user_id", userId)
|
||||||
|
.eq("couple_id", coupleId);
|
||||||
|
|
||||||
|
const answeredQuestionIds = new Set(userResponses?.map(r => r.question_id) ?? []);
|
||||||
|
|
||||||
|
// Get partner's responses (questions they answered that user hasn't)
|
||||||
|
const { data: partnerResponses, error } = await supabase
|
||||||
|
.from("responses")
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
question_id,
|
||||||
|
created_at,
|
||||||
|
question:questions(
|
||||||
|
*,
|
||||||
|
pack:question_packs(id, name, icon)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq("user_id", partnerId)
|
||||||
|
.eq("couple_id", coupleId)
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Filter to only questions user hasn't answered, excluding deleted questions
|
||||||
|
// Note: Supabase returns single-object relations as arrays in type inference, but they're actually objects at runtime
|
||||||
|
const pendingQuestions: PendingQuestion[] = (partnerResponses ?? [])
|
||||||
|
.filter(r => {
|
||||||
|
const question = r.question as unknown as PendingQuestion['question'] | null;
|
||||||
|
return !answeredQuestionIds.has(r.question_id) && question && !(question as any).deleted_at;
|
||||||
|
})
|
||||||
|
.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
question: r.question as unknown as PendingQuestion['question'],
|
||||||
|
partnerAnsweredAt: r.created_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
set({ pendingQuestions, isLoadingPending: false });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching pending questions:", err);
|
||||||
|
set({ isLoadingPending: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentView: (view: MatchViewType) => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
// Update showArchived for backwards compatibility
|
||||||
|
const showArchived = view === 'archived';
|
||||||
|
set({ currentView: view, showArchived });
|
||||||
|
|
||||||
|
// Fetch data for the selected view if not already loaded
|
||||||
|
if (view === 'archived' && state.archivedMatches.length === 0) {
|
||||||
|
get().fetchArchivedMatches();
|
||||||
|
} else if (view === 'pending' && state.pendingQuestions.length === 0) {
|
||||||
|
get().fetchPendingQuestions();
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
@@ -4,51 +4,55 @@ import { Events } from "../lib/analytics";
|
|||||||
import type { QuestionPack, Category } from "@/types";
|
import type { QuestionPack, Category } from "@/types";
|
||||||
import { useAuthStore } from "./authStore";
|
import { useAuthStore } from "./authStore";
|
||||||
|
|
||||||
const DEFAULT_MAX_INTENSITY = 2;
|
// Intensity thresholds derived from hide_nsfw for backwards compatibility
|
||||||
|
// When hide_nsfw=true, max_intensity=2 (mild content only)
|
||||||
|
// When hide_nsfw=false, max_intensity=5 (all content)
|
||||||
|
const NSFW_OFF_INTENSITY = 2;
|
||||||
|
const NSFW_ON_INTENSITY = 5;
|
||||||
|
|
||||||
const normalizeIntensity = (value?: number | null) => {
|
const getMaxIntensityFromHideNsfw = (hideNsfw: boolean) => {
|
||||||
if (typeof value !== "number" || Number.isNaN(value)) return null;
|
return hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
|
||||||
const rounded = Math.round(value);
|
|
||||||
if (rounded < 1 || rounded > 5) return null;
|
|
||||||
return rounded;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getUserMaxIntensity = () => {
|
// Progress data for a pack
|
||||||
const user = useAuthStore.getState().user;
|
export interface PackProgressData {
|
||||||
const normalized = normalizeIntensity(user?.max_intensity ?? null);
|
totalQuestions: number;
|
||||||
if (normalized) return normalized;
|
answeredQuestions: number;
|
||||||
return user?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
interface PacksState {
|
interface PacksState {
|
||||||
packs: QuestionPack[];
|
packs: QuestionPack[];
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
enabledPackIds: string[];
|
enabledPackIds: string[];
|
||||||
|
packProgress: Map<string, PackProgressData>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
showAllIntensities: boolean;
|
showAllIntensities: boolean;
|
||||||
fetchPacks: () => Promise<void>;
|
fetchPacks: () => Promise<void>;
|
||||||
fetchEnabledPacks: () => Promise<void>;
|
fetchEnabledPacks: () => Promise<void>;
|
||||||
|
fetchPackProgress: () => Promise<void>;
|
||||||
ensureEnabledPacksLoaded: () => Promise<void>;
|
ensureEnabledPacksLoaded: () => Promise<void>;
|
||||||
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
|
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
|
||||||
setShowAllIntensities: (value: boolean) => void;
|
setShowAllIntensities: (value: boolean) => void;
|
||||||
clearPacks: () => void;
|
clearPacks: () => void;
|
||||||
|
getPackProgress: (packId: string) => PackProgressData | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePacksStore = create<PacksState>((set, get) => ({
|
export const usePacksStore = create<PacksState>((set, get) => ({
|
||||||
packs: [],
|
packs: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
enabledPackIds: [],
|
enabledPackIds: [],
|
||||||
|
packProgress: new Map(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
showAllIntensities: false,
|
showAllIntensities: false,
|
||||||
|
|
||||||
fetchPacks: async () => {
|
fetchPacks: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
|
||||||
// Get user's max intensity preference
|
// Get hide_nsfw preference and derive max_intensity from it
|
||||||
const maxIntensity = getUserMaxIntensity();
|
const hideNsfw = useAuthStore.getState().user?.hide_nsfw ?? false;
|
||||||
|
const maxIntensity = getMaxIntensityFromHideNsfw(hideNsfw);
|
||||||
const showAllIntensities = get().showAllIntensities;
|
const showAllIntensities = get().showAllIntensities;
|
||||||
|
|
||||||
|
|
||||||
// Fetch categories
|
// Fetch categories
|
||||||
const { data: categories } = await supabase
|
const { data: categories } = await supabase
|
||||||
.from("categories")
|
.from("categories")
|
||||||
@@ -71,6 +75,11 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
|||||||
query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`);
|
query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out explicit packs when user has hide_nsfw enabled
|
||||||
|
if (hideNsfw) {
|
||||||
|
query = query.eq("is_explicit", false);
|
||||||
|
}
|
||||||
|
|
||||||
const { data: packs } = await query;
|
const { data: packs } = await query;
|
||||||
|
|
||||||
const visiblePacks = (packs ?? []).filter(pack => {
|
const visiblePacks = (packs ?? []).filter(pack => {
|
||||||
@@ -89,6 +98,9 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
|||||||
categories: visibleCategories,
|
categories: visibleCategories,
|
||||||
isLoading: false
|
isLoading: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch progress after packs are loaded
|
||||||
|
get().fetchPackProgress();
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchEnabledPacks: async () => {
|
fetchEnabledPacks: async () => {
|
||||||
@@ -165,7 +177,61 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
|||||||
get().fetchPacks();
|
get().fetchPacks();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchPackProgress: async () => {
|
||||||
|
const userId = useAuthStore.getState().user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
set({ packProgress: new Map() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packs = get().packs;
|
||||||
|
if (packs.length === 0) return;
|
||||||
|
|
||||||
|
// Fetch user's response counts grouped by pack
|
||||||
|
const { data: responseCounts, error } = await supabase
|
||||||
|
.from("responses")
|
||||||
|
.select("question:questions!inner(pack_id)")
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching pack progress:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: log response counts
|
||||||
|
console.log("[PackProgress] userId:", userId);
|
||||||
|
console.log("[PackProgress] total responses:", responseCounts?.length ?? 0);
|
||||||
|
|
||||||
|
// Count responses per pack
|
||||||
|
const responsesByPack = new Map<string, number>();
|
||||||
|
responseCounts?.forEach((r) => {
|
||||||
|
const packId = (r.question as any)?.pack_id;
|
||||||
|
if (packId) {
|
||||||
|
responsesByPack.set(packId, (responsesByPack.get(packId) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug: log responses by pack
|
||||||
|
if (responsesByPack.size > 0) {
|
||||||
|
console.log("[PackProgress] responses by pack:", Object.fromEntries(responsesByPack));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build progress map
|
||||||
|
const progressMap = new Map<string, PackProgressData>();
|
||||||
|
packs.forEach((pack) => {
|
||||||
|
const totalQuestions = pack.questions?.[0]?.count ?? 0;
|
||||||
|
const answeredQuestions = responsesByPack.get(pack.id) || 0;
|
||||||
|
progressMap.set(pack.id, { totalQuestions, answeredQuestions });
|
||||||
|
});
|
||||||
|
|
||||||
|
set({ packProgress: progressMap });
|
||||||
|
},
|
||||||
|
|
||||||
|
getPackProgress: (packId: string) => {
|
||||||
|
return get().packProgress.get(packId);
|
||||||
|
},
|
||||||
|
|
||||||
clearPacks: () => {
|
clearPacks: () => {
|
||||||
set({ enabledPackIds: [] });
|
set({ enabledPackIds: [], packProgress: new Map() });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -94,6 +94,64 @@ export const gradients = {
|
|||||||
|
|
||||||
// Feature-specific color mapping
|
// Feature-specific color mapping
|
||||||
// Each feature has a consistent accent color for icons, buttons, and highlights
|
// Each feature has a consistent accent color for icons, buttons, and highlights
|
||||||
|
// Tile colors for discovery screen - vibrant solid colors
|
||||||
|
export const tileColors = {
|
||||||
|
teal: '#14B8A6',
|
||||||
|
purple: '#8B5CF6',
|
||||||
|
orange: '#F97316',
|
||||||
|
coral: '#F87171',
|
||||||
|
emerald: '#10B981',
|
||||||
|
indigo: '#6366F1',
|
||||||
|
rose: '#EC4899',
|
||||||
|
amber: '#F59E0B',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map categories to tile colors
|
||||||
|
export const categoryColorMap: Record<string, string> = {
|
||||||
|
// Common category names (lowercase for matching)
|
||||||
|
passion: tileColors.coral,
|
||||||
|
connection: tileColors.teal,
|
||||||
|
fantasy: tileColors.purple,
|
||||||
|
adventure: tileColors.orange,
|
||||||
|
starter: tileColors.emerald,
|
||||||
|
romance: tileColors.rose,
|
||||||
|
intimacy: tileColors.indigo,
|
||||||
|
communication: tileColors.amber,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get color for a category (with fallback)
|
||||||
|
// Accepts either a Category object or just the category name for backwards compatibility
|
||||||
|
export const getCategoryColor = (
|
||||||
|
category?: { name?: string | null; color?: string | null } | string | null
|
||||||
|
): string => {
|
||||||
|
// Handle string input (backwards compatibility)
|
||||||
|
if (typeof category === 'string') {
|
||||||
|
return getCategoryColorFromName(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If category has an explicit color set, use it
|
||||||
|
if (category?.color) {
|
||||||
|
return category.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to name-based logic
|
||||||
|
return getCategoryColorFromName(category?.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Internal helper: derive color from category name
|
||||||
|
const getCategoryColorFromName = (categoryName?: string | null): string => {
|
||||||
|
if (!categoryName) return tileColors.teal;
|
||||||
|
const normalizedName = categoryName.toLowerCase();
|
||||||
|
// Check for partial matches
|
||||||
|
for (const [key, color] of Object.entries(categoryColorMap)) {
|
||||||
|
if (normalizedName.includes(key)) return color;
|
||||||
|
}
|
||||||
|
// Fallback: use consistent color based on string hash
|
||||||
|
const hash = normalizedName.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||||
|
const colorKeys = Object.keys(tileColors) as (keyof typeof tileColors)[];
|
||||||
|
return tileColors[colorKeys[hash % colorKeys.length]];
|
||||||
|
};
|
||||||
|
|
||||||
export const featureColors = {
|
export const featureColors = {
|
||||||
swipe: {
|
swipe: {
|
||||||
accent: colors.primary,
|
accent: colors.primary,
|
||||||
@@ -288,6 +346,9 @@ export const theme = {
|
|||||||
colors,
|
colors,
|
||||||
gradients,
|
gradients,
|
||||||
featureColors,
|
featureColors,
|
||||||
|
tileColors,
|
||||||
|
categoryColorMap,
|
||||||
|
getCategoryColor,
|
||||||
spacing,
|
spacing,
|
||||||
radius,
|
radius,
|
||||||
typography,
|
typography,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface Profile {
|
|||||||
gender: Gender | null;
|
gender: Gender | null;
|
||||||
show_explicit_content: boolean;
|
show_explicit_content: boolean;
|
||||||
max_intensity: IntensityLevel;
|
max_intensity: IntensityLevel;
|
||||||
|
hide_nsfw: boolean;
|
||||||
onboarding_completed: boolean;
|
onboarding_completed: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -33,6 +34,7 @@ export interface Category {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
color: string | null;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Add hide_nsfw column to profiles table
|
||||||
|
-- When true, packs marked as is_explicit will be hidden from the user
|
||||||
|
-- Independent of max_intensity setting
|
||||||
|
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS hide_nsfw BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN profiles.hide_nsfw IS
|
||||||
|
'When true, packs marked as is_explicit will be hidden. Independent of max_intensity.';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add color column to categories table
|
||||||
|
-- Allows explicit color assignment; app falls back to name-based color logic if NULL
|
||||||
|
|
||||||
|
ALTER TABLE categories
|
||||||
|
ADD COLUMN color text;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN categories.color IS 'Hex color code (e.g. #14B8A6) for category tiles. Falls back to app logic if NULL.';
|
||||||
Whitespace-only changes.
Generated
+148
@@ -119,6 +119,7 @@
|
|||||||
"react-native-reanimated": "~3.16.0",
|
"react-native-reanimated": "~3.16.0",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
|
"react-native-svg": "15.8.0",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
@@ -137,6 +138,21 @@
|
|||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"apps/mobile/node_modules/react-native-svg": {
|
||||||
|
"version": "15.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.8.0.tgz",
|
||||||
|
"integrity": "sha512-KHJzKpgOjwj1qeZzsBjxNdoIgv2zNCO9fVcoq2TEhTRsVV5DGTZ9JzUZwybd7q4giT/H3RdtqC3u44dWdO0Ffw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-tree": "^1.1.3",
|
||||||
|
"warn-once": "0.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"apps/supabase": {
|
"apps/supabase": {
|
||||||
"name": "@sauci/supabase",
|
"name": "@sauci/supabase",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
@@ -10319,6 +10335,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/bplist-creator": {
|
"node_modules/bplist-creator": {
|
||||||
"version": "0.0.7",
|
"version": "0.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.0.7.tgz",
|
||||||
@@ -11306,6 +11328,47 @@
|
|||||||
"hyphenate-style-name": "^1.0.3"
|
"hyphenate-style-name": "^1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-tree": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.0.14",
|
||||||
|
"source-map": "^0.6.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -11789,6 +11852,44 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dom-serializer/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/domexception": {
|
"node_modules/domexception": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz",
|
||||||
@@ -11803,6 +11904,35 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.3",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
@@ -17143,6 +17273,12 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdn-data": {
|
||||||
|
"version": "2.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||||
|
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/memoize-one": {
|
"node_modules/memoize-one": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
@@ -18031,6 +18167,18 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nullthrows": {
|
"node_modules/nullthrows": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface Profile {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
/** RSA public key in JWK format for E2EE */
|
/** RSA public key in JWK format for E2EE */
|
||||||
public_key_jwk?: Record<string, unknown> | null;
|
public_key_jwk?: Record<string, unknown> | null;
|
||||||
|
/** When true, packs marked as is_explicit will be hidden */
|
||||||
|
hide_nsfw?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Couple pairing
|
// Couple pairing
|
||||||
|
|||||||
Reference in new issue
Block a user