This commit is contained in:
Jamie Brunton committed 2025-12-29 21:03:25 +00:00
1 parent d1ca114285
commit 8acc43981e
22 files changed
+1728 -94

No files matched your search

+120 -23
View File
@@ -1,10 +1,11 @@
import { Tabs, useRouter } from "expo-router"; import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { View, ActivityIndicator, StyleSheet, Platform, Modal, Text, Pressable, Animated, AppState } from "react-native"; import { View, ActivityIndicator, StyleSheet, Platform, Modal, Text, Pressable, Animated, AppState } from "react-native";
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore } from "../../src/store"; import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store";
import { colors, blur, radius, spacing, typography, shadows } from "../../src/theme"; import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import type { MatchWithQuestion } from "../../src/types"; import type { MatchWithQuestion } from "../../src/types";
import type { Database } from "../../src/types/supabase"; import type { Database } from "../../src/types/supabase";
@@ -15,31 +16,74 @@ function TabBarBackground() {
if (Platform.OS === 'ios') { if (Platform.OS === 'ios') {
return ( return (
<BlurView <BlurView
intensity={blur.medium} intensity={blur.heavy}
tint="dark" tint="systemChromeMaterialDark"
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
/> />
); );
} }
return <View style={[StyleSheet.absoluteFill, { backgroundColor: colors.glass.backgroundLight }]} />; return <View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(22, 33, 62, 0.9)' }]} />;
}
interface PlayTabButtonProps {
children?: React.ReactNode;
onPress?: (e: any) => void;
accessibilityState?: { selected?: boolean };
}
function PlayTabButton({ onPress, accessibilityState }: PlayTabButtonProps) {
const isSelected = accessibilityState?.selected;
return (
<Pressable
onPress={onPress}
style={styles.playButtonContainer}
>
{/* Outer glow effect */}
<View style={[styles.playButtonGlow, isSelected && styles.playButtonGlowActive]} />
{/* Circle container */}
<View style={styles.playButtonCircle}>
<LinearGradient
colors={gradients.primary as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.playButtonGradient}
>
<Ionicons
name="flame"
size={32}
color={colors.text}
/>
</LinearGradient>
</View>
</Pressable>
);
} }
export default function AppLayout() { export default function AppLayout() {
const router = useRouter(); const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut } = useAuthStore(); const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
const { newMatchesCount, addMatch } = useMatchStore(); const { newMatchesCount, addMatch } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore(); const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore(); const { initializeRevenueCat } = useSubscriptionStore();
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null); const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
const messageToastAnim = useRef(new Animated.Value(-100)).current; const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null); const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Redirect to login when not authenticated // Check if we're currently on the onboarding screen
const isOnOnboarding = segments.includes("onboarding");
// Redirect to login when not authenticated, or to onboarding if not completed
useEffect(() => { useEffect(() => {
if (!isLoading && !isAuthenticated) { if (!isLoading && !isAuthenticated) {
router.replace("/(auth)/login"); router.replace("/(auth)/login");
} else if (!isLoading && isAuthenticated && user && !user.onboarding_completed && !isOnOnboarding) {
router.replace("/(app)/onboarding");
} }
}, [isLoading, isAuthenticated, router]); }, [isLoading, isAuthenticated, user, router, isOnOnboarding]);
// Check session validity on mount and when app comes to foreground // Check session validity on mount and when app comes to foreground
useEffect(() => { useEffect(() => {
@@ -169,6 +213,32 @@ export default function AppLayout() {
}; };
}, [user?.couple_id, user?.id, addMessage]); }, [user?.couple_id, user?.id, addMessage]);
// Subscribe to realtime pack setting changes (when partner toggles a pack)
useEffect(() => {
if (!user?.couple_id) return;
const channel = supabase
.channel(`couple_packs:${user.couple_id}`)
.on(
"postgres_changes",
{
event: "*", // INSERT, UPDATE, DELETE
schema: "public",
table: "couple_packs",
filter: `couple_id=eq.${user.couple_id}`,
},
() => {
// Refetch enabled packs when partner changes pack settings
fetchEnabledPacks();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [user?.couple_id, fetchEnabledPacks]);
// Animate message toast when lastMessage changes // Animate message toast when lastMessage changes
useEffect(() => { useEffect(() => {
if (lastMessage) { if (lastMessage) {
@@ -219,17 +289,8 @@ export default function AppLayout() {
} }
}, [lastMessage, dismissMessageToast, router]); }, [lastMessage, dismissMessageToast, router]);
// Show loading state while checking authentication // Show loading state while checking authentication or redirecting to onboarding
if (isLoading) { if (isLoading || !isAuthenticated || (user && !user.onboarding_completed && !isOnOnboarding)) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
// Show loading while checking auth or redirecting
if (!isAuthenticated) {
return ( return (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
@@ -282,10 +343,8 @@ export default function AppLayout() {
<Tabs.Screen <Tabs.Screen
name="swipe" name="swipe"
options={{ options={{
title: "Play", title: "",
tabBarIcon: ({ color, size }) => ( tabBarButton: (props) => <PlayTabButton {...props} />,
<Ionicons name="flame" size={size} color={color} />
),
}} }}
/> />
<Tabs.Screen <Tabs.Screen
@@ -326,6 +385,13 @@ export default function AppLayout() {
tabBarStyle: { display: 'none' }, // Hide tab bar on chat screen tabBarStyle: { display: 'none' }, // Hide tab bar on chat screen
}} }}
/> />
<Tabs.Screen
name="onboarding"
options={{
href: null, // Hide from tab bar
tabBarStyle: { display: 'none' }, // Hide tab bar on onboarding screen
}}
/>
</Tabs> </Tabs>
{/* Match Notification Modal */} {/* Match Notification Modal */}
@@ -498,4 +564,35 @@ const styles = StyleSheet.create({
color: colors.textSecondary, color: colors.textSecondary,
marginTop: 2, marginTop: 2,
}, },
// Play button styles
playButtonContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
marginTop: -28,
},
playButtonGlow: {
position: 'absolute',
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.primaryGlow,
opacity: 0.5,
},
playButtonGlowActive: {
opacity: 1,
...shadows.glow(colors.primary),
},
playButtonCircle: {
width: 60,
height: 60,
borderRadius: 30,
overflow: 'hidden',
...shadows.lg,
},
playButtonGradient: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
}); });
+9
View File
@@ -30,6 +30,7 @@ export default function ChatScreen() {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [partnerTyping, setPartnerTyping] = useState(false); const [partnerTyping, setPartnerTyping] = useState(false);
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null); const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true);
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null); const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isFocusedRef = useRef(false); const isFocusedRef = useRef(false);
@@ -441,11 +442,16 @@ export default function ChatScreen() {
> >
<Ionicons name="close" size={28} color={colors.text} /> <Ionicons name="close" size={28} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
{fullScreenImageLoading && (
<ActivityIndicator size="large" color={colors.primary} style={styles.fullScreenSpinner} />
)}
{fullScreenImage && ( {fullScreenImage && (
<Image <Image
source={{ uri: fullScreenImage }} source={{ uri: fullScreenImage }}
style={styles.fullScreenImage} style={styles.fullScreenImage}
resizeMode="contain" resizeMode="contain"
onLoadStart={() => setFullScreenImageLoading(true)}
onLoadEnd={() => setFullScreenImageLoading(false)}
/> />
)} )}
</View> </View>
@@ -944,4 +950,7 @@ const styles = StyleSheet.create({
width: Dimensions.get("window").width, width: Dimensions.get("window").width,
height: Dimensions.get("window").height * 0.8, height: Dimensions.get("window").height * 0.8,
}, },
fullScreenSpinner: {
position: "absolute",
},
}); });
+26 -2
View File
@@ -11,7 +11,7 @@ import { colors, gradients, spacing, radius, typography, shadows } from "../../s
const MAX_CONTENT_WIDTH = 500; const MAX_CONTENT_WIDTH = 500;
export default function HomeScreen() { export default function HomeScreen() {
const { user, partner } = useAuthStore(); const { user, partner, couple } = useAuthStore();
const { matches, newMatchesCount, fetchMatches } = useMatchStore(); const { matches, newMatchesCount, fetchMatches } = useMatchStore();
const { packs, fetchPacks } = usePacksStore(); const { packs, fetchPacks } = usePacksStore();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
@@ -52,6 +52,14 @@ export default function HomeScreen() {
{partner.name || partner.email?.split('@')[0] || 'Partner'} {partner.name || partner.email?.split('@')[0] || 'Partner'}
</Text> </Text>
</View> </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 for partner</Text>
</TouchableOpacity>
) : ( ) : (
<TouchableOpacity <TouchableOpacity
style={styles.pairBadge} style={styles.pairBadge}
@@ -184,7 +192,7 @@ export default function HomeScreen() {
</Text> </Text>
</View> </View>
<View style={styles.ctaIconContainer}> <View style={styles.ctaIconContainer}>
<Ionicons name="play" size={24} color={colors.text} /> <Ionicons name="flame" size={24} color={colors.text} />
</View> </View>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
@@ -351,6 +359,22 @@ const styles = StyleSheet.create({
...typography.caption1, ...typography.caption1,
color: colors.textTertiary, color: colors.textTertiary,
}, },
waitingBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
alignSelf: 'flex-start',
marginTop: spacing.sm,
gap: spacing.xs,
},
waitingText: {
...typography.caption1,
color: colors.text,
fontWeight: '500',
},
avatarGradient: { avatarGradient: {
width: 48, width: 48,
height: 48, height: 48,
+1 -1
View File
@@ -179,7 +179,7 @@ export default function MatchesScreen() {
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
> >
<Ionicons name="play" size={18} color={colors.text} /> <Ionicons name="flame" size={18} color={colors.text} />
<Text style={styles.emptyButtonText}>Start Swiping</Text> <Text style={styles.emptyButtonText}>Start Swiping</Text>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
+555
View File
@@ -0,0 +1,555 @@
import React, { useState, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform,
Keyboard,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
FadeOutLeft,
SlideInRight,
SlideOutLeft,
Layout,
} from 'react-native-reanimated';
import { GradientBackground } from '../../src/components/ui/GradientBackground';
import { GlassCard } from '../../src/components/ui/GlassCard';
import { GlassButton } from '../../src/components/ui/GlassButton';
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
import { useAuthStore } from '../../src/store';
import { supabase } from '../../src/lib/supabase';
import type { Gender } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'male', label: 'Male', icon: 'male' },
{ value: 'female', label: 'Female', icon: 'female' },
{ value: 'non-binary', label: 'Non-binary', icon: 'male-female' },
{ value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' },
];
type Stage = 'name' | 'gender' | 'explicit';
export default function OnboardingScreen() {
const router = useRouter();
const { user, fetchUser } = useAuthStore();
const [stage, setStage] = useState<Stage>('name');
const [name, setName] = useState(user?.name || '');
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content || false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<TextInput>(null);
const handleNameSubmit = () => {
if (!name.trim()) {
setError('Please enter your name');
return;
}
Keyboard.dismiss();
setError(null);
setStage('gender');
};
const handleGenderSelect = (selectedGender: Gender) => {
setGender(selectedGender);
setError(null);
setTimeout(() => setStage('explicit'), 300);
};
const handleComplete = async () => {
if (!user?.id) {
setError('Not logged in. Please restart the app.');
return;
}
setIsLoading(true);
setError(null);
try {
const { error: updateError } = await supabase
.from('profiles')
.update({
name: name.trim(),
gender,
show_explicit_content: showExplicit,
onboarding_completed: true,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (updateError) {
throw updateError;
}
await fetchUser();
router.replace('/');
} catch (err: any) {
console.error('Onboarding error:', err);
setError(err.message || 'Something went wrong');
} finally {
setIsLoading(false);
}
};
const renderStage = () => {
switch (stage) {
case 'name':
return (
<Animated.View
key="name"
entering={FadeInUp.duration(500)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
>
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="heart" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>Welcome to Sauci</Text>
<Text style={styles.subtitle}>
Let's get to know you
</Text>
</View>
<GlassCard style={styles.card}>
<Text style={styles.label}>What's your name?</Text>
<View style={styles.inputContainer}>
<Ionicons
name="person-outline"
size={20}
color={colors.textSecondary}
style={styles.inputIcon}
/>
<TextInput
ref={inputRef}
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Enter your name"
placeholderTextColor={colors.textTertiary}
autoCapitalize="words"
autoCorrect={false}
autoFocus
onSubmitEditing={handleNameSubmit}
returnKeyType="next"
/>
</View>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton onPress={handleNameSubmit} fullWidth size="lg">
Continue
</GlassButton>
</View>
</Animated.View>
);
case 'gender':
return (
<Animated.View
key="gender"
entering={SlideInRight.duration(400)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
>
<View style={styles.header}>
<Text style={styles.greeting}>Hey, {name.trim()}!</Text>
<Text style={styles.subtitle}>
This helps us show you the right questions
</Text>
</View>
<GlassCard style={styles.card}>
<Text style={styles.label}>What's your gender?</Text>
<View style={styles.genderGrid}>
{GENDER_OPTIONS.map((option) => (
<Pressable
key={option.value}
style={[
styles.genderOption,
gender === option.value && styles.genderOptionSelected,
]}
onPress={() => handleGenderSelect(option.value)}
>
<Ionicons
name={option.icon as any}
size={28}
color={gender === option.value ? colors.primary : colors.textSecondary}
/>
<Text
style={[
styles.genderLabel,
gender === option.value && styles.genderLabelSelected,
]}
>
{option.label}
</Text>
</Pressable>
))}
</View>
</GlassCard>
</Animated.View>
);
case 'explicit':
return (
<Animated.View
key="explicit"
entering={SlideInRight.duration(400)}
exiting={FadeOut.duration(200)}
style={styles.stageContainer}
>
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="flame" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>Almost there!</Text>
<Text style={styles.subtitle}>
Shall we keep it clean or show you everything?
</Text>
</View>
<GlassCard style={styles.card}>
<Pressable
style={styles.explicitOption}
onPress={() => setShowExplicit(true)}
>
<View style={[
styles.explicitIcon,
showExplicit && styles.explicitIconSelected
]}>
<Ionicons
name="flame"
size={32}
color={showExplicit ? colors.primary : colors.textSecondary}
/>
</View>
<View style={styles.explicitText}>
<Text style={[
styles.explicitTitle,
showExplicit && styles.explicitTitleSelected
]}>
Show me everything
</Text>
<Text style={styles.explicitDescription}>
Include 18+ question packs
</Text>
</View>
<View style={[
styles.radioOuter,
showExplicit && styles.radioOuterSelected
]}>
{showExplicit && <View style={styles.radioInner} />}
</View>
</Pressable>
<Pressable
style={styles.explicitOption}
onPress={() => setShowExplicit(false)}
>
<View style={[
styles.explicitIcon,
!showExplicit && styles.explicitIconSelected
]}>
<Ionicons
name="heart"
size={32}
color={!showExplicit ? colors.primary : colors.textSecondary}
/>
</View>
<View style={styles.explicitText}>
<Text style={[
styles.explicitTitle,
!showExplicit && styles.explicitTitleSelected
]}>
Keep it clean
</Text>
<Text style={styles.explicitDescription}>
Only show SFW content
</Text>
</View>
<View style={[
styles.radioOuter,
!showExplicit && styles.radioOuterSelected
]}>
{!showExplicit && <View style={styles.radioInner} />}
</View>
</Pressable>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton
onPress={handleComplete}
loading={isLoading}
fullWidth
size="lg"
>
Let's Go!
</GlassButton>
</View>
</Animated.View>
);
}
};
return (
<GradientBackground>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
{/* Header with back button and progress dots */}
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
{stage !== 'name' ? (
<Pressable
style={styles.backButton}
onPress={() => setStage(stage === 'explicit' ? 'gender' : 'name')}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
) : (
<View style={styles.backButtonPlaceholder} />
)}
<View style={styles.progressContainer}>
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'explicit' && styles.progressDotActive]} />
</View>
<View style={styles.backButtonPlaceholder} />
</Animated.View>
{renderStage()}
</KeyboardAvoidingView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
headerBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingBottom: spacing.md,
paddingHorizontal: spacing.md,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.glass.background,
alignItems: 'center',
justifyContent: 'center',
},
backButtonPlaceholder: {
width: 40,
},
progressContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.sm,
},
progressDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.glass.border,
},
progressDotActive: {
backgroundColor: colors.primary,
width: 24,
},
stageContainer: {
flex: 1,
padding: spacing.lg,
paddingBottom: 40,
},
header: {
alignItems: 'center',
marginBottom: spacing.xl,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
...shadows.lg,
},
title: {
...typography.largeTitle,
color: colors.text,
marginBottom: spacing.sm,
textAlign: 'center',
},
greeting: {
...typography.largeTitle,
color: colors.primary,
marginBottom: spacing.sm,
textAlign: 'center',
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
},
card: {
marginBottom: spacing.lg,
},
label: {
...typography.headline,
color: colors.text,
marginBottom: spacing.md,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
paddingHorizontal: spacing.md,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
...typography.body,
color: colors.text,
paddingVertical: spacing.md,
},
genderGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
},
genderOption: {
width: '48%',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.md,
gap: spacing.sm,
},
genderOptionSelected: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
genderLabel: {
...typography.subhead,
color: colors.textSecondary,
textAlign: 'center',
},
genderLabelSelected: {
color: colors.primary,
fontWeight: '600',
},
explicitOption: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
padding: spacing.md,
marginBottom: spacing.sm,
},
explicitIcon: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.glass.background,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
explicitIconSelected: {
backgroundColor: colors.primaryLight,
},
explicitText: {
flex: 1,
},
explicitTitle: {
...typography.headline,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
explicitTitleSelected: {
color: colors.text,
},
explicitDescription: {
...typography.caption1,
color: colors.textTertiary,
},
radioOuter: {
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: colors.glass.border,
alignItems: 'center',
justifyContent: 'center',
},
radioOuterSelected: {
borderColor: colors.primary,
},
radioInner: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: colors.primary,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(231, 76, 60, 0.1)',
borderRadius: radius.md,
padding: spacing.md,
gap: spacing.sm,
marginTop: spacing.sm,
},
errorText: {
...typography.caption1,
color: colors.error,
flex: 1,
},
footer: {
marginTop: 'auto',
},
});
+31 -3
View File
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useState, useCallback } from "react";
import Animated, { FadeInDown, FadeInRight, FadeIn, FadeOut, Layout } from "react-native-reanimated"; import Animated, { FadeInDown, FadeInRight, FadeIn, FadeOut, Layout } from "react-native-reanimated";
import { GradientBackground, GlassCard } from "../../src/components/ui"; import { GradientBackground, GlassCard } from "../../src/components/ui";
import { Paywall } from "../../src/components/Paywall"; import { Paywall } from "../../src/components/Paywall";
import { PackTeaser } from "../../src/components/PackTeaser";
import { colors, spacing, typography, radius } from "../../src/theme"; import { colors, spacing, typography, radius } from "../../src/theme";
import type { QuestionPack, Category } from "../../src/types"; import type { QuestionPack, Category } from "../../src/types";
@@ -19,6 +20,8 @@ export default function PacksScreen() {
const { subscription } = useSubscriptionStore(); const { subscription } = useSubscriptionStore();
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set()); const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const [showPaywall, setShowPaywall] = useState(false); const [showPaywall, setShowPaywall] = useState(false);
const [showTeaser, setShowTeaser] = useState(false);
const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(null);
// Check if user or partner has premium access // Check if user or partner has premium access
const hasPremiumAccess = useMemo(() => { const hasPremiumAccess = useMemo(() => {
@@ -29,15 +32,28 @@ export default function PacksScreen() {
fetchPacks(); fetchPacks();
}, []); }, []);
// Handle toggling a pack - show paywall if premium pack without access // Handle toggling a pack - show teaser if premium pack without access
const handleTogglePack = useCallback((pack: QuestionPack) => { const handleTogglePack = useCallback((pack: QuestionPack) => {
if (pack.is_premium && !hasPremiumAccess) { if (pack.is_premium && !hasPremiumAccess) {
setShowPaywall(true); setTeaserPack(pack);
setShowTeaser(true);
return; return;
} }
togglePack(pack.id); togglePack(pack.id);
}, [hasPremiumAccess, togglePack]); }, [hasPremiumAccess, togglePack]);
// Handle teaser unlock button - show paywall
const handleTeaserUnlock = useCallback(() => {
setShowTeaser(false);
setShowPaywall(true);
}, []);
// Handle teaser close
const handleTeaserClose = useCallback(() => {
setShowTeaser(false);
setTeaserPack(null);
}, []);
const enabledPacks = packs.filter(p => enabledPackIds.includes(p.id)); const enabledPacks = packs.filter(p => enabledPackIds.includes(p.id));
const enabledPackCount = enabledPacks.length; const enabledPackCount = enabledPacks.length;
const totalQuestions = enabledPacks.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0); const totalQuestions = enabledPacks.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0);
@@ -81,7 +97,8 @@ export default function PacksScreen() {
activeOpacity={0.7} activeOpacity={0.7}
onPress={() => { onPress={() => {
if (isPremiumLocked) { if (isPremiumLocked) {
setShowPaywall(true); setTeaserPack(item);
setShowTeaser(true);
} else { } else {
router.push(`/pack/${item.id}`); router.push(`/pack/${item.id}`);
} }
@@ -227,6 +244,17 @@ export default function PacksScreen() {
} }
/> />
{teaserPack && (
<PackTeaser
visible={showTeaser}
packId={teaserPack.id}
packName={teaserPack.name}
packIcon={teaserPack.icon}
onClose={handleTeaserClose}
onUnlock={handleTeaserUnlock}
/>
)}
<Paywall <Paywall
visible={showPaywall} visible={showPaywall}
onClose={() => setShowPaywall(false)} onClose={() => setShowPaywall(false)}
+18 -5
View File
@@ -165,6 +165,12 @@ export default function PairingScreen() {
if (couple && !partner) { if (couple && !partner) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
>
<Ionicons name="arrow-back" size={24} color="#fff" />
</TouchableOpacity>
<View style={styles.content}> <View style={styles.content}>
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
<Ionicons name="heart" size={48} color="#e94560" /> <Ionicons name="heart" size={48} color="#e94560" />
@@ -188,11 +194,6 @@ export default function PairingScreen() {
Waiting for your partner to join... Waiting for your partner to join...
</Text> </Text>
<ActivityIndicator size="small" color="#e94560" style={{ marginTop: 16 }} /> <ActivityIndicator size="small" color="#e94560" style={{ marginTop: 16 }} />
<TouchableOpacity style={styles.signOutButton} onPress={handleSignOut}>
<Ionicons name="log-out-outline" size={20} color="#888" />
<Text style={styles.signOutText}>Sign Out</Text>
</TouchableOpacity>
</View> </View>
</View> </View>
); );
@@ -262,6 +263,18 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
backgroundColor: "#1a1a2e", backgroundColor: "#1a1a2e",
}, },
backButton: {
position: "absolute",
top: Platform.OS === "ios" ? 60 : 40,
left: 20,
zIndex: 10,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
centerContent: { centerContent: {
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
+96 -7
View File
@@ -1,4 +1,4 @@
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform, useWindowDimensions, TextInput, Modal, ActivityIndicator, Linking } from "react-native"; import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform, useWindowDimensions, TextInput, Modal, ActivityIndicator, Linking, Switch } from "react-native";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useAuthStore, useMatchStore, useMessageStore, usePacksStore, useSubscriptionStore } from "../../src/store"; import { useAuthStore, useMatchStore, useMessageStore, usePacksStore, useSubscriptionStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
@@ -14,10 +14,10 @@ import { colors, gradients, spacing, radius, typography, shadows } from "../../s
const MAX_CONTENT_WIDTH = 500; const MAX_CONTENT_WIDTH = 500;
export default function ProfileScreen() { export default function ProfileScreen() {
const { user, partner, couple, signOut, fetchCouple } = useAuthStore(); const { user, partner, couple, signOut, fetchCouple, fetchUser } = useAuthStore();
const { clearMatches } = useMatchStore(); const { clearMatches } = useMatchStore();
const { clearMessages } = useMessageStore(); const { clearMessages } = useMessageStore();
const { clearPacks } = usePacksStore(); const { clearPacks, fetchPacks } = usePacksStore();
const { subscription, restorePurchases, isPurchasing } = useSubscriptionStore(); const { subscription, restorePurchases, isPurchasing } = useSubscriptionStore();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH; const isWideScreen = width > MAX_CONTENT_WIDTH;
@@ -27,6 +27,8 @@ export default function ProfileScreen() {
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [showFeedbackModal, setShowFeedbackModal] = useState(false); const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [showPaywall, setShowPaywall] = useState(false); const [showPaywall, setShowPaywall] = useState(false);
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? false);
const [isUpdatingExplicit, setIsUpdatingExplicit] = useState(false);
// Check if user or partner has premium access // Check if user or partner has premium access
const hasPremiumAccess = useMemo(() => { const hasPremiumAccess = useMemo(() => {
@@ -55,6 +57,35 @@ export default function ProfileScreen() {
} }
}; };
const handleExplicitToggle = async (value: boolean) => {
if (!user?.id) return;
setShowExplicit(value);
setIsUpdatingExplicit(true);
try {
const { error } = await supabase
.from('profiles')
.update({
show_explicit_content: value,
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();
} catch (error) {
// Revert on error
setShowExplicit(!value);
Alert.alert("Error", "Failed to update preference. Please try again.");
} finally {
setIsUpdatingExplicit(false);
}
};
const handleManageSubscription = () => { const handleManageSubscription = () => {
Linking.openURL("https://apps.apple.com/account/subscriptions"); Linking.openURL("https://apps.apple.com/account/subscriptions");
}; };
@@ -205,6 +236,30 @@ export default function ProfileScreen() {
<Ionicons name="unlink-outline" size={18} color={colors.textTertiary} /> <Ionicons name="unlink-outline" size={18} color={colors.textTertiary} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : couple ? (
<TouchableOpacity
style={styles.rowContainer}
onPress={() => router.push("/(app)/pairing")}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="hourglass-outline" size={20} color={colors.text} />
</LinearGradient>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValue}>Waiting for partner</Text>
<Text style={styles.rowLabel}>Tap to view invite code</Text>
</View>
</View>
<View style={styles.chevronContainer}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
</TouchableOpacity>
) : ( ) : (
<TouchableOpacity <TouchableOpacity
style={styles.rowContainer} style={styles.rowContainer}
@@ -307,9 +362,43 @@ export default function ProfileScreen() {
)} )}
</Animated.View> </Animated.View>
{/* Preferences Section */}
<Animated.View
entering={FadeInDown.delay(375).duration(500)}
style={styles.section}
>
<Text style={styles.sectionTitle}>Preferences</Text>
<GlassCard>
<View style={styles.rowContainer}>
<View style={styles.rowLeft}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="flame" size={20} color={colors.text} />
</LinearGradient>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValue}>Explicit Content</Text>
<Text style={styles.rowLabel}>Show 18+ question packs</Text>
</View>
</View>
<Switch
value={showExplicit}
onValueChange={handleExplicitToggle}
disabled={isUpdatingExplicit}
trackColor={{ false: colors.glass.border, true: colors.primaryLight }}
thumbColor={showExplicit ? colors.primary : colors.textTertiary}
ios_backgroundColor={colors.glass.border}
/>
</View>
</GlassCard>
</Animated.View>
{/* Account Section */} {/* Account Section */}
<Animated.View <Animated.View
entering={FadeInDown.delay(400).duration(500)} entering={FadeInDown.delay(425).duration(500)}
style={styles.section} style={styles.section}
> >
<Text style={styles.sectionTitle}>Account</Text> <Text style={styles.sectionTitle}>Account</Text>
@@ -332,7 +421,7 @@ export default function ProfileScreen() {
{/* Support Section */} {/* Support Section */}
<Animated.View <Animated.View
entering={FadeInDown.delay(450).duration(500)} entering={FadeInDown.delay(475).duration(500)}
style={styles.section} style={styles.section}
> >
<Text style={styles.sectionTitle}>Support</Text> <Text style={styles.sectionTitle}>Support</Text>
@@ -366,7 +455,7 @@ export default function ProfileScreen() {
{/* Danger Zone - Only show if in a couple */} {/* Danger Zone - Only show if in a couple */}
{couple && ( {couple && (
<Animated.View <Animated.View
entering={FadeInDown.delay(550).duration(500)} entering={FadeInDown.delay(525).duration(500)}
style={styles.section} style={styles.section}
> >
<Text style={styles.dangerSectionTitle}>Danger Zone</Text> <Text style={styles.dangerSectionTitle}>Danger Zone</Text>
@@ -398,7 +487,7 @@ export default function ProfileScreen() {
{/* Version */} {/* Version */}
<Animated.View <Animated.View
entering={FadeInDown.delay(couple ? 650 : 550).duration(500)} entering={FadeInDown.delay(couple ? 575 : 525).duration(500)}
style={styles.versionContainer} style={styles.versionContainer}
> >
<View style={styles.versionBadge}> <View style={styles.versionBadge}>
+65 -13
View File
@@ -3,11 +3,13 @@ import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-nativ
import { useLocalSearchParams, router } from "expo-router"; import { useLocalSearchParams, router } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated"; import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { usePacksStore } from "../../src/store"; import { usePacksStore } from "../../src/store";
import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions";
import SwipeCard from "../../src/components/SwipeCard"; import SwipeCard from "../../src/components/SwipeCard";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
import { colors, spacing, typography, radius, shadows } from "../../src/theme"; import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
export default function SwipeScreen() { export default function SwipeScreen() {
const { packId } = useLocalSearchParams(); const { packId } = useLocalSearchParams();
@@ -20,18 +22,39 @@ export default function SwipeScreen() {
fetchPacks().then(() => fetchQuestions()); fetchPacks().then(() => fetchQuestions());
}, [packId]); }, [packId]);
// Filter out questions from disabled packs immediately when enabledPackIds changes
useEffect(() => {
if (!packId && enabledPackIds.length > 0 && questions.length > 0) {
const enabledSet = new Set(enabledPackIds);
setQuestions(prev => {
const filtered = prev.filter(q => enabledSet.has(q.pack_id));
// Adjust currentIndex if it's now beyond the filtered list
if (currentIndex >= filtered.length && filtered.length > 0) {
setCurrentIndex(filtered.length - 1);
}
return filtered;
});
}
}, [enabledPackIds]);
const fetchQuestions = async () => { const fetchQuestions = async () => {
try { try {
const { data, error } = await supabase.rpc("get_recommended_questions", { const [{ data, error }, skippedIds] = await Promise.all([
supabase.rpc("get_recommended_questions", {
target_pack_id: packId || null target_pack_id: packId || null
}); }),
getSkippedQuestionIds()
]);
if (error) { if (error) {
console.error("Error fetching recommended questions:", error); console.error("Error fetching recommended questions:", error);
throw error; throw error;
} }
const sorted = (data || []).sort((a: any, b: any) => { // Filter out recently skipped questions
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
const sorted = filtered.sort((a: any, b: any) => {
let scoreA = Math.random(); let scoreA = Math.random();
let scoreB = Math.random(); let scoreB = Math.random();
@@ -54,22 +77,40 @@ export default function SwipeScreen() {
} }
}; };
const handleSwipe = async (direction: "left" | "right" | "up") => { const handleSwipe = async (direction: "left" | "right" | "up" | "down") => {
const question = questions[currentIndex]; const question = questions[currentIndex];
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
setCurrentIndex(prev => prev + 1); setCurrentIndex(prev => prev + 1);
// Handle skip - don't submit a response, just store it for later
if (direction === "down") {
await skipQuestion(question.id);
return;
}
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
try { try {
const { data: { session } } = await supabase.auth.getSession(); const { data: { session } } = await supabase.auth.getSession();
console.log("DEBUG - Session exists:", !!session);
await supabase.functions.invoke("submit-response", { if (!session?.access_token) {
console.error("No valid session - user may need to re-login");
return;
}
const { error } = await supabase.functions.invoke("submit-response", {
body: { body: {
question_id: question.id, question_id: question.id,
answer, answer,
}, },
headers: {
Authorization: `Bearer ${session.access_token}`,
},
}); });
if (error) {
console.error("Submit response error:", error);
}
} catch (error) { } catch (error) {
console.error("Failed to submit response", error); console.error("Failed to submit response", error);
} }
@@ -159,7 +200,14 @@ export default function SwipeScreen() {
styles.progressFill, styles.progressFill,
{ width: `${((currentIndex + 1) / questions.length) * 100}%` } { width: `${((currentIndex + 1) / questions.length) * 100}%` }
]} ]}
>
<LinearGradient
colors={gradients.primary as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.progressGradient}
/> />
</Animated.View>
</View> </View>
</View> </View>
</Animated.View> </Animated.View>
@@ -217,16 +265,20 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm, marginBottom: spacing.sm,
}, },
progressBar: { progressBar: {
width: 120, width: 160,
height: 4, height: 6,
backgroundColor: colors.glass.background, backgroundColor: colors.glass.background,
borderRadius: 2, borderRadius: 3,
overflow: "hidden", overflow: "hidden",
}, },
progressFill: { progressFill: {
height: "100%", height: "100%",
backgroundColor: colors.primary, borderRadius: 3,
borderRadius: 2, overflow: "hidden",
},
progressGradient: {
flex: 1,
borderRadius: 3,
}, },
cardContainer: { cardContainer: {
flex: 1, flex: 1,
+60 -1
View File
@@ -46,6 +46,8 @@ export default function LoginScreen() {
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false); const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null); const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
const [isCheckingVerification, setIsCheckingVerification] = useState(false); const [isCheckingVerification, setIsCheckingVerification] = useState(false);
const [isResendingEmail, setIsResendingEmail] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const [authMode, setAuthMode] = useState<AuthMode>("magic-link"); const [authMode, setAuthMode] = useState<AuthMode>("magic-link");
const [isSignUp, setIsSignUp] = useState(false); const [isSignUp, setIsSignUp] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -63,6 +65,47 @@ export default function LoginScreen() {
} }
}, []); }, []);
// Cooldown timer for resend verification email
useEffect(() => {
if (resendCooldown <= 0) return;
const timer = setInterval(() => {
setResendCooldown((prev) => {
if (prev <= 1) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [resendCooldown]);
// Resend verification email
const resendVerificationEmail = useCallback(async () => {
if (!pendingVerification || resendCooldown > 0) return;
setIsResendingEmail(true);
const { error: resendError } = await supabase.auth.resend({
type: 'signup',
email: pendingVerification.email,
});
setIsResendingEmail(false);
if (resendError) {
if (Platform.OS !== 'web') {
Alert.alert("Error", resendError.message);
}
} else {
// Start 60 second cooldown
setResendCooldown(60);
if (Platform.OS !== 'web') {
Alert.alert("Email Sent", "A new verification email has been sent. Please check your inbox.");
}
}
}, [pendingVerification, resendCooldown]);
// Check if user has verified their email // Check if user has verified their email
const checkVerificationStatus = useCallback(async () => { const checkVerificationStatus = useCallback(async () => {
if (!pendingVerification) return; if (!pendingVerification) return;
@@ -155,7 +198,7 @@ export default function LoginScreen() {
setIsLoading(true); setIsLoading(true);
if (isSignUp) { if (isSignUp) {
const { error: signUpError } = await supabase.auth.signUp({ const { data, error: signUpError } = await supabase.auth.signUp({
email: email.trim(), email: email.trim(),
password, password,
}); });
@@ -164,6 +207,10 @@ export default function LoginScreen() {
if (signUpError) { if (signUpError) {
showError(signUpError.message); showError(signUpError.message);
} else if (data.user && data.user.identities && data.user.identities.length === 0) {
// User already exists - Supabase returns success but empty identities
// for security (prevents email enumeration)
showError("An account with this email already exists. Please sign in instead.");
} else { } else {
// Show the verification pending screen // Show the verification pending screen
setPendingVerification({ setPendingVerification({
@@ -302,12 +349,24 @@ export default function LoginScreen() {
> >
I've verified my email I've verified my email
</GlassButton> </GlassButton>
<GlassButton
variant="secondary"
onPress={resendVerificationEmail}
loading={isResendingEmail}
disabled={isResendingEmail || resendCooldown > 0}
style={{ marginTop: spacing.sm }}
>
{resendCooldown > 0
? `Resend email (${resendCooldown}s)`
: "Resend verification email"}
</GlassButton>
<GlassButton <GlassButton
variant="ghost" variant="ghost"
onPress={() => { onPress={() => {
setPendingVerification(null); setPendingVerification(null);
setEmail(pendingVerification.email); setEmail(pendingVerification.email);
setPassword(""); setPassword("");
setResendCooldown(0);
}} }}
style={{ marginTop: spacing.sm }} style={{ marginTop: spacing.sm }}
> >
+24 -14
View File
@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 46; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -11,6 +11,7 @@
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 */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
46CB71552F030871008AC4FC /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46CB71542F030871008AC4FC /* StoreKit.framework */; };
96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */; }; 96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
B6ADEA6ED390C90F199467A5 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1C9767CAD348ACB05A4870BE /* PrivacyInfo.xcprivacy */; }; B6ADEA6ED390C90F199467A5 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1C9767CAD348ACB05A4870BE /* PrivacyInfo.xcprivacy */; };
@@ -25,8 +26,9 @@
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>"; };
1C9767CAD348ACB05A4870BE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; 1C9767CAD348ACB05A4870BE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
1E5EDAB25D1C4414926F678F /* 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>"; }; 1E5EDAB25D1C4414926F678F /* 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>"; };
46CB71542F030871008AC4FC /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Sauci.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Sauci.a"; sourceTree = BUILT_PRODUCTS_DIR; };
63D24AC64FB84C3AB9F4302E /* 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>"; }; 63D24AC64FB84C3AB9F4302E /* 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>"; };
@@ -43,6 +45,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */, 96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */,
46CB71552F030871008AC4FC /* StoreKit.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -69,6 +72,7 @@
2D16E6871FA4F8E400B85C8A /* Frameworks */ = { 2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
46CB71542F030871008AC4FC /* StoreKit.framework */,
ED297162215061F000B7C4FE /* JavaScriptCore.framework */, ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */, 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */,
); );
@@ -382,7 +386,10 @@
); );
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
@@ -413,7 +420,10 @@
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
@@ -478,14 +488,14 @@
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = "$(inherited) ";
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -534,13 +544,13 @@
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = "$(inherited) ";
"$(inherited)",
" ",
);
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos; SDKROOT = iphoneos;
USE_HERMES = true; USE_HERMES = true;
+375
View File
@@ -0,0 +1,375 @@
import React, { useEffect, useState } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
ActivityIndicator,
ScrollView,
Platform,
Pressable,
} from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { supabase } from "../lib/supabase";
import { colors, gradients, spacing, radius, typography } from "../theme";
interface PackTeaserProps {
visible: boolean;
packId: string;
packName: string;
packIcon: string | null;
onClose: () => void;
onUnlock: () => void;
}
interface PreviewQuestion {
id: string;
text: string;
intensity: number;
}
const TEASER_QUESTION_COUNT = 3;
export function PackTeaser({
visible,
packId,
packName,
packIcon,
onClose,
onUnlock,
}: PackTeaserProps) {
const [questions, setQuestions] = useState<PreviewQuestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const overlayOpacity = useSharedValue(0);
const contentTranslateY = useSharedValue(300);
const overlayStyle = useAnimatedStyle(() => ({
opacity: overlayOpacity.value,
}));
const contentStyle = useAnimatedStyle(() => ({
transform: [{ translateY: contentTranslateY.value }],
}));
useEffect(() => {
if (visible) {
setModalVisible(true);
overlayOpacity.value = withTiming(1, { duration: 250 });
contentTranslateY.value = withTiming(0, { duration: 300 });
} else {
overlayOpacity.value = withTiming(0, { duration: 200 });
contentTranslateY.value = withTiming(300, { duration: 250 }, () => {
runOnJS(setModalVisible)(false);
});
}
}, [visible]);
useEffect(() => {
if (visible && packId) {
fetchQuestions();
}
}, [visible, packId]);
const fetchQuestions = async () => {
setIsLoading(true);
try {
const { data, error } = await supabase.rpc("get_pack_teaser_questions", {
target_pack_id: packId,
});
if (error) {
console.error("Error fetching teaser questions:", error);
return;
}
// Take only the first 3 questions for the teaser
const previewQuestions = (data || [])
.slice(0, TEASER_QUESTION_COUNT)
.map((q: any) => ({
id: q.id,
text: q.text,
intensity: q.intensity,
}));
setQuestions(previewQuestions);
} catch (error) {
console.error("Failed to fetch teaser questions:", error);
} finally {
setIsLoading(false);
}
};
return (
<Modal
visible={modalVisible}
transparent
animationType="none"
onRequestClose={onClose}
>
<View style={styles.container}>
<Animated.View style={[styles.overlay, overlayStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.content, contentStyle]}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{/* Close Button */}
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
{/* Header */}
<View style={styles.header}>
<View style={styles.iconContainer}>
<Text style={styles.packEmoji}>{packIcon || "📦"}</Text>
</View>
<Text style={styles.title}>{packName}</Text>
<Text style={styles.subtitle}>
Preview a few questions from this pack
</Text>
</View>
{/* Questions Preview */}
<View style={styles.questionsContainer}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} size="large" />
<Text style={styles.loadingText}>
Loading preview...
</Text>
</View>
) : questions.length > 0 ? (
questions.map((question, index) => (
<View key={question.id} style={styles.questionCard}>
<View style={styles.questionHeader}>
<View style={styles.intensityContainer}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons
key={i}
name="flame"
size={12}
color={colors.primary}
/>
))}
</View>
<Text style={styles.questionNumber}>
{index + 1} of {questions.length}
</Text>
</View>
<Text style={styles.questionText}>{question.text}</Text>
</View>
))
) : (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
No preview available
</Text>
</View>
)}
</View>
{/* More questions hint */}
{questions.length > 0 && (
<View style={styles.moreHint}>
<Ionicons
name="lock-closed"
size={16}
color={colors.textTertiary}
/>
<Text style={styles.moreHintText}>
+ many more questions to unlock
</Text>
</View>
)}
{/* Unlock Button */}
<View style={styles.actions}>
<TouchableOpacity
style={styles.unlockButton}
onPress={onUnlock}
activeOpacity={0.8}
>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.unlockButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Ionicons
name="star"
size={20}
color={colors.text}
style={{ marginRight: spacing.sm }}
/>
<Text style={styles.unlockButtonText}>
Unlock with Pro
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</ScrollView>
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "flex-end",
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.8)",
},
content: {
backgroundColor: colors.backgroundLight,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
maxHeight: "85%",
borderWidth: 1,
borderBottomWidth: 0,
borderColor: colors.glass.border,
},
scrollContent: {
padding: spacing.lg,
paddingTop: spacing.xl,
paddingBottom: Platform.OS === "ios" ? 40 : spacing.lg,
},
closeButton: {
position: "absolute",
top: spacing.md,
right: spacing.md,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
},
header: {
alignItems: "center",
marginBottom: spacing.xl,
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
},
packEmoji: {
fontSize: 36,
},
title: {
...typography.title1,
color: colors.text,
marginBottom: spacing.xs,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
questionsContainer: {
marginBottom: spacing.lg,
gap: spacing.md,
},
loadingContainer: {
alignItems: "center",
padding: spacing.xl,
},
loadingText: {
...typography.subhead,
color: colors.textSecondary,
marginTop: spacing.md,
},
questionCard: {
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.glass.border,
padding: spacing.md,
},
questionHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.sm,
},
intensityContainer: {
flexDirection: "row",
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
gap: 1,
},
questionNumber: {
...typography.caption2,
color: colors.textTertiary,
},
questionText: {
...typography.body,
color: colors.text,
lineHeight: 22,
},
emptyContainer: {
alignItems: "center",
padding: spacing.xl,
},
emptyText: {
...typography.subhead,
color: colors.textSecondary,
},
moreHint: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
gap: spacing.xs,
},
moreHintText: {
...typography.caption1,
color: colors.textTertiary,
},
actions: {
marginBottom: spacing.md,
},
unlockButton: {
borderRadius: radius.lg,
overflow: "hidden",
},
unlockButtonGradient: {
flexDirection: "row",
paddingVertical: spacing.md,
alignItems: "center",
justifyContent: "center",
},
unlockButtonText: {
...typography.headline,
color: colors.text,
},
});
export default PackTeaser;
+5
View File
@@ -235,6 +235,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
<Text style={styles.errorText}> <Text style={styles.errorText}>
Unable to load subscription options. Unable to load subscription options.
</Text> </Text>
{error && (
<Text style={[styles.errorText, { fontSize: 12, marginTop: 4 }]}>
{error}
</Text>
)}
<TouchableOpacity <TouchableOpacity
style={styles.retryButton} style={styles.retryButton}
onPress={fetchOfferings} onPress={fetchOfferings}
@@ -34,7 +34,7 @@ const SWIPE_THRESHOLD = 100;
interface Props { interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean }; question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean };
onSwipe: (direction: "left" | "right" | "up") => void; onSwipe: (direction: "left" | "right" | "up" | "down") => void;
} }
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView); const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
@@ -83,6 +83,10 @@ export default function SwipeCard({ question, onSwipe }: Props) {
runOnJS(triggerHaptic)('medium'); runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle); translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("up"); runOnJS(onSwipe)("up");
} else if (translateY.value > SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('light');
translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("down");
} else { } else {
translateX.value = withSpring(0, animations.spring); translateX.value = withSpring(0, animations.spring);
translateY.value = withSpring(0, animations.spring); translateY.value = withSpring(0, animations.spring);
@@ -99,7 +103,7 @@ export default function SwipeCard({ question, onSwipe }: Props) {
], ],
})); }));
const overlayStyle = (direction: "left" | "right" | "up") => const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => { useAnimatedStyle(() => {
let opacity = 0; let opacity = 0;
let backgroundColor = 'transparent'; let backgroundColor = 'transparent';
@@ -118,13 +122,20 @@ export default function SwipeCard({ question, onSwipe }: Props) {
[0, 0.9], [0, 0.9],
['transparent', 'rgba(231, 76, 60, 0.4)'] ['transparent', 'rgba(231, 76, 60, 0.4)']
) as string; ) as string;
} else { } else if (direction === "up") {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 0.9]); opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor( backgroundColor = interpolateColor(
opacity, opacity,
[0, 0.9], [0, 0.9],
['transparent', 'rgba(243, 156, 18, 0.4)'] ['transparent', 'rgba(243, 156, 18, 0.4)']
) as string; ) as string;
} else {
opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(108, 117, 125, 0.4)']
) as string;
} }
return { return {
@@ -193,7 +204,7 @@ function CardContent({
onFeedbackPress, onFeedbackPress,
}: { }: {
question: Props['question']; question: Props['question'];
overlayStyle: (direction: "left" | "right" | "up") => any; overlayStyle: (direction: "left" | "right" | "up" | "down") => any;
handleButtonPress: (direction: "left" | "right" | "up") => void; handleButtonPress: (direction: "left" | "right" | "up") => void;
onFeedbackPress: () => void; onFeedbackPress: () => void;
}) { }) {
@@ -234,6 +245,11 @@ function CardContent({
<Text style={styles.overlayText}>MAYBE</Text> <Text style={styles.overlayText}>MAYBE</Text>
</View> </View>
</Animated.View> </Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("down")]} pointerEvents="none">
<View style={[styles.overlayBadge, { backgroundColor: colors.textTertiary }]}>
<Text style={styles.overlayText}>SKIP</Text>
</View>
</Animated.View>
{/* Content */} {/* Content */}
<View style={styles.content}> <View style={styles.content}>
+24 -1
View File
@@ -8,7 +8,7 @@ const SWIPE_THRESHOLD = 100;
interface Props { interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null }; question: { id: string; text: string; intensity: number; partner_text?: string | null };
onSwipe: (direction: "left" | "right" | "up") => void; onSwipe: (direction: "left" | "right" | "up" | "down") => void;
} }
/** /**
@@ -51,6 +51,12 @@ export default function SwipeCard({ question, onSwipe }: Props) {
extrapolate: "clamp", extrapolate: "clamp",
}); });
const downOverlayOpacity = translateY.interpolate({
inputRange: [0, SWIPE_THRESHOLD],
outputRange: [0, 1],
extrapolate: "clamp",
});
const panResponder = useRef( const panResponder = useRef(
PanResponder.create({ PanResponder.create({
onStartShouldSetPanResponder: (_, gestureState) => { onStartShouldSetPanResponder: (_, gestureState) => {
@@ -104,6 +110,15 @@ export default function SwipeCard({ question, onSwipe }: Props) {
setDismissed(true); setDismissed(true);
onSwipe("up"); onSwipe("up");
}); });
} else if (gestureState.dy > SWIPE_THRESHOLD) {
// Vertical swipe down - skip
Animated.spring(translateY, {
toValue: screenWidth * 1.5,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe("down");
});
} else { } else {
// Reset position // Reset position
Animated.parallel([ Animated.parallel([
@@ -176,6 +191,14 @@ export default function SwipeCard({ question, onSwipe }: Props) {
> >
<Text style={styles.overlayText}>MAYBE</Text> <Text style={styles.overlayText}>MAYBE</Text>
</Animated.View> </Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: downOverlayOpacity, backgroundColor: "rgba(108, 117, 125, 0.4)" },
]}
>
<Text style={styles.overlayText}>SKIP</Text>
</Animated.View>
<View style={styles.content}> <View style={styles.content}>
<View style={styles.intensityContainer}> <View style={styles.intensityContainer}>
+39 -13
View File
@@ -73,18 +73,21 @@ class RevenueCatService {
return; return;
} }
const available = await this.loadPurchases(); // Skip if API key is missing or placeholder
if (!available) return; if (!REVENUECAT_IOS_API_KEY || REVENUECAT_IOS_API_KEY === "your_revenuecat_ios_api_key") {
console.log("RevenueCat: Skipping - no valid API key configured");
if (!REVENUECAT_IOS_API_KEY) {
console.warn("RevenueCat: Missing iOS API key");
return; return;
} }
const available = await this.loadPurchases();
if (!available) return;
try { try {
if (__DEV__) { // Always enable debug logging for now to diagnose issues
this.Purchases.setLogLevel(this.Purchases.LOG_LEVEL?.DEBUG || 4); this.Purchases.setLogLevel(this.Purchases.LOG_LEVEL?.DEBUG || 4);
}
const keyPrefix = REVENUECAT_IOS_API_KEY.substring(0, 10);
console.log("RevenueCat: Configuring with API key prefix:", keyPrefix + "...");
await this.Purchases.configure({ await this.Purchases.configure({
apiKey: REVENUECAT_IOS_API_KEY, apiKey: REVENUECAT_IOS_API_KEY,
@@ -92,9 +95,10 @@ class RevenueCatService {
}); });
this.initialized = true; this.initialized = true;
console.log("RevenueCat: Initialized successfully"); console.log("RevenueCat: Initialized successfully with user:", userId || "anonymous");
} catch (error) { } catch (error) {
console.error("RevenueCat: Failed to initialize:", error); console.error("RevenueCat: Failed to initialize:", error);
// Don't rethrow - allow app to continue without subscriptions
} }
} }
@@ -129,16 +133,38 @@ class RevenueCatService {
} }
async getOfferings(): Promise<PurchasesOffering | null> { async getOfferings(): Promise<PurchasesOffering | null> {
const result = await this.getOfferingsDebug();
return result.current;
}
async getOfferingsDebug(): Promise<{
current: PurchasesOffering | null;
availableOfferings: string[] | null;
error: string | null;
}> {
if (!this.initialized || !this.Purchases) { if (!this.initialized || !this.Purchases) {
return null; return {
current: null,
availableOfferings: null,
error: "RevenueCat not initialized"
};
} }
try { try {
const offerings = await this.Purchases.getOfferings(); const offerings = await this.Purchases.getOfferings();
return offerings.current; const availableOfferings = Object.keys(offerings.all || {});
} catch (error) {
console.error("RevenueCat: Error fetching offerings:", error); return {
return null; current: offerings.current,
availableOfferings,
error: null
};
} catch (error: any) {
return {
current: null,
availableOfferings: null,
error: error.message || "Failed to fetch offerings"
};
} }
} }
+75
View File
@@ -0,0 +1,75 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const STORAGE_KEY = "skipped_questions";
const SKIP_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
interface SkippedQuestion {
id: string;
skippedAt: number;
}
async function getStorage(): Promise<SkippedQuestion[]> {
try {
let data: string | null = null;
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(STORAGE_KEY);
}
} else {
data = await SecureStore.getItemAsync(STORAGE_KEY);
}
if (data) {
return JSON.parse(data);
}
} catch (error) {
console.error("Error reading skipped questions:", error);
}
return [];
}
async function setStorage(questions: SkippedQuestion[]): Promise<void> {
try {
const data = JSON.stringify(questions);
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, data);
}
} else {
await SecureStore.setItemAsync(STORAGE_KEY, data);
}
} catch (error) {
console.error("Error saving skipped questions:", error);
}
}
export async function skipQuestion(questionId: string): Promise<void> {
const skipped = await getStorage();
const now = Date.now();
// Remove old entries (older than SKIP_DURATION_MS)
const filtered = skipped.filter(q => now - q.skippedAt < SKIP_DURATION_MS);
// Add new skipped question (or update timestamp if already exists)
const existingIndex = filtered.findIndex(q => q.id === questionId);
if (existingIndex >= 0) {
filtered[existingIndex].skippedAt = now;
} else {
filtered.push({ id: questionId, skippedAt: now });
}
await setStorage(filtered);
}
export async function getSkippedQuestionIds(): Promise<Set<string>> {
const skipped = await getStorage();
const now = Date.now();
// Return only question IDs that are still within the skip duration
const validSkipped = skipped.filter(q => now - q.skippedAt < SKIP_DURATION_MS);
return new Set(validSkipped.map(q => q.id));
}
+55 -5
View File
@@ -256,6 +256,9 @@ export const usePacksStore = create<PacksState>((set, get) => ({
fetchPacks: async () => { fetchPacks: async () => {
set({ isLoading: true }); set({ isLoading: true });
// Get user's explicit content preference
const showExplicit = useAuthStore.getState().user?.show_explicit_content ?? false;
// Fetch categories // Fetch categories
const { data: categories } = await supabase const { data: categories } = await supabase
.from("categories") .from("categories")
@@ -263,12 +266,19 @@ export const usePacksStore = create<PacksState>((set, get) => ({
.order("sort_order"); .order("sort_order");
// Fetch packs with category info and question count // Fetch packs with category info and question count
const { data: packs } = await supabase let query = supabase
.from("question_packs") .from("question_packs")
.select("*, category:categories(*), questions(count)") .select("*, category:categories(*), questions(count)")
.eq("is_public", true) .eq("is_public", true)
.order("sort_order"); .order("sort_order");
// Filter out explicit packs if user doesn't want to see them
if (!showExplicit) {
query = query.eq("is_explicit", false);
}
const { data: packs } = await query;
// Also fetch enabled packs if logged in // Also fetch enabled packs if logged in
await get().fetchEnabledPacks(); await get().fetchEnabledPacks();
@@ -481,11 +491,51 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
fetchOfferings: async () => { fetchOfferings: async () => {
set({ isLoadingOfferings: true, error: null }); set({ isLoadingOfferings: true, error: null });
try { try {
const offerings = await revenueCatService.getOfferings(); // Check if RevenueCat is initialized
set({ offerings, isLoadingOfferings: false }); if (!revenueCatService.isInitialized()) {
} catch (error) { set({
offerings: null,
isLoadingOfferings: false,
error: "Subscriptions not available"
});
return;
}
const result = await revenueCatService.getOfferingsDebug();
if (result.error) {
set({
offerings: null,
isLoadingOfferings: false,
error: result.error
});
return;
}
const offerings = result.current;
if (!offerings) {
set({
offerings: null,
isLoadingOfferings: false,
error: `No current offering. Available: ${result.availableOfferings?.join(", ") || "none"}`
});
return;
}
if (!offerings.availablePackages || offerings.availablePackages.length === 0) {
set({
offerings: null,
isLoadingOfferings: false,
error: "No packages in offering. Check RevenueCat dashboard."
});
return;
}
set({ offerings, isLoadingOfferings: false, error: null });
} catch (error: any) {
console.error("Error fetching offerings:", error); console.error("Error fetching offerings:", error);
set({ error: "Failed to load subscription options", isLoadingOfferings: false }); set({ error: `Error: ${error.message || "Unknown"}`, isLoadingOfferings: false });
} }
}, },
+8 -2
View File
@@ -21,6 +21,10 @@ export const colors = {
primaryDark: '#c73a52', primaryDark: '#c73a52',
primaryLight: 'rgba(233, 69, 96, 0.15)', primaryLight: 'rgba(233, 69, 96, 0.15)',
primaryGlow: 'rgba(233, 69, 96, 0.3)', primaryGlow: 'rgba(233, 69, 96, 0.3)',
secondary: '#9b59b6',
secondaryDark: '#8e44ad',
secondaryLight: 'rgba(155, 89, 182, 0.15)',
secondaryGlow: 'rgba(155, 89, 182, 0.3)',
// Text // Text
text: '#ffffff', text: '#ffffff',
@@ -37,8 +41,10 @@ export const colors = {
}; };
export const gradients = { export const gradients = {
primary: ['#e94560', '#c73a52'], primary: ['#e94560', '#9b59b6'],
primarySubtle: ['rgba(233, 69, 96, 0.8)', 'rgba(199, 58, 82, 0.8)'], primarySubtle: ['rgba(233, 69, 96, 0.8)', 'rgba(155, 89, 182, 0.8)'],
primaryReverse: ['#9b59b6', '#e94560'],
primaryVertical: ['#e94560', '#9b59b6'],
background: ['#1a1a2e', '#0d0d1a'], background: ['#1a1a2e', '#0d0d1a'],
backgroundReverse: ['#0d0d1a', '#1a1a2e'], backgroundReverse: ['#0d0d1a', '#1a1a2e'],
glass: ['rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0)'], glass: ['rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0)'],
+6
View File
@@ -1,4 +1,6 @@
// User profile (extends Supabase auth.users) // User profile (extends Supabase auth.users)
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
export interface Profile { export interface Profile {
id: string; id: string;
name: string | null; name: string | null;
@@ -7,6 +9,9 @@ export interface Profile {
push_token: string | null; push_token: string | null;
is_premium: boolean; is_premium: boolean;
couple_id: string | null; couple_id: string | null;
gender: Gender | null;
show_explicit_content: boolean;
onboarding_completed: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -36,6 +41,7 @@ export interface QuestionPack {
icon: string | null; icon: string | null;
is_premium: boolean; is_premium: boolean;
is_public: boolean; is_public: boolean;
is_explicit: boolean;
sort_order: number; sort_order: number;
category_id: string | null; category_id: string | null;
category?: Category; category?: Category;
@@ -0,0 +1,94 @@
-- Fix: Filter out premium packs from get_recommended_questions when user lacks premium access
-- This ensures users whose subscriptions have expired don't continue seeing premium questions
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE (
id UUID,
text TEXT,
partner_text TEXT,
is_two_part BOOLEAN,
pack_id UUID,
intensity INT,
partner_answered BOOLEAN
) LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_has_premium BOOLEAN;
BEGIN
v_user_id := auth.uid();
-- Get couple_id
SELECT p.couple_id INTO v_couple_id
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
-- Check if user or partner has premium access
v_has_premium := public.has_premium_access(v_user_id);
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
-- When targeting a specific pack, only include if accessible (not premium OR has premium access)
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM public.question_packs qp
WHERE qp.id = target_pack_id
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
)
UNION ALL
-- When browsing all enabled packs, filter out premium packs if no premium access
SELECT cp.pack_id FROM public.couple_packs cp
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id
AND cp.enabled = TRUE
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
-- Default packs when no config exists (filter premium here too)
SELECT p.id AS pack_id FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
CASE
WHEN q.partner_text IS NOT NULL AND EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) THEN q.partner_text
ELSE q.text
END as text,
q.partner_text,
q.partner_text IS NOT NULL as is_two_part,
q.pack_id,
q.intensity,
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) AS partner_answered
FROM public.questions q
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
AND NOT EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.user_id = v_user_id
);
END;
$$;
@@ -0,0 +1,22 @@
-- Enforce maximum 2 members per couple at the database level
-- This prevents race conditions and direct API manipulation from allowing
-- more than 2 people to join a single couple relationship
CREATE OR REPLACE FUNCTION check_couple_size()
RETURNS TRIGGER AS $$
BEGIN
-- Only check when couple_id is being set (not when clearing it)
IF NEW.couple_id IS NOT NULL THEN
-- Count existing members excluding the current user
IF (SELECT COUNT(*) FROM profiles WHERE couple_id = NEW.couple_id AND id != NEW.id) >= 2 THEN
RAISE EXCEPTION 'A couple can only have 2 members';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger fires before insert or update of couple_id column
CREATE TRIGGER enforce_couple_size
BEFORE INSERT OR UPDATE OF couple_id ON profiles
FOR EACH ROW EXECUTE FUNCTION check_couple_size();