mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 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 { View, ActivityIndicator, StyleSheet, Platform, Modal, Text, Pressable, Animated, AppState } from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore } from "../../src/store";
|
||||
import { colors, blur, radius, spacing, typography, shadows } from "../../src/theme";
|
||||
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store";
|
||||
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import type { MatchWithQuestion } from "../../src/types";
|
||||
import type { Database } from "../../src/types/supabase";
|
||||
@@ -15,31 +16,74 @@ function TabBarBackground() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<BlurView
|
||||
intensity={blur.medium}
|
||||
tint="dark"
|
||||
intensity={blur.heavy}
|
||||
tint="systemChromeMaterialDark"
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
|
||||
const { newMatchesCount, addMatch } = useMatchStore();
|
||||
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||
const { fetchEnabledPacks } = usePacksStore();
|
||||
const { initializeRevenueCat } = useSubscriptionStore();
|
||||
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
|
||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||
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(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -169,6 +213,32 @@ export default function AppLayout() {
|
||||
};
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (lastMessage) {
|
||||
@@ -219,17 +289,8 @@ export default function AppLayout() {
|
||||
}
|
||||
}, [lastMessage, dismissMessageToast, router]);
|
||||
|
||||
// Show loading state while checking authentication
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading while checking auth or redirecting
|
||||
if (!isAuthenticated) {
|
||||
// Show loading state while checking authentication or redirecting to onboarding
|
||||
if (isLoading || !isAuthenticated || (user && !user.onboarding_completed && !isOnOnboarding)) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
@@ -282,10 +343,8 @@ export default function AppLayout() {
|
||||
<Tabs.Screen
|
||||
name="swipe"
|
||||
options={{
|
||||
title: "Play",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="flame" size={size} color={color} />
|
||||
),
|
||||
title: "",
|
||||
tabBarButton: (props) => <PlayTabButton {...props} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
@@ -326,6 +385,13 @@ export default function AppLayout() {
|
||||
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>
|
||||
|
||||
{/* Match Notification Modal */}
|
||||
@@ -498,4 +564,35 @@ const styles = StyleSheet.create({
|
||||
color: colors.textSecondary,
|
||||
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 [partnerTyping, setPartnerTyping] = useState(false);
|
||||
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
|
||||
const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true);
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isFocusedRef = useRef(false);
|
||||
@@ -441,11 +442,16 @@ export default function ChatScreen() {
|
||||
>
|
||||
<Ionicons name="close" size={28} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
{fullScreenImageLoading && (
|
||||
<ActivityIndicator size="large" color={colors.primary} style={styles.fullScreenSpinner} />
|
||||
)}
|
||||
{fullScreenImage && (
|
||||
<Image
|
||||
source={{ uri: fullScreenImage }}
|
||||
style={styles.fullScreenImage}
|
||||
resizeMode="contain"
|
||||
onLoadStart={() => setFullScreenImageLoading(true)}
|
||||
onLoadEnd={() => setFullScreenImageLoading(false)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -944,4 +950,7 @@ const styles = StyleSheet.create({
|
||||
width: Dimensions.get("window").width,
|
||||
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;
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { user, partner } = useAuthStore();
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
||||
const { packs, fetchPacks } = usePacksStore();
|
||||
const { width } = useWindowDimensions();
|
||||
@@ -52,6 +52,14 @@ export default function HomeScreen() {
|
||||
{partner.name || partner.email?.split('@')[0] || 'Partner'}
|
||||
</Text>
|
||||
</View>
|
||||
) : couple ? (
|
||||
<TouchableOpacity
|
||||
style={styles.waitingBadge}
|
||||
onPress={() => router.push("/(app)/pairing")}
|
||||
>
|
||||
<Ionicons name="hourglass-outline" size={12} color={colors.primary} />
|
||||
<Text style={styles.waitingText}>Waiting for partner</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.pairBadge}
|
||||
@@ -184,7 +192,7 @@ export default function HomeScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.ctaIconContainer}>
|
||||
<Ionicons name="play" size={24} color={colors.text} />
|
||||
<Ionicons name="flame" size={24} color={colors.text} />
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
@@ -351,6 +359,22 @@ const styles = StyleSheet.create({
|
||||
...typography.caption1,
|
||||
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: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function MatchesScreen() {
|
||||
start={{ x: 0, y: 0 }}
|
||||
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>
|
||||
</LinearGradient>
|
||||
</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 { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
import { Paywall } from "../../src/components/Paywall";
|
||||
import { PackTeaser } from "../../src/components/PackTeaser";
|
||||
import { colors, spacing, typography, radius } from "../../src/theme";
|
||||
import type { QuestionPack, Category } from "../../src/types";
|
||||
|
||||
@@ -19,6 +20,8 @@ export default function PacksScreen() {
|
||||
const { subscription } = useSubscriptionStore();
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
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
|
||||
const hasPremiumAccess = useMemo(() => {
|
||||
@@ -29,15 +32,28 @@ export default function PacksScreen() {
|
||||
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) => {
|
||||
if (pack.is_premium && !hasPremiumAccess) {
|
||||
setShowPaywall(true);
|
||||
setTeaserPack(pack);
|
||||
setShowTeaser(true);
|
||||
return;
|
||||
}
|
||||
togglePack(pack.id);
|
||||
}, [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 enabledPackCount = enabledPacks.length;
|
||||
const totalQuestions = enabledPacks.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0);
|
||||
@@ -81,7 +97,8 @@ export default function PacksScreen() {
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
if (isPremiumLocked) {
|
||||
setShowPaywall(true);
|
||||
setTeaserPack(item);
|
||||
setShowTeaser(true);
|
||||
} else {
|
||||
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
|
||||
visible={showPaywall}
|
||||
onClose={() => setShowPaywall(false)}
|
||||
|
||||
@@ -165,6 +165,12 @@ export default function PairingScreen() {
|
||||
if (couple && !partner) {
|
||||
return (
|
||||
<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.iconContainer}>
|
||||
<Ionicons name="heart" size={48} color="#e94560" />
|
||||
@@ -188,11 +194,6 @@ export default function PairingScreen() {
|
||||
Waiting for your partner to join...
|
||||
</Text>
|
||||
<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>
|
||||
);
|
||||
@@ -262,6 +263,18 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
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: {
|
||||
justifyContent: "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 { useAuthStore, useMatchStore, useMessageStore, usePacksStore, useSubscriptionStore } from "../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
@@ -14,10 +14,10 @@ import { colors, gradients, spacing, radius, typography, shadows } from "../../s
|
||||
const MAX_CONTENT_WIDTH = 500;
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { user, partner, couple, signOut, fetchCouple } = useAuthStore();
|
||||
const { user, partner, couple, signOut, fetchCouple, fetchUser } = useAuthStore();
|
||||
const { clearMatches } = useMatchStore();
|
||||
const { clearMessages } = useMessageStore();
|
||||
const { clearPacks } = usePacksStore();
|
||||
const { clearPacks, fetchPacks } = usePacksStore();
|
||||
const { subscription, restorePurchases, isPurchasing } = useSubscriptionStore();
|
||||
const { width } = useWindowDimensions();
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
@@ -27,6 +27,8 @@ export default function ProfileScreen() {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showFeedbackModal, setShowFeedbackModal] = 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
|
||||
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 = () => {
|
||||
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} />
|
||||
</TouchableOpacity>
|
||||
</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
|
||||
style={styles.rowContainer}
|
||||
@@ -307,9 +362,43 @@ export default function ProfileScreen() {
|
||||
)}
|
||||
</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 */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
entering={FadeInDown.delay(425).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.sectionTitle}>Account</Text>
|
||||
@@ -332,7 +421,7 @@ export default function ProfileScreen() {
|
||||
|
||||
{/* Support Section */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(450).duration(500)}
|
||||
entering={FadeInDown.delay(475).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.sectionTitle}>Support</Text>
|
||||
@@ -366,7 +455,7 @@ export default function ProfileScreen() {
|
||||
{/* Danger Zone - Only show if in a couple */}
|
||||
{couple && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(550).duration(500)}
|
||||
entering={FadeInDown.delay(525).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
|
||||
@@ -398,7 +487,7 @@ export default function ProfileScreen() {
|
||||
|
||||
{/* Version */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(couple ? 650 : 550).duration(500)}
|
||||
entering={FadeInDown.delay(couple ? 575 : 525).duration(500)}
|
||||
style={styles.versionContainer}
|
||||
>
|
||||
<View style={styles.versionBadge}>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-nativ
|
||||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { usePacksStore } from "../../src/store";
|
||||
import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions";
|
||||
import SwipeCard from "../../src/components/SwipeCard";
|
||||
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() {
|
||||
const { packId } = useLocalSearchParams();
|
||||
@@ -20,18 +22,39 @@ export default function SwipeScreen() {
|
||||
fetchPacks().then(() => fetchQuestions());
|
||||
}, [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 () => {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc("get_recommended_questions", {
|
||||
target_pack_id: packId || null
|
||||
});
|
||||
const [{ data, error }, skippedIds] = await Promise.all([
|
||||
supabase.rpc("get_recommended_questions", {
|
||||
target_pack_id: packId || null
|
||||
}),
|
||||
getSkippedQuestionIds()
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching recommended questions:", 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 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 answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
|
||||
|
||||
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 {
|
||||
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: {
|
||||
question_id: question.id,
|
||||
answer,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Submit response error:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to submit response", error);
|
||||
}
|
||||
@@ -159,7 +200,14 @@ export default function SwipeScreen() {
|
||||
styles.progressFill,
|
||||
{ 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>
|
||||
</Animated.View>
|
||||
@@ -217,16 +265,20 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
progressBar: {
|
||||
width: 120,
|
||||
height: 4,
|
||||
width: 160,
|
||||
height: 6,
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: 2,
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressFill: {
|
||||
height: "100%",
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 2,
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
},
|
||||
progressGradient: {
|
||||
flex: 1,
|
||||
borderRadius: 3,
|
||||
},
|
||||
cardContainer: {
|
||||
flex: 1,
|
||||
|
||||
@@ -46,6 +46,8 @@ export default function LoginScreen() {
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
|
||||
const [isCheckingVerification, setIsCheckingVerification] = useState(false);
|
||||
const [isResendingEmail, setIsResendingEmail] = useState(false);
|
||||
const [resendCooldown, setResendCooldown] = useState(0);
|
||||
const [authMode, setAuthMode] = useState<AuthMode>("magic-link");
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
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
|
||||
const checkVerificationStatus = useCallback(async () => {
|
||||
if (!pendingVerification) return;
|
||||
@@ -155,7 +198,7 @@ export default function LoginScreen() {
|
||||
setIsLoading(true);
|
||||
|
||||
if (isSignUp) {
|
||||
const { error: signUpError } = await supabase.auth.signUp({
|
||||
const { data, error: signUpError } = await supabase.auth.signUp({
|
||||
email: email.trim(),
|
||||
password,
|
||||
});
|
||||
@@ -164,6 +207,10 @@ export default function LoginScreen() {
|
||||
|
||||
if (signUpError) {
|
||||
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 {
|
||||
// Show the verification pending screen
|
||||
setPendingVerification({
|
||||
@@ -302,12 +349,24 @@ export default function LoginScreen() {
|
||||
>
|
||||
I've verified my email
|
||||
</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
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
setPendingVerification(null);
|
||||
setEmail(pendingVerification.email);
|
||||
setPassword("");
|
||||
setResendCooldown(0);
|
||||
}}
|
||||
style={{ marginTop: spacing.sm }}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user