mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
1 parent
d1ca114285
commit
8acc43981e
22 files changed
+1731
-97
No files matched your search
@@ -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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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([
|
||||||
target_pack_id: packId || null
|
supabase.rpc("get_recommended_questions", {
|
||||||
});
|
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,
|
||||||
|
|||||||
@@ -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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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)'],
|
||||||
|
|||||||
@@ -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();
|
||||||
Reference in new issue
Block a user