diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx
index e0d2e54..a9eabd5 100755
--- a/apps/mobile/app/(app)/_layout.tsx
+++ b/apps/mobile/app/(app)/_layout.tsx
@@ -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 (
);
}
- return ;
+ return ;
+}
+
+interface PlayTabButtonProps {
+ children?: React.ReactNode;
+ onPress?: (e: any) => void;
+ accessibilityState?: { selected?: boolean };
+}
+
+function PlayTabButton({ onPress, accessibilityState }: PlayTabButtonProps) {
+ const isSelected = accessibilityState?.selected;
+
+ return (
+
+ {/* Outer glow effect */}
+
+
+ {/* Circle container */}
+
+
+
+
+
+
+ );
}
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(null);
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef(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 (
-
-
-
- );
- }
-
- // 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 (
@@ -282,10 +343,8 @@ export default function AppLayout() {
(
-
- ),
+ title: "",
+ tabBarButton: (props) => ,
}}
/>
+
{/* 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',
+ },
});
diff --git a/apps/mobile/app/(app)/chat/[id].tsx b/apps/mobile/app/(app)/chat/[id].tsx
index 9365e6b..4db1e67 100644
--- a/apps/mobile/app/(app)/chat/[id].tsx
+++ b/apps/mobile/app/(app)/chat/[id].tsx
@@ -30,6 +30,7 @@ export default function ChatScreen() {
const [uploading, setUploading] = useState(false);
const [partnerTyping, setPartnerTyping] = useState(false);
const [fullScreenImage, setFullScreenImage] = useState(null);
+ const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true);
const flatListRef = useRef(null);
const typingTimeoutRef = useRef(null);
const isFocusedRef = useRef(false);
@@ -441,11 +442,16 @@ export default function ChatScreen() {
>
+ {fullScreenImageLoading && (
+
+ )}
{fullScreenImage && (
setFullScreenImageLoading(true)}
+ onLoadEnd={() => setFullScreenImageLoading(false)}
/>
)}
@@ -944,4 +950,7 @@ const styles = StyleSheet.create({
width: Dimensions.get("window").width,
height: Dimensions.get("window").height * 0.8,
},
+ fullScreenSpinner: {
+ position: "absolute",
+ },
});
diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx
index 5ce3d05..0b63677 100755
--- a/apps/mobile/app/(app)/index.tsx
+++ b/apps/mobile/app/(app)/index.tsx
@@ -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'}
+ ) : couple ? (
+ router.push("/(app)/pairing")}
+ >
+
+ Waiting for partner
+
) : (
-
+
@@ -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,
diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx
index 9799259..43cac2b 100755
--- a/apps/mobile/app/(app)/matches.tsx
+++ b/apps/mobile/app/(app)/matches.tsx
@@ -179,7 +179,7 @@ export default function MatchesScreen() {
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
-
+
Start Swiping
diff --git a/apps/mobile/app/(app)/onboarding.tsx b/apps/mobile/app/(app)/onboarding.tsx
new file mode 100644
index 0000000..6bcc125
--- /dev/null
+++ b/apps/mobile/app/(app)/onboarding.tsx
@@ -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('name');
+ const [name, setName] = useState(user?.name || '');
+ const [gender, setGender] = useState(user?.gender || null);
+ const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content || false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const inputRef = useRef(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 (
+
+
+
+
+
+ Welcome to Sauci
+
+ Let's get to know you
+
+
+
+
+ What's your name?
+
+
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+
+ Continue
+
+
+
+ );
+
+ case 'gender':
+ return (
+
+
+ Hey, {name.trim()}!
+
+ This helps us show you the right questions
+
+
+
+
+ What's your gender?
+
+ {GENDER_OPTIONS.map((option) => (
+ handleGenderSelect(option.value)}
+ >
+
+
+ {option.label}
+
+
+ ))}
+
+
+
+ );
+
+ case 'explicit':
+ return (
+
+
+
+
+
+ Almost there!
+
+ Shall we keep it clean or show you everything?
+
+
+
+
+ setShowExplicit(true)}
+ >
+
+
+
+
+
+ Show me everything
+
+
+ Include 18+ question packs
+
+
+
+ {showExplicit && }
+
+
+
+ setShowExplicit(false)}
+ >
+
+
+
+
+
+ Keep it clean
+
+
+ Only show SFW content
+
+
+
+ {!showExplicit && }
+
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+
+ Let's Go!
+
+
+
+ );
+ }
+ };
+
+ return (
+
+
+ {/* Header with back button and progress dots */}
+
+ {stage !== 'name' ? (
+ setStage(stage === 'explicit' ? 'gender' : 'name')}
+ >
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {renderStage()}
+
+
+ );
+}
+
+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',
+ },
+});
diff --git a/apps/mobile/app/(app)/packs.tsx b/apps/mobile/app/(app)/packs.tsx
index 705511d..4538b55 100755
--- a/apps/mobile/app/(app)/packs.tsx
+++ b/apps/mobile/app/(app)/packs.tsx
@@ -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>(new Set());
const [showPaywall, setShowPaywall] = useState(false);
+ const [showTeaser, setShowTeaser] = useState(false);
+ const [teaserPack, setTeaserPack] = useState(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 && (
+
+ )}
+
setShowPaywall(false)}
diff --git a/apps/mobile/app/(app)/pairing.tsx b/apps/mobile/app/(app)/pairing.tsx
index 2103dcc..916a616 100755
--- a/apps/mobile/app/(app)/pairing.tsx
+++ b/apps/mobile/app/(app)/pairing.tsx
@@ -165,6 +165,12 @@ export default function PairingScreen() {
if (couple && !partner) {
return (
+ router.back()}
+ >
+
+
@@ -188,11 +194,6 @@ export default function PairingScreen() {
Waiting for your partner to join...
-
-
-
- Sign Out
-
);
@@ -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",
diff --git a/apps/mobile/app/(app)/profile.tsx b/apps/mobile/app/(app)/profile.tsx
index 988c4bb..49ed088 100755
--- a/apps/mobile/app/(app)/profile.tsx
+++ b/apps/mobile/app/(app)/profile.tsx
@@ -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() {
+ ) : couple ? (
+ router.push("/(app)/pairing")}
+ activeOpacity={0.7}
+ >
+
+
+
+
+
+ Waiting for partner
+ Tap to view invite code
+
+
+
+
+
+
) : (
+ {/* Preferences Section */}
+
+ Preferences
+
+
+
+
+
+
+
+ Explicit Content
+ Show 18+ question packs
+
+
+
+
+
+
+
{/* Account Section */}
Account
@@ -332,7 +421,7 @@ export default function ProfileScreen() {
{/* Support Section */}
Support
@@ -366,7 +455,7 @@ export default function ProfileScreen() {
{/* Danger Zone - Only show if in a couple */}
{couple && (
Danger Zone
@@ -398,7 +487,7 @@ export default function ProfileScreen() {
{/* Version */}
diff --git a/apps/mobile/app/(app)/swipe.tsx b/apps/mobile/app/(app)/swipe.tsx
index 984357d..8ffa613 100755
--- a/apps/mobile/app/(app)/swipe.tsx
+++ b/apps/mobile/app/(app)/swipe.tsx
@@ -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}%` }
]}
- />
+ >
+
+
@@ -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,
diff --git a/apps/mobile/app/(auth)/login.tsx b/apps/mobile/app/(auth)/login.tsx
index c1aa489..c40c352 100755
--- a/apps/mobile/app/(auth)/login.tsx
+++ b/apps/mobile/app/(auth)/login.tsx
@@ -46,6 +46,8 @@ export default function LoginScreen() {
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [pendingVerification, setPendingVerification] = useState(null);
const [isCheckingVerification, setIsCheckingVerification] = useState(false);
+ const [isResendingEmail, setIsResendingEmail] = useState(false);
+ const [resendCooldown, setResendCooldown] = useState(0);
const [authMode, setAuthMode] = useState("magic-link");
const [isSignUp, setIsSignUp] = useState(false);
const [error, setError] = useState(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
+ 0}
+ style={{ marginTop: spacing.sm }}
+ >
+ {resendCooldown > 0
+ ? `Resend email (${resendCooldown}s)`
+ : "Resend verification email"}
+
{
setPendingVerification(null);
setEmail(pendingVerification.email);
setPassword("");
+ setResendCooldown(0);
}}
style={{ marginTop: spacing.sm }}
>
diff --git a/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj b/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj
index ac7ebdd..646515d 100644
--- a/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj
+++ b/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
- objectVersion = 46;
+ objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -11,6 +11,7 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
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 */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
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 = ""; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = ""; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = ""; };
- 1C9767CAD348ACB05A4870BE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = ""; };
+ 1C9767CAD348ACB05A4870BE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = ""; };
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 = ""; };
+ 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; };
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 = ""; };
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 = ""; };
@@ -43,6 +45,7 @@
buildActionMask = 2147483647;
files = (
96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */,
+ 46CB71552F030871008AC4FC /* StoreKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -69,6 +72,7 @@
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
+ 46CB71542F030871008AC4FC /* StoreKit.framework */,
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */,
);
@@ -382,7 +386,10 @@
);
INFOPLIST_FILE = Sauci/Info.plist;
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;
OTHER_LDFLAGS = (
"$(inherited)",
@@ -413,7 +420,10 @@
DEVELOPMENT_TEAM = BXVPBATDZY;
INFOPLIST_FILE = Sauci/Info.plist;
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;
OTHER_LDFLAGS = (
"$(inherited)",
@@ -478,14 +488,14 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
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)\"";
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
- OTHER_LDFLAGS = (
- "$(inherited)",
- " ",
- );
+ OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
@@ -534,13 +544,13 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
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)\"";
MTL_ENABLE_DEBUG_INFO = NO;
- OTHER_LDFLAGS = (
- "$(inherited)",
- " ",
- );
+ OTHER_LDFLAGS = "$(inherited) ";
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
diff --git a/apps/mobile/src/components/PackTeaser.tsx b/apps/mobile/src/components/PackTeaser.tsx
new file mode 100644
index 0000000..4ff648e
--- /dev/null
+++ b/apps/mobile/src/components/PackTeaser.tsx
@@ -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([]);
+ 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 (
+
+
+
+
+
+
+
+ {/* Close Button */}
+
+
+
+
+ {/* Header */}
+
+
+ {packIcon || "📦"}
+
+ {packName}
+
+ Preview a few questions from this pack
+
+
+
+ {/* Questions Preview */}
+
+ {isLoading ? (
+
+
+
+ Loading preview...
+
+
+ ) : questions.length > 0 ? (
+ questions.map((question, index) => (
+
+
+
+ {[...Array(question.intensity)].map((_, i) => (
+
+ ))}
+
+
+ {index + 1} of {questions.length}
+
+
+ {question.text}
+
+ ))
+ ) : (
+
+
+ No preview available
+
+
+ )}
+
+
+ {/* More questions hint */}
+ {questions.length > 0 && (
+
+
+
+ + many more questions to unlock
+
+
+ )}
+
+ {/* Unlock Button */}
+
+
+
+
+
+ Unlock with Pro
+
+
+
+
+
+
+
+
+ );
+}
+
+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;
diff --git a/apps/mobile/src/components/Paywall.tsx b/apps/mobile/src/components/Paywall.tsx
index 6c8f9ed..4d12c89 100644
--- a/apps/mobile/src/components/Paywall.tsx
+++ b/apps/mobile/src/components/Paywall.tsx
@@ -235,6 +235,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
Unable to load subscription options.
+ {error && (
+
+ {error}
+
+ )}
void;
+ onSwipe: (direction: "left" | "right" | "up" | "down") => void;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
@@ -83,6 +83,10 @@ export default function SwipeCard({ question, onSwipe }: Props) {
runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("up");
+ } else if (translateY.value > SWIPE_THRESHOLD) {
+ runOnJS(triggerHaptic)('light');
+ translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
+ runOnJS(onSwipe)("down");
} else {
translateX.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(() => {
let opacity = 0;
let backgroundColor = 'transparent';
@@ -118,13 +122,20 @@ export default function SwipeCard({ question, onSwipe }: Props) {
[0, 0.9],
['transparent', 'rgba(231, 76, 60, 0.4)']
) as string;
- } else {
+ } else if (direction === "up") {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(243, 156, 18, 0.4)']
) 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 {
@@ -193,7 +204,7 @@ function CardContent({
onFeedbackPress,
}: {
question: Props['question'];
- overlayStyle: (direction: "left" | "right" | "up") => any;
+ overlayStyle: (direction: "left" | "right" | "up" | "down") => any;
handleButtonPress: (direction: "left" | "right" | "up") => void;
onFeedbackPress: () => void;
}) {
@@ -234,6 +245,11 @@ function CardContent({
MAYBE
+
+
+ SKIP
+
+
{/* Content */}
diff --git a/apps/mobile/src/components/SwipeCard.web.tsx b/apps/mobile/src/components/SwipeCard.web.tsx
index 4321c17..00b297f 100644
--- a/apps/mobile/src/components/SwipeCard.web.tsx
+++ b/apps/mobile/src/components/SwipeCard.web.tsx
@@ -8,7 +8,7 @@ const SWIPE_THRESHOLD = 100;
interface Props {
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",
});
+ const downOverlayOpacity = translateY.interpolate({
+ inputRange: [0, SWIPE_THRESHOLD],
+ outputRange: [0, 1],
+ extrapolate: "clamp",
+ });
+
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: (_, gestureState) => {
@@ -104,6 +110,15 @@ export default function SwipeCard({ question, onSwipe }: Props) {
setDismissed(true);
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 {
// Reset position
Animated.parallel([
@@ -176,6 +191,14 @@ export default function SwipeCard({ question, onSwipe }: Props) {
>
MAYBE
+
+ SKIP
+
diff --git a/apps/mobile/src/lib/revenuecat.ts b/apps/mobile/src/lib/revenuecat.ts
index e357d53..a14d837 100644
--- a/apps/mobile/src/lib/revenuecat.ts
+++ b/apps/mobile/src/lib/revenuecat.ts
@@ -73,18 +73,21 @@ class RevenueCatService {
return;
}
- const available = await this.loadPurchases();
- if (!available) return;
-
- if (!REVENUECAT_IOS_API_KEY) {
- console.warn("RevenueCat: Missing iOS API key");
+ // Skip if API key is missing or placeholder
+ if (!REVENUECAT_IOS_API_KEY || REVENUECAT_IOS_API_KEY === "your_revenuecat_ios_api_key") {
+ console.log("RevenueCat: Skipping - no valid API key configured");
return;
}
+ const available = await this.loadPurchases();
+ if (!available) return;
+
try {
- if (__DEV__) {
- this.Purchases.setLogLevel(this.Purchases.LOG_LEVEL?.DEBUG || 4);
- }
+ // Always enable debug logging for now to diagnose issues
+ 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({
apiKey: REVENUECAT_IOS_API_KEY,
@@ -92,9 +95,10 @@ class RevenueCatService {
});
this.initialized = true;
- console.log("RevenueCat: Initialized successfully");
+ console.log("RevenueCat: Initialized successfully with user:", userId || "anonymous");
} catch (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 {
+ 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) {
- return null;
+ return {
+ current: null,
+ availableOfferings: null,
+ error: "RevenueCat not initialized"
+ };
}
try {
const offerings = await this.Purchases.getOfferings();
- return offerings.current;
- } catch (error) {
- console.error("RevenueCat: Error fetching offerings:", error);
- return null;
+ const availableOfferings = Object.keys(offerings.all || {});
+
+ return {
+ current: offerings.current,
+ availableOfferings,
+ error: null
+ };
+ } catch (error: any) {
+ return {
+ current: null,
+ availableOfferings: null,
+ error: error.message || "Failed to fetch offerings"
+ };
}
}
diff --git a/apps/mobile/src/lib/skippedQuestions.ts b/apps/mobile/src/lib/skippedQuestions.ts
new file mode 100644
index 0000000..f497205
--- /dev/null
+++ b/apps/mobile/src/lib/skippedQuestions.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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> {
+ 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));
+}
diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts
index ef12f51..75719c5 100755
--- a/apps/mobile/src/store/index.ts
+++ b/apps/mobile/src/store/index.ts
@@ -256,6 +256,9 @@ export const usePacksStore = create((set, get) => ({
fetchPacks: async () => {
set({ isLoading: true });
+ // Get user's explicit content preference
+ const showExplicit = useAuthStore.getState().user?.show_explicit_content ?? false;
+
// Fetch categories
const { data: categories } = await supabase
.from("categories")
@@ -263,12 +266,19 @@ export const usePacksStore = create((set, get) => ({
.order("sort_order");
// Fetch packs with category info and question count
- const { data: packs } = await supabase
+ let query = supabase
.from("question_packs")
.select("*, category:categories(*), questions(count)")
.eq("is_public", true)
.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
await get().fetchEnabledPacks();
@@ -481,11 +491,51 @@ export const useSubscriptionStore = create((set, get) =>
fetchOfferings: async () => {
set({ isLoadingOfferings: true, error: null });
try {
- const offerings = await revenueCatService.getOfferings();
- set({ offerings, isLoadingOfferings: false });
- } catch (error) {
+ // Check if RevenueCat is initialized
+ if (!revenueCatService.isInitialized()) {
+ 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);
- set({ error: "Failed to load subscription options", isLoadingOfferings: false });
+ set({ error: `Error: ${error.message || "Unknown"}`, isLoadingOfferings: false });
}
},
diff --git a/apps/mobile/src/theme/index.ts b/apps/mobile/src/theme/index.ts
index 6cd5097..26580bb 100644
--- a/apps/mobile/src/theme/index.ts
+++ b/apps/mobile/src/theme/index.ts
@@ -21,6 +21,10 @@ export const colors = {
primaryDark: '#c73a52',
primaryLight: 'rgba(233, 69, 96, 0.15)',
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: '#ffffff',
@@ -37,8 +41,10 @@ export const colors = {
};
export const gradients = {
- primary: ['#e94560', '#c73a52'],
- primarySubtle: ['rgba(233, 69, 96, 0.8)', 'rgba(199, 58, 82, 0.8)'],
+ primary: ['#e94560', '#9b59b6'],
+ primarySubtle: ['rgba(233, 69, 96, 0.8)', 'rgba(155, 89, 182, 0.8)'],
+ primaryReverse: ['#9b59b6', '#e94560'],
+ primaryVertical: ['#e94560', '#9b59b6'],
background: ['#1a1a2e', '#0d0d1a'],
backgroundReverse: ['#0d0d1a', '#1a1a2e'],
glass: ['rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0)'],
diff --git a/apps/mobile/src/types/index.ts b/apps/mobile/src/types/index.ts
index 53fbcfa..e90867f 100755
--- a/apps/mobile/src/types/index.ts
+++ b/apps/mobile/src/types/index.ts
@@ -1,4 +1,6 @@
// User profile (extends Supabase auth.users)
+export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
+
export interface Profile {
id: string;
name: string | null;
@@ -7,6 +9,9 @@ export interface Profile {
push_token: string | null;
is_premium: boolean;
couple_id: string | null;
+ gender: Gender | null;
+ show_explicit_content: boolean;
+ onboarding_completed: boolean;
created_at: string;
updated_at: string;
}
@@ -36,6 +41,7 @@ export interface QuestionPack {
icon: string | null;
is_premium: boolean;
is_public: boolean;
+ is_explicit: boolean;
sort_order: number;
category_id: string | null;
category?: Category;
diff --git a/apps/supabase/migrations/20251229000003_filter_premium_packs.sql b/apps/supabase/migrations/20251229000003_filter_premium_packs.sql
new file mode 100644
index 0000000..c70f7a9
--- /dev/null
+++ b/apps/supabase/migrations/20251229000003_filter_premium_packs.sql
@@ -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;
+$$;
diff --git a/apps/supabase/migrations/20251229000004_enforce_couple_size.sql b/apps/supabase/migrations/20251229000004_enforce_couple_size.sql
new file mode 100644
index 0000000..ebf8dcb
--- /dev/null
+++ b/apps/supabase/migrations/20251229000004_enforce_couple_size.sql
@@ -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();