This commit is contained in:
Jamie Brunton committed 2025-12-31 09:40:27 +00:00
1 parent d1a2032724
commit dfcf0ba289
10 files changed
+630 -28

No files matched your search

+12 -2
View File
@@ -10,6 +10,7 @@ import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth"; import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications"; import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { Events } from "../../src/lib/analytics";
import type { MatchWithQuestion } from "../../src/types"; import type { MatchWithQuestion } from "../../src/types";
import type { Database } from "../../src/types/supabase"; import type { Database } from "../../src/types/supabase";
@@ -76,7 +77,7 @@ export default function AppLayout() {
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut } = useAuthStore(); const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
const { newMatchesCount, addMatch } = useMatchStore(); const { matches, newMatchesCount, addMatch } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore(); const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore(); const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore(); const { initializeRevenueCat } = useSubscriptionStore();
@@ -256,9 +257,18 @@ export default function AppLayout() {
.single(); .single();
if (matchWithQuestion) { if (matchWithQuestion) {
// Track milestone events before adding (so count is accurate)
const currentMatchCount = matches.length;
if (currentMatchCount === 0) {
Events.firstMatch();
} else if ((currentMatchCount + 1) % 10 === 0) {
Events.milestoneMatch(currentMatchCount + 1);
}
// Add to store and show notification // Add to store and show notification
addMatch(matchWithQuestion); addMatch(matchWithQuestion);
setMatchNotification(matchWithQuestion); setMatchNotification(matchWithQuestion);
Events.matchCreated(matchWithQuestion.match_type || "unknown");
} }
} }
) )
@@ -267,7 +277,7 @@ export default function AppLayout() {
return () => { return () => {
supabase.removeChannel(channel); supabase.removeChannel(channel);
}; };
}, [user?.couple_id, addMatch]); }, [user?.couple_id, addMatch, matches.length]);
// Subscribe to realtime message notifications // Subscribe to realtime message notifications
useEffect(() => { useEffect(() => {
+46 -4
View File
@@ -23,7 +23,7 @@ export default function ChatScreen() {
const matchId = id as string; const matchId = id as string;
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { user } = useAuthStore(); const { user, partner } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore(); const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [match, setMatch] = useState<any>(null); const [match, setMatch] = useState<any>(null);
@@ -44,6 +44,7 @@ export default function ChatScreen() {
if (matchId) { if (matchId) {
setActiveMatchId(matchId); setActiveMatchId(matchId);
markMatchMessagesAsRead(matchId); markMatchMessagesAsRead(matchId);
Events.matchViewed();
} }
return () => { return () => {
isFocusedRef.current = false; isFocusedRef.current = false;
@@ -350,7 +351,26 @@ export default function ChatScreen() {
> >
<Ionicons name="chevron-back" size={24} color={colors.text} /> <Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>Chat</Text> <View style={styles.headerCenter}>
{partner?.avatar_url ? (
<Image
source={{ uri: partner.avatar_url }}
style={styles.headerAvatar}
/>
) : (
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.headerAvatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.headerAvatarText}>
{partner?.name?.[0]?.toUpperCase() || "P"}
</Text>
</LinearGradient>
)}
<Text style={styles.headerTitle}>{partner?.name || "Chat"}</Text>
</View>
<View style={styles.headerSpacer} /> <View style={styles.headerSpacer} />
</View> </View>
@@ -710,11 +730,33 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
headerAvatar: {
width: 32,
height: 32,
borderRadius: 16,
},
headerAvatarGradient: {
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
},
headerAvatarText: {
fontSize: 14,
fontWeight: 'bold',
color: colors.text,
},
headerTitle: { headerTitle: {
...typography.headline, ...typography.headline,
color: colors.text, color: colors.text,
flex: 1,
textAlign: 'center',
}, },
headerSpacer: { headerSpacer: {
width: 40, width: 40,
+153 -5
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { import {
View, View,
Text, Text,
@@ -40,7 +40,17 @@ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
{ value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' }, { value: 'prefer-not-to-say', label: 'Skip', icon: 'remove-circle-outline' },
]; ];
type Stage = 'name' | 'gender' | 'explicit' | 'notifications'; 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' },
];
type Stage = 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
export default function OnboardingScreen() { export default function OnboardingScreen() {
const router = useRouter(); const router = useRouter();
@@ -48,11 +58,16 @@ export default function OnboardingScreen() {
const [stage, setStage] = useState<Stage>('name'); const [stage, setStage] = useState<Stage>('name');
const [name, setName] = useState(user?.name || ''); const [name, setName] = useState(user?.name || '');
const [gender, setGender] = useState<Gender | null>(user?.gender || null); const [gender, setGender] = useState<Gender | null>(user?.gender || null);
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true); const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
useEffect(() => {
Events.onboardingStart();
}, []);
const handleNameSubmit = () => { const handleNameSubmit = () => {
if (!name.trim()) { if (!name.trim()) {
setError('Please enter your name'); setError('Please enter your name');
@@ -74,10 +89,24 @@ export default function OnboardingScreen() {
setError('Please select a gender or skip'); setError('Please select a gender or skip');
return; return;
} }
setStage('explicit'); setStage('purpose');
Events.onboardingStageComplete('gender'); 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('explicit');
Events.onboardingStageComplete('purpose');
};
const handleExplicitContinue = () => { const handleExplicitContinue = () => {
setStage('notifications'); setStage('notifications');
Events.onboardingStageComplete('explicit'); Events.onboardingStageComplete('explicit');
@@ -115,6 +144,7 @@ export default function OnboardingScreen() {
.update({ .update({
name: name.trim(), name: name.trim(),
gender, gender,
usage_reason: usageReason,
show_explicit_content: showExplicit, show_explicit_content: showExplicit,
onboarding_completed: true, onboarding_completed: true,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
@@ -260,6 +290,82 @@ export default function OnboardingScreen() {
</Animated.View> </Animated.View>
); );
case 'purpose':
return (
<Animated.View
key="purpose"
entering={SlideInRight.duration(400)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
>
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="sparkles" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>What brings you here?</Text>
<Text style={styles.subtitle}>
This helps us personalize your experience
</Text>
</View>
<GlassCard style={styles.card}>
<Text style={styles.label}>I'm using Sauci to...</Text>
<View style={styles.purposeList}>
{PURPOSE_OPTIONS.map((option) => (
<Pressable
key={option.value}
style={[
styles.purposeOption,
usageReason === option.value && styles.purposeOptionSelected,
]}
onPress={() => handlePurposeSelect(option.value)}
>
<View style={[
styles.purposeIcon,
usageReason === option.value && styles.purposeIconSelected
]}>
<Ionicons
name={option.icon as any}
size={24}
color={usageReason === option.value ? colors.primary : colors.textSecondary}
/>
</View>
<Text
style={[
styles.purposeLabel,
usageReason === option.value && styles.purposeLabelSelected,
]}
>
{option.label}
</Text>
<View style={[
styles.radioOuter,
usageReason === option.value && styles.radioOuterSelected
]}>
{usageReason === option.value && <View style={styles.radioInner} />}
</View>
</Pressable>
))}
</View>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton onPress={handlePurposeContinue} fullWidth size="lg">
Continue
</GlassButton>
</View>
</Animated.View>
);
case 'explicit': case 'explicit':
return ( return (
<Animated.View <Animated.View
@@ -401,7 +507,7 @@ export default function OnboardingScreen() {
</Text> </Text>
</View> </View>
</View> </View>
<View style={styles.notificationFeature}> <View style={[styles.notificationFeature, styles.notificationFeatureLast]}>
<View style={styles.notificationIcon}> <View style={styles.notificationIcon}>
<Ionicons name="chatbubble" size={24} color={colors.primary} /> <Ionicons name="chatbubble" size={24} color={colors.primary} />
</View> </View>
@@ -456,7 +562,8 @@ export default function OnboardingScreen() {
style={styles.backButton} style={styles.backButton}
onPress={() => { onPress={() => {
if (stage === 'notifications') setStage('explicit'); if (stage === 'notifications') setStage('explicit');
else if (stage === 'explicit') setStage('gender'); else if (stage === 'explicit') setStage('purpose');
else if (stage === 'purpose') setStage('gender');
else setStage('name'); else setStage('name');
}} }}
> >
@@ -468,6 +575,7 @@ export default function OnboardingScreen() {
<View style={styles.progressContainer}> <View style={styles.progressContainer}>
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'explicit' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'explicit' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} /> <View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
</View> </View>
@@ -617,6 +725,43 @@ const styles = StyleSheet.create({
color: colors.primary, color: colors.primary,
fontWeight: '600', fontWeight: '600',
}, },
purposeList: {
gap: spacing.sm,
},
purposeOption: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
padding: spacing.md,
},
purposeOptionSelected: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
purposeIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
purposeIconSelected: {
backgroundColor: colors.primaryLight,
},
purposeLabel: {
...typography.subhead,
color: colors.textSecondary,
flex: 1,
},
purposeLabelSelected: {
color: colors.text,
fontWeight: '600',
},
explicitOption: { explicitOption: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -696,6 +841,9 @@ const styles = StyleSheet.create({
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: colors.glass.border, borderBottomColor: colors.glass.border,
}, },
notificationFeatureLast: {
borderBottomWidth: 0,
},
notificationIcon: { notificationIcon: {
width: 48, width: 48,
height: 48, height: 48,
+1
View File
@@ -136,6 +136,7 @@ export default function PairingScreen() {
const copyToClipboard = async () => { const copyToClipboard = async () => {
if (couple?.invite_code) { if (couple?.invite_code) {
await Clipboard.setStringAsync(couple.invite_code); await Clipboard.setStringAsync(couple.invite_code);
Events.inviteCodeCopied();
Alert.alert("Copied", "Invite code copied to clipboard"); Alert.alert("Copied", "Invite code copied to clipboard");
} }
}; };
+359 -13
View File
@@ -1,4 +1,4 @@
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform, useWindowDimensions, TextInput, Modal, ActivityIndicator, Linking, Switch } from "react-native"; import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform, useWindowDimensions, TextInput, Modal, ActivityIndicator, Linking, Switch, Image, ActionSheetIOS } from "react-native";
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo, useEffect } from "react";
import { useAuthStore, useMatchStore, useMessageStore, usePacksStore, useSubscriptionStore } from "../../src/store"; import { useAuthStore, useMatchStore, useMessageStore, usePacksStore, useSubscriptionStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
@@ -6,6 +6,9 @@ import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated"; import Animated, { FadeInDown } from "react-native-reanimated";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { resetSwipeTutorial } from "../../src/lib/swipeTutorialSeen"; import { resetSwipeTutorial } from "../../src/lib/swipeTutorialSeen";
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { import {
isBiometricAvailable, isBiometricAvailable,
isBiometricEnabled, isBiometricEnabled,
@@ -16,6 +19,7 @@ import { router } from "expo-router";
import { GradientBackground, GlassCard } from "../../src/components/ui"; import { GradientBackground, GlassCard } from "../../src/components/ui";
import { FeedbackModal } from "../../src/components/FeedbackModal"; import { FeedbackModal } from "../../src/components/FeedbackModal";
import { Paywall } from "../../src/components/Paywall"; import { Paywall } from "../../src/components/Paywall";
import { Events } from "../../src/lib/analytics";
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
const MAX_CONTENT_WIDTH = 500; const MAX_CONTENT_WIDTH = 500;
@@ -40,12 +44,23 @@ export default function ProfileScreen() {
const [biometricEnabled, setBiometricEnabledState] = useState(false); const [biometricEnabled, setBiometricEnabledState] = useState(false);
const [biometricType, setBiometricType] = useState("Face ID"); const [biometricType, setBiometricType] = useState("Face ID");
const [isUpdatingBiometric, setIsUpdatingBiometric] = useState(false); const [isUpdatingBiometric, setIsUpdatingBiometric] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const [newName, setNewName] = useState(user?.name || "");
const [isUpdatingName, setIsUpdatingName] = useState(false);
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
// Check if user or partner has premium access // Check if user or partner has premium access
const hasPremiumAccess = useMemo(() => { const hasPremiumAccess = useMemo(() => {
return user?.is_premium || partner?.is_premium || subscription.isProUser; return user?.is_premium || partner?.is_premium || subscription.isProUser;
}, [user?.is_premium, partner?.is_premium, subscription.isProUser]); }, [user?.is_premium, partner?.is_premium, subscription.isProUser]);
// Update newName when user changes
useEffect(() => {
if (user?.name) {
setNewName(user.name);
}
}, [user?.name]);
// Check biometric availability on mount // Check biometric availability on mount
useEffect(() => { useEffect(() => {
const checkBiometric = async () => { const checkBiometric = async () => {
@@ -130,6 +145,181 @@ export default function ProfileScreen() {
} }
}; };
const handleUpdateName = async () => {
if (!user?.id) return;
const trimmedName = newName.trim();
if (!trimmedName) {
Alert.alert("Error", "Name cannot be empty");
return;
}
if (trimmedName === user.name) {
setIsEditingName(false);
return;
}
setIsUpdatingName(true);
try {
const { error } = await supabase
.from('profiles')
.update({
name: trimmedName,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (error) throw error;
await fetchUser();
setIsEditingName(false);
} catch (error) {
Alert.alert("Error", "Failed to update name. Please try again.");
setNewName(user.name || "");
} finally {
setIsUpdatingName(false);
}
};
const handleCancelEditName = () => {
setNewName(user?.name || "");
setIsEditingName(false);
};
const handleAvatarPress = () => {
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
options: ['Cancel', 'Take Photo', 'Choose from Library'],
cancelButtonIndex: 0,
},
(buttonIndex) => {
if (buttonIndex === 1) {
pickAvatar('camera');
} else if (buttonIndex === 2) {
pickAvatar('library');
}
}
);
} else {
Alert.alert(
'Change Profile Photo',
'Choose an option',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Take Photo', onPress: () => pickAvatar('camera') },
{ text: 'Choose from Library', onPress: () => pickAvatar('library') },
]
);
}
};
const pickAvatar = async (source: 'camera' | 'library') => {
try {
let result: ImagePicker.ImagePickerResult;
if (source === 'camera') {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
} else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.');
return;
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
}
if (!result.canceled && result.assets[0]) {
await uploadAvatar(result.assets[0].uri);
}
} catch (error) {
console.error('Error picking avatar:', error);
Alert.alert('Error', 'Failed to pick image. Please try again.');
}
};
const uploadAvatar = async (uri: string) => {
if (!user?.id) return;
setIsUploadingAvatar(true);
try {
let fileBody;
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(uri);
const blob = await response.blob();
fileBody = blob;
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/webp') ext = 'webp';
} else {
const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64);
const uriExt = uri.split('.').pop()?.toLowerCase();
if (uriExt && ['jpg', 'jpeg', 'png', 'webp'].includes(uriExt)) {
ext = uriExt === 'jpeg' ? 'jpg' : uriExt;
}
}
const fileName = `${user.id}/${Date.now()}.${ext}`;
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
// Upload to storage
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, fileBody, {
contentType,
upsert: true,
});
if (uploadError) throw uploadError;
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName);
// Update profile with avatar URL
const { error: updateError } = await supabase
.from('profiles')
.update({
avatar_url: publicUrl,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (updateError) throw updateError;
// Refresh user data
await fetchUser();
Events.avatarUploaded();
} catch (error) {
console.error('Error uploading avatar:', error);
Alert.alert('Error', 'Failed to upload photo. Please try again.');
} finally {
setIsUploadingAvatar(false);
}
};
const handleManageSubscription = () => { const handleManageSubscription = () => {
Linking.openURL("https://apps.apple.com/account/subscriptions"); Linking.openURL("https://apps.apple.com/account/subscriptions");
}; };
@@ -153,6 +343,7 @@ export default function ProfileScreen() {
// Refresh user data (will also clear couple/partner since couple_id is now null) // Refresh user data (will also clear couple/partner since couple_id is now null)
await useAuthStore.getState().fetchUser(); await useAuthStore.getState().fetchUser();
Events.relationshipEnded();
setShowDeleteModal(false); setShowDeleteModal(false);
setDeleteConfirmText(""); setDeleteConfirmText("");
Alert.alert("Success", "All relationship data has been deleted."); Alert.alert("Success", "All relationship data has been deleted.");
@@ -227,20 +418,90 @@ export default function ProfileScreen() {
> >
<GlassCard variant="elevated"> <GlassCard variant="elevated">
<View style={styles.profileContent}> <View style={styles.profileContent}>
<LinearGradient <TouchableOpacity
colors={gradients.primary as [string, string]} onPress={handleAvatarPress}
style={styles.avatarGradient} disabled={isUploadingAvatar}
start={{ x: 0, y: 0 }} activeOpacity={0.7}
end={{ x: 1, y: 1 }} style={styles.avatarTouchable}
> >
<View style={styles.avatarInner}> <LinearGradient
<Text style={styles.avatarText}> colors={gradients.primary as [string, string]}
{user?.name?.[0]?.toUpperCase() || "U"} style={styles.avatarGradient}
</Text> start={{ x: 0, y: 0 }}
</View> end={{ x: 1, y: 1 }}
</LinearGradient> >
{user?.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
style={styles.avatarImage}
/>
) : (
<View style={styles.avatarInner}>
<Text style={styles.avatarText}>
{user?.name?.[0]?.toUpperCase() || "U"}
</Text>
</View>
)}
</LinearGradient>
{isUploadingAvatar ? (
<View style={styles.avatarOverlay}>
<ActivityIndicator size="large" color={colors.text} />
</View>
) : (
<View style={styles.avatarEditBadge}>
<Ionicons name="camera" size={14} color={colors.text} />
</View>
)}
</TouchableOpacity>
<View style={styles.profileInfo}> <View style={styles.profileInfo}>
<Text style={styles.name}>{user?.name || "User"}</Text> {isEditingName ? (
<View style={styles.editNameContainer}>
<TextInput
style={styles.nameInput}
value={newName}
onChangeText={setNewName}
placeholder="Enter your name"
placeholderTextColor={colors.textTertiary}
autoFocus
editable={!isUpdatingName}
onSubmitEditing={handleUpdateName}
returnKeyType="done"
/>
<View style={styles.editNameButtons}>
<TouchableOpacity
style={styles.cancelEditButton}
onPress={handleCancelEditName}
disabled={isUpdatingName}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity
style={styles.saveEditButton}
onPress={handleUpdateName}
disabled={isUpdatingName}
activeOpacity={0.7}
>
{isUpdatingName ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="checkmark" size={20} color={colors.text} />
)}
</TouchableOpacity>
</View>
</View>
) : (
<TouchableOpacity
style={styles.nameContainer}
onPress={() => setIsEditingName(true)}
activeOpacity={0.7}
>
<Text style={styles.name}>{user?.name || "User"}</Text>
<View style={styles.editNameIcon}>
<Ionicons name="pencil" size={14} color={colors.textTertiary} />
</View>
</TouchableOpacity>
)}
<Text style={styles.email}>{user?.email}</Text> <Text style={styles.email}>{user?.email}</Text>
</View> </View>
</View> </View>
@@ -764,15 +1025,100 @@ const styles = StyleSheet.create({
fontWeight: "bold", fontWeight: "bold",
color: colors.text, color: colors.text,
}, },
avatarTouchable: {
position: "relative",
},
avatarImage: {
width: 96,
height: 96,
borderRadius: 48,
},
avatarOverlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 50,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "center",
alignItems: "center",
},
avatarEditBadge: {
position: "absolute",
bottom: 0,
right: 0,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.primary,
justifyContent: "center",
alignItems: "center",
borderWidth: 3,
borderColor: colors.background,
},
profileInfo: { profileInfo: {
alignItems: "center", alignItems: "center",
marginTop: spacing.lg, marginTop: spacing.lg,
}, },
nameContainer: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
name: { name: {
...typography.title2, ...typography.title2,
color: colors.text, color: colors.text,
marginBottom: spacing.xs, marginBottom: spacing.xs,
}, },
editNameIcon: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.xs,
},
editNameContainer: {
width: "100%",
alignItems: "center",
gap: spacing.sm,
},
nameInput: {
...typography.title2,
color: colors.text,
textAlign: "center",
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.primary,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
minWidth: 200,
},
editNameButtons: {
flexDirection: "row",
gap: spacing.sm,
},
cancelEditButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: colors.glass.border,
},
saveEditButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.primary,
justifyContent: "center",
alignItems: "center",
},
email: { email: {
...typography.subhead, ...typography.subhead,
color: colors.textSecondary, color: colors.textSecondary,
+11 -1
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native"; import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
import { useLocalSearchParams, router } from "expo-router"; import { useLocalSearchParams, router } from "expo-router";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
@@ -22,12 +22,22 @@ export default function SwipeScreen() {
const [showTutorial, setShowTutorial] = useState(false); const [showTutorial, setShowTutorial] = useState(false);
const { enabledPackIds, fetchPacks } = usePacksStore(); const { enabledPackIds, fetchPacks } = usePacksStore();
const { partner, couple } = useAuthStore(); const { partner, couple } = useAuthStore();
const hasTrackedExhausted = useRef(false);
useEffect(() => { useEffect(() => {
fetchPacks().then(() => fetchQuestions()); fetchPacks().then(() => fetchQuestions());
checkTutorial(); checkTutorial();
hasTrackedExhausted.current = false; // Reset when pack changes
}, [packId]); }, [packId]);
// Track when all questions are exhausted
useEffect(() => {
if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) {
hasTrackedExhausted.current = true;
Events.allQuestionsExhausted();
}
}, [currentIndex, questions.length]);
const checkTutorial = async () => { const checkTutorial = async () => {
const seen = await hasSeenSwipeTutorial(); const seen = await hasSeenSwipeTutorial();
if (!seen) { if (!seen) {
+11 -3
View File
@@ -5,8 +5,8 @@ import "../src/polyfills/web";
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry"; import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
initSentry(); initSentry();
// Initialize Firebase Analytics // Initialize Analytics
import { initAnalytics, setUserId, clearUserId } from "../src/lib/analytics"; import { initAnalytics, setUserId, clearUserId, logScreenView } from "../src/lib/analytics";
initAnalytics(); initAnalytics();
// Import push notification utilities // Import push notification utilities
@@ -33,7 +33,7 @@ if (Platform.OS === "web" && typeof window === "undefined") {
}; };
} }
import { Stack, router } from "expo-router"; import { Stack, router, usePathname } from "expo-router";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
// GestureHandlerRootView is only needed on native platforms // GestureHandlerRootView is only needed on native platforms
// On web, we use a simple View to avoid findNodeHandle errors // On web, we use a simple View to avoid findNodeHandle errors
@@ -49,6 +49,14 @@ const queryClient = new QueryClient();
export default function RootLayout() { export default function RootLayout() {
const { fetchUser, setUser } = useAuthStore(); const { fetchUser, setUser } = useAuthStore();
const pathname = usePathname();
// Track screen views
useEffect(() => {
if (pathname) {
logScreenView(pathname);
}
}, [pathname]);
useEffect(() => { useEffect(() => {
// Fetch user on mount // Fetch user on mount
+21
View File
@@ -145,4 +145,25 @@ export const Events = {
// Pack events // Pack events
packEnabled: (packId: string) => logEvent("pack_enabled", { pack_id: packId }), packEnabled: (packId: string) => logEvent("pack_enabled", { pack_id: packId }),
packDisabled: (packId: string) => logEvent("pack_disabled", { pack_id: packId }), packDisabled: (packId: string) => logEvent("pack_disabled", { pack_id: packId }),
// App lifecycle events
appOpened: (type: "cold" | "warm") => logEvent("app_opened", { type }),
// Profile events
profileUpdated: (fields: string[]) => logEvent("profile_updated", { fields: fields.join(",") }),
avatarUploaded: () => logEvent("avatar_uploaded"),
// Notification events
notificationPermissionGranted: () => logEvent("notification_permission_granted"),
notificationPermissionDenied: () => logEvent("notification_permission_denied"),
// Relationship events
relationshipEnded: () => logEvent("relationship_ended"),
// Milestone events
firstMatch: () => logEvent("first_match"),
milestoneMatch: (count: number) => logEvent("milestone_match", { match_count: count }),
// Invite events
inviteCodeCopied: () => logEvent("invite_code_copied"),
}; };
+7
View File
@@ -4,6 +4,7 @@ import { Platform } from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { supabase } from "./supabase"; import { supabase } from "./supabase";
import { captureError } from "./sentry"; import { captureError } from "./sentry";
import { Events } from "./analytics";
// Configure notification behavior for foreground // Configure notification behavior for foreground
// We don't show system alerts since the app handles notifications with in-app toasts // We don't show system alerts since the app handles notifications with in-app toasts
@@ -41,6 +42,12 @@ export async function registerForPushNotificationsAsync(): Promise<string | null
if (existingStatus !== "granted") { if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync(); const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status; finalStatus = status;
// Track permission result (only when we actually requested)
if (status === "granted") {
Events.notificationPermissionGranted();
} else {
Events.notificationPermissionDenied();
}
} }
if (finalStatus !== "granted") { if (finalStatus !== "granted") {
+9
View File
@@ -1,5 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { supabase } from "../lib/supabase"; import { supabase } from "../lib/supabase";
import { Events } from "../lib/analytics";
import revenueCatService, { import revenueCatService, {
SubscriptionState, SubscriptionState,
PurchasesPackage, PurchasesPackage,
@@ -87,6 +88,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}, },
signOut: async () => { signOut: async () => {
Events.signOut();
// Clear local state FIRST to ensure UI updates even if Supabase call fails // Clear local state FIRST to ensure UI updates even if Supabase call fails
set({ set({
user: null, user: null,
@@ -337,6 +339,13 @@ export const usePacksStore = create<PacksState>((set, get) => ({
return { success: false, reason: "error" }; return { success: false, reason: "error" };
} }
// Track pack enable/disable
if (newValue) {
Events.packEnabled(packId);
} else {
Events.packDisabled(packId);
}
return { success: true }; return { success: true };
}, },