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();