import React, { useState, useRef, useEffect } from 'react'; import { View, Text, StyleSheet, TextInput, Pressable, KeyboardAvoidingView, Platform, Keyboard, ScrollView, Image, } 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, SlideInRight, SlideOutLeft, } 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 { GlassToggle } from '../../src/components/ui/GlassToggle'; import { Paywall } from '../../src/components/paywall'; import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme'; import { useAuthStore } from '../../src/store'; import { supabase } from '../../src/lib/supabase'; import { getProfileError } from '../../src/lib/errors'; import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications'; import { Events } from '../../src/lib/analytics'; import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen'; import { useAvatarPicker } from '../../src/hooks/useAvatarPicker'; import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding'; 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 UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship'; const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [ { value: 'improve_communication', label: 'Improve communication', icon: 'chatbubbles' }, { value: 'spice_up_intimacy', label: 'Spice up intimacy', icon: 'flame' }, { value: 'deeper_connection', label: 'Build deeper connection', icon: 'heart' }, { value: 'have_fun', label: 'Have fun together', icon: 'happy' }, { value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' }, ]; // Intensity thresholds for backwards compatibility // When hide_nsfw=true, max_intensity=2 (mild content only) // When hide_nsfw=false, max_intensity=5 (all content) const NSFW_OFF_INTENSITY = 2; const NSFW_ON_INTENSITY = 5; type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'content' | 'notifications'; export default function OnboardingScreen() { const router = useRouter(); const { user, fetchUser } = useAuthStore(); const [stage, setStage] = useState('avatar'); const [name, setName] = useState(user?.name || ''); const [gender, setGender] = useState(user?.gender || null); const [usageReason, setUsageReason] = useState(null); const [hideNsfw, setHideNsfw] = useState(user?.hide_nsfw ?? false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [showPaywall, setShowPaywall] = useState(false); const inputRef = useRef(null); const paywallNavigationRef = useRef(false); const { avatarUri, isUploading: isUploadingAvatar, showPicker: handleAvatarPress, uploadAvatar, } = useAvatarPicker({ userId: user?.id, onSelect: () => setError(null), }); useEffect(() => { Events.onboardingStart(); }, []); const handleAvatarContinue = () => { if (!avatarUri) { setError('Please add a profile photo to continue'); return; } setStage('name'); Events.onboardingStageComplete('avatar'); }; const handleNameSubmit = () => { if (!name.trim()) { setError('Please enter your name'); return; } Keyboard.dismiss(); setError(null); setStage('gender'); Events.onboardingStageComplete('name'); }; const handleGenderSelect = (selectedGender: Gender) => { setGender(selectedGender); setError(null); }; const handleGenderContinue = () => { if (!gender) { setError('Please select a gender or skip'); return; } setStage('purpose'); Events.onboardingStageComplete('gender'); }; const handlePurposeSelect = (selectedReason: UsageReason) => { setUsageReason(selectedReason); setError(null); }; const handlePurposeContinue = () => { if (!usageReason) { setError('Please select what brings you to Sauci'); return; } setStage('content'); Events.onboardingStageComplete('purpose'); }; const handleContentContinue = () => { setStage('notifications'); Events.onboardingStageComplete('content'); }; const handleEnableNotifications = async () => { setIsLoading(true); try { const token = await registerForPushNotificationsAsync(); if (token && user?.id) { await savePushToken(user.id, token); } } catch (err) { console.error('Failed to enable notifications:', err); } finally { setIsLoading(false); await handleComplete(); } }; const handleComplete = async () => { console.log('[Onboarding] handleComplete called, user:', user?.id, 'name:', name); if (!user?.id) { console.error('[Onboarding] No user ID available'); setError('Not logged in. Please restart the app.'); return; } setIsLoading(true); setError(null); try { // Upload avatar if one was selected let avatarUrl: string | null = null; if (avatarUri) { avatarUrl = await uploadAvatar(); if (avatarUrl) { Events.avatarUploaded(); } } // Derive intensity from hide_nsfw for backwards compatibility const maxIntensity = hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY; const showExplicitContent = !hideNsfw; const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"]; const updateData: Record = { name: name.trim(), gender, usage_reason: usageReason, max_intensity: maxIntensity, show_explicit_content: showExplicitContent, hide_nsfw: hideNsfw, onboarding_completed: true, onboarding_version: REQUIRED_ONBOARDING_VERSION, updated_at: new Date().toISOString(), }; if (avatarUrl) { updateData.avatar_url = avatarUrl; updatedFields.push("avatar_url"); } const { error: updateError } = await supabase .from('profiles') .update(updateData) .eq('id', user.id); if (updateError) { console.error('[Onboarding] Profile update error:', updateError); throw updateError; } console.log('[Onboarding] Profile updated successfully, fetching user...'); await fetchUser(); console.log('[Onboarding] User fetched, navigating to home...'); Events.profileUpdated(updatedFields); Events.onboardingComplete(); const currentUser = useAuthStore.getState().user; if (currentUser?.id && !currentUser.is_premium) { const hasSeenPaywall = await hasSeenOnboardingPaywall(currentUser.id); if (!hasSeenPaywall) { paywallNavigationRef.current = true; setShowPaywall(true); return; } } router.replace('/'); } catch (err: any) { console.error('Onboarding error:', err); setError(getProfileError(err)); } finally { setIsLoading(false); } }; const handlePaywallClose = () => { setShowPaywall(false); const currentUser = useAuthStore.getState().user; if (currentUser?.id) { void markOnboardingPaywallSeen(currentUser.id); } if (paywallNavigationRef.current) { paywallNavigationRef.current = false; router.replace('/'); } }; const renderStage = () => { switch (stage) { case 'avatar': return ( Welcome to Sauci Add a profile photo so your partner knows it's you {avatarUri ? ( ) : ( )} {avatarUri ? 'Tap to change photo' : 'Tap to add a photo'} {error && ( {error} )} Continue ); case 'name': return ( {avatarUri ? ( ) : ( )} What can we call you? Nicknames and pet names are welcome too! {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} ))} {error && ( {error} )} Continue ); case 'purpose': return ( What brings you here? This helps us personalize your experience {PURPOSE_OPTIONS.map((option) => ( handlePurposeSelect(option.value)} > {option.label} {usageReason === option.value && } ))} {error && ( {error} )} Continue ); case 'content': return ( Content Preferences Choose what type of content you'd like to see Hide Adult Content Only show mild, family-friendly question packs You can change this anytime in settings {error && ( {error} )} Continue ); case 'notifications': return ( Stay in the Loop Get notified when you and your partner match on answers Match Alerts Know instantly when you both agree Messages Never miss a message from your partner {error && ( {error} )} Enable Notifications Maybe Later ); } }; return ( {/* Header with back button and progress dots */} {stage !== 'avatar' ? ( { setError(null); if (stage === 'notifications') setStage('content'); else if (stage === 'content') setStage('purpose'); else if (stage === 'purpose') setStage('gender'); else if (stage === 'gender') setStage('name'); else if (stage === 'name') setStage('avatar'); }} > ) : ( )} {renderStage()} ); } const styles = StyleSheet.create({ container: { flex: 1, }, scrollContent: { flexGrow: 1, }, headerBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingTop: 60, paddingBottom: spacing.md, paddingHorizontal: spacing.md, }, backButton: { width: 40, height: 40, borderRadius: 20, backgroundColor: colors.backgroundLight, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.border, }, // ... progressDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.border, }, // ... avatarPlaceholder: { width: 120, height: 120, borderRadius: 60, alignItems: 'center', justifyContent: 'center', borderWidth: 2, borderColor: colors.border, borderStyle: 'dashed', backgroundColor: colors.backgroundLight, }, // ... inputContainer: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.background, // Flat background borderRadius: radius.md, borderWidth: 1, borderColor: colors.border, paddingHorizontal: spacing.md, }, // ... genderOption: { width: '48%', alignItems: 'center', backgroundColor: colors.backgroundLight, borderRadius: radius.lg, borderWidth: 2, borderColor: colors.border, paddingVertical: spacing.lg, paddingHorizontal: spacing.md, gap: spacing.sm, }, // ... purposeOption: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.backgroundLight, borderRadius: radius.lg, borderWidth: 2, borderColor: colors.border, padding: spacing.md, }, // ... purposeIcon: { width: 44, height: 44, borderRadius: 22, backgroundColor: colors.background, alignItems: 'center', justifyContent: 'center', marginRight: spacing.md, borderWidth: 1, borderColor: colors.border, }, // ... radioOuter: { width: 24, height: 24, borderRadius: 12, borderWidth: 2, borderColor: colors.border, alignItems: 'center', justifyContent: 'center', }, // ... notificationFeature: { flexDirection: 'row', alignItems: 'center', paddingVertical: spacing.md, borderBottomWidth: 1, borderBottomColor: colors.border, }, backButtonPlaceholder: { width: 40, }, progressContainer: { flexDirection: 'row', justifyContent: 'center', gap: spacing.sm, }, 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, }, headerAvatar: { width: 80, height: 80, borderRadius: 40, marginBottom: spacing.lg, borderWidth: 2, borderColor: colors.primary, }, avatarSection: { alignItems: 'center', paddingVertical: spacing.lg, }, avatarTouchable: { position: 'relative', }, avatarImage: { width: 120, height: 120, borderRadius: 60, borderWidth: 3, borderColor: colors.primary, }, avatarBadge: { position: 'absolute', bottom: 4, right: 4, width: 32, height: 32, borderRadius: 16, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center', borderWidth: 2, borderColor: colors.background, }, avatarHint: { ...typography.caption1, color: colors.textSecondary, marginTop: spacing.md, }, 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, }, inputIcon: { marginRight: spacing.sm, }, input: { flex: 1, ...typography.body, color: colors.text, paddingVertical: spacing.md, }, genderGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, }, genderOptionSelected: { borderColor: colors.primary, backgroundColor: colors.primaryLight, }, genderLabel: { ...typography.subhead, color: colors.textSecondary, textAlign: 'center', }, genderLabelSelected: { color: colors.primary, fontWeight: '600', }, purposeList: { gap: spacing.sm, }, purposeOptionSelected: { borderColor: colors.primary, backgroundColor: colors.primaryLight, }, purposeIconSelected: { backgroundColor: colors.primaryLight, }, purposeLabel: { ...typography.subhead, color: colors.textSecondary, flex: 1, }, purposeLabelSelected: { color: colors.text, fontWeight: '600', }, contentToggleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }, contentToggleLeft: { flex: 1, flexDirection: 'row', alignItems: 'center', marginRight: spacing.md, }, contentToggleIcon: { width: 48, height: 48, borderRadius: 24, alignItems: 'center', justifyContent: 'center', marginRight: spacing.md, }, contentToggleText: { flex: 1, }, contentToggleLabel: { ...typography.headline, color: colors.text, }, contentToggleDescription: { ...typography.caption1, color: colors.textSecondary, marginTop: 2, }, contentNote: { ...typography.caption1, color: colors.textSecondary, textAlign: 'center', marginTop: spacing.lg, }, 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', }, notificationFeatureLast: { borderBottomWidth: 0, }, notificationIcon: { width: 48, height: 48, borderRadius: 24, backgroundColor: colors.primaryLight, alignItems: 'center', justifyContent: 'center', marginRight: spacing.md, }, notificationText: { flex: 1, }, notificationTitle: { ...typography.headline, color: colors.text, marginBottom: spacing.xs, }, notificationDescription: { ...typography.caption1, color: colors.textSecondary, }, skipButton: { alignItems: 'center', paddingVertical: spacing.md, marginTop: spacing.sm, }, skipText: { ...typography.subhead, color: colors.textSecondary, }, });