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

No files matched your search

+120 -23
View File
@@ -1,10 +1,11 @@
import { Tabs, useRouter } from "expo-router";
import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { 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',
},
});
+9
View File
@@ -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",
},
});
+26 -2
View File
@@ -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,
+1 -1
View File
@@ -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>
+555
View File
@@ -0,0 +1,555 @@
import React, { useState, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform,
Keyboard,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
FadeOutLeft,
SlideInRight,
SlideOutLeft,
Layout,
} from 'react-native-reanimated';
import { GradientBackground } from '../../src/components/ui/GradientBackground';
import { GlassCard } from '../../src/components/ui/GlassCard';
import { GlassButton } from '../../src/components/ui/GlassButton';
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
import { useAuthStore } from '../../src/store';
import { supabase } from '../../src/lib/supabase';
import type { Gender } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'male', label: 'Male', icon: 'male' },
{ value: 'female', label: 'Female', icon: 'female' },
{ value: 'non-binary', label: 'Non-binary', icon: 'male-female' },
{ value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' },
];
type Stage = 'name' | 'gender' | 'explicit';
export default function OnboardingScreen() {
const router = useRouter();
const { user, fetchUser } = useAuthStore();
const [stage, setStage] = useState<Stage>('name');
const [name, setName] = useState(user?.name || '');
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content || false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<TextInput>(null);
const handleNameSubmit = () => {
if (!name.trim()) {
setError('Please enter your name');
return;
}
Keyboard.dismiss();
setError(null);
setStage('gender');
};
const handleGenderSelect = (selectedGender: Gender) => {
setGender(selectedGender);
setError(null);
setTimeout(() => setStage('explicit'), 300);
};
const handleComplete = async () => {
if (!user?.id) {
setError('Not logged in. Please restart the app.');
return;
}
setIsLoading(true);
setError(null);
try {
const { error: updateError } = await supabase
.from('profiles')
.update({
name: name.trim(),
gender,
show_explicit_content: showExplicit,
onboarding_completed: true,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (updateError) {
throw updateError;
}
await fetchUser();
router.replace('/');
} catch (err: any) {
console.error('Onboarding error:', err);
setError(err.message || 'Something went wrong');
} finally {
setIsLoading(false);
}
};
const renderStage = () => {
switch (stage) {
case 'name':
return (
<Animated.View
key="name"
entering={FadeInUp.duration(500)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
>
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="heart" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>Welcome to Sauci</Text>
<Text style={styles.subtitle}>
Let's get to know you
</Text>
</View>
<GlassCard style={styles.card}>
<Text style={styles.label}>What's your name?</Text>
<View style={styles.inputContainer}>
<Ionicons
name="person-outline"
size={20}
color={colors.textSecondary}
style={styles.inputIcon}
/>
<TextInput
ref={inputRef}
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Enter your name"
placeholderTextColor={colors.textTertiary}
autoCapitalize="words"
autoCorrect={false}
autoFocus
onSubmitEditing={handleNameSubmit}
returnKeyType="next"
/>
</View>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton onPress={handleNameSubmit} fullWidth size="lg">
Continue
</GlassButton>
</View>
</Animated.View>
);
case 'gender':
return (
<Animated.View
key="gender"
entering={SlideInRight.duration(400)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
>
<View style={styles.header}>
<Text style={styles.greeting}>Hey, {name.trim()}!</Text>
<Text style={styles.subtitle}>
This helps us show you the right questions
</Text>
</View>
<GlassCard style={styles.card}>
<Text style={styles.label}>What's your gender?</Text>
<View style={styles.genderGrid}>
{GENDER_OPTIONS.map((option) => (
<Pressable
key={option.value}
style={[
styles.genderOption,
gender === option.value && styles.genderOptionSelected,
]}
onPress={() => handleGenderSelect(option.value)}
>
<Ionicons
name={option.icon as any}
size={28}
color={gender === option.value ? colors.primary : colors.textSecondary}
/>
<Text
style={[
styles.genderLabel,
gender === option.value && styles.genderLabelSelected,
]}
>
{option.label}
</Text>
</Pressable>
))}
</View>
</GlassCard>
</Animated.View>
);
case 'explicit':
return (
<Animated.View
key="explicit"
entering={SlideInRight.duration(400)}
exiting={FadeOut.duration(200)}
style={styles.stageContainer}
>
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="flame" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>Almost there!</Text>
<Text style={styles.subtitle}>
Shall we keep it clean or show you everything?
</Text>
</View>
<GlassCard style={styles.card}>
<Pressable
style={styles.explicitOption}
onPress={() => setShowExplicit(true)}
>
<View style={[
styles.explicitIcon,
showExplicit && styles.explicitIconSelected
]}>
<Ionicons
name="flame"
size={32}
color={showExplicit ? colors.primary : colors.textSecondary}
/>
</View>
<View style={styles.explicitText}>
<Text style={[
styles.explicitTitle,
showExplicit && styles.explicitTitleSelected
]}>
Show me everything
</Text>
<Text style={styles.explicitDescription}>
Include 18+ question packs
</Text>
</View>
<View style={[
styles.radioOuter,
showExplicit && styles.radioOuterSelected
]}>
{showExplicit && <View style={styles.radioInner} />}
</View>
</Pressable>
<Pressable
style={styles.explicitOption}
onPress={() => setShowExplicit(false)}
>
<View style={[
styles.explicitIcon,
!showExplicit && styles.explicitIconSelected
]}>
<Ionicons
name="heart"
size={32}
color={!showExplicit ? colors.primary : colors.textSecondary}
/>
</View>
<View style={styles.explicitText}>
<Text style={[
styles.explicitTitle,
!showExplicit && styles.explicitTitleSelected
]}>
Keep it clean
</Text>
<Text style={styles.explicitDescription}>
Only show SFW content
</Text>
</View>
<View style={[
styles.radioOuter,
!showExplicit && styles.radioOuterSelected
]}>
{!showExplicit && <View style={styles.radioInner} />}
</View>
</Pressable>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton
onPress={handleComplete}
loading={isLoading}
fullWidth
size="lg"
>
Let's Go!
</GlassButton>
</View>
</Animated.View>
);
}
};
return (
<GradientBackground>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
{/* Header with back button and progress dots */}
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
{stage !== 'name' ? (
<Pressable
style={styles.backButton}
onPress={() => setStage(stage === 'explicit' ? 'gender' : 'name')}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
) : (
<View style={styles.backButtonPlaceholder} />
)}
<View style={styles.progressContainer}>
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'explicit' && styles.progressDotActive]} />
</View>
<View style={styles.backButtonPlaceholder} />
</Animated.View>
{renderStage()}
</KeyboardAvoidingView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
headerBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingBottom: spacing.md,
paddingHorizontal: spacing.md,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.glass.background,
alignItems: 'center',
justifyContent: 'center',
},
backButtonPlaceholder: {
width: 40,
},
progressContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.sm,
},
progressDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.glass.border,
},
progressDotActive: {
backgroundColor: colors.primary,
width: 24,
},
stageContainer: {
flex: 1,
padding: spacing.lg,
paddingBottom: 40,
},
header: {
alignItems: 'center',
marginBottom: spacing.xl,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
...shadows.lg,
},
title: {
...typography.largeTitle,
color: colors.text,
marginBottom: spacing.sm,
textAlign: 'center',
},
greeting: {
...typography.largeTitle,
color: colors.primary,
marginBottom: spacing.sm,
textAlign: 'center',
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
},
card: {
marginBottom: spacing.lg,
},
label: {
...typography.headline,
color: colors.text,
marginBottom: spacing.md,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
paddingHorizontal: spacing.md,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
...typography.body,
color: colors.text,
paddingVertical: spacing.md,
},
genderGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
},
genderOption: {
width: '48%',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.md,
gap: spacing.sm,
},
genderOptionSelected: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
genderLabel: {
...typography.subhead,
color: colors.textSecondary,
textAlign: 'center',
},
genderLabelSelected: {
color: colors.primary,
fontWeight: '600',
},
explicitOption: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
padding: spacing.md,
marginBottom: spacing.sm,
},
explicitIcon: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.glass.background,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
explicitIconSelected: {
backgroundColor: colors.primaryLight,
},
explicitText: {
flex: 1,
},
explicitTitle: {
...typography.headline,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
explicitTitleSelected: {
color: colors.text,
},
explicitDescription: {
...typography.caption1,
color: colors.textTertiary,
},
radioOuter: {
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: colors.glass.border,
alignItems: 'center',
justifyContent: 'center',
},
radioOuterSelected: {
borderColor: colors.primary,
},
radioInner: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: colors.primary,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(231, 76, 60, 0.1)',
borderRadius: radius.md,
padding: spacing.md,
gap: spacing.sm,
marginTop: spacing.sm,
},
errorText: {
...typography.caption1,
color: colors.error,
flex: 1,
},
footer: {
marginTop: 'auto',
},
});
+31 -3
View File
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useState, useCallback } from "react";
import Animated, { FadeInDown, FadeInRight, FadeIn, FadeOut, Layout } from "react-native-reanimated";
import { 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)}
+18 -5
View File
@@ -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",
+96 -7
View File
@@ -1,4 +1,4 @@
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform, useWindowDimensions, TextInput, Modal, ActivityIndicator, Linking } from "react-native";
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform, useWindowDimensions, TextInput, Modal, ActivityIndicator, Linking, Switch } from "react-native";
import { useState, useMemo } from "react";
import { 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}>
+67 -15
View File
@@ -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,
+60 -1
View File
@@ -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 }}
>