From dfcf0ba289e3f49be0ad0bef2cad3e242d7fe890 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Wed, 31 Dec 2025 09:40:27 +0000 Subject: [PATCH] Update --- apps/mobile/app/(app)/_layout.tsx | 14 +- apps/mobile/app/(app)/chat/[id].tsx | 50 +++- apps/mobile/app/(app)/onboarding.tsx | 158 +++++++++++- apps/mobile/app/(app)/pairing.tsx | 1 + apps/mobile/app/(app)/profile.tsx | 372 ++++++++++++++++++++++++++- apps/mobile/app/(app)/swipe.tsx | 12 +- apps/mobile/app/_layout.tsx | 14 +- apps/mobile/src/lib/analytics.ts | 21 ++ apps/mobile/src/lib/notifications.ts | 7 + apps/mobile/src/store/index.ts | 9 + 10 files changed, 630 insertions(+), 28 deletions(-) diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index 5aa67c1..1b20de0 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -10,6 +10,7 @@ import { supabase } from "../../src/lib/supabase"; import { isBiometricEnabled } from "../../src/lib/biometricAuth"; import { checkAndRegisterPushToken } from "../../src/lib/notifications"; import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; +import { Events } from "../../src/lib/analytics"; import type { MatchWithQuestion } from "../../src/types"; import type { Database } from "../../src/types/supabase"; @@ -76,7 +77,7 @@ export default function AppLayout() { const router = useRouter(); const segments = useSegments(); const { isAuthenticated, isLoading, user, signOut } = useAuthStore(); - const { newMatchesCount, addMatch } = useMatchStore(); + const { matches, newMatchesCount, addMatch } = useMatchStore(); const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore(); const { fetchEnabledPacks } = usePacksStore(); const { initializeRevenueCat } = useSubscriptionStore(); @@ -256,9 +257,18 @@ export default function AppLayout() { .single(); 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 addMatch(matchWithQuestion); setMatchNotification(matchWithQuestion); + Events.matchCreated(matchWithQuestion.match_type || "unknown"); } } ) @@ -267,7 +277,7 @@ export default function AppLayout() { return () => { supabase.removeChannel(channel); }; - }, [user?.couple_id, addMatch]); + }, [user?.couple_id, addMatch, matches.length]); // Subscribe to realtime message notifications useEffect(() => { diff --git a/apps/mobile/app/(app)/chat/[id].tsx b/apps/mobile/app/(app)/chat/[id].tsx index c8265c5..da40792 100644 --- a/apps/mobile/app/(app)/chat/[id].tsx +++ b/apps/mobile/app/(app)/chat/[id].tsx @@ -23,7 +23,7 @@ export default function ChatScreen() { const matchId = id as string; const router = useRouter(); const insets = useSafeAreaInsets(); - const { user } = useAuthStore(); + const { user, partner } = useAuthStore(); const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore(); const [messages, setMessages] = useState([]); const [match, setMatch] = useState(null); @@ -44,6 +44,7 @@ export default function ChatScreen() { if (matchId) { setActiveMatchId(matchId); markMatchMessagesAsRead(matchId); + Events.matchViewed(); } return () => { isFocusedRef.current = false; @@ -350,7 +351,26 @@ export default function ChatScreen() { > - Chat + + {partner?.avatar_url ? ( + + ) : ( + + + {partner?.name?.[0]?.toUpperCase() || "P"} + + + )} + {partner?.name || "Chat"} + @@ -710,11 +730,33 @@ const styles = StyleSheet.create({ justifyContent: '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: { ...typography.headline, color: colors.text, - flex: 1, - textAlign: 'center', }, headerSpacer: { width: 40, diff --git a/apps/mobile/app/(app)/onboarding.tsx b/apps/mobile/app/(app)/onboarding.tsx index 7a99b38..065dff7 100644 --- a/apps/mobile/app/(app)/onboarding.tsx +++ b/apps/mobile/app/(app)/onboarding.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { View, 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' }, ]; -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() { const router = useRouter(); @@ -48,11 +58,16 @@ export default function OnboardingScreen() { const [stage, setStage] = useState('name'); const [name, setName] = useState(user?.name || ''); const [gender, setGender] = useState(user?.gender || null); + const [usageReason, setUsageReason] = useState(null); const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const inputRef = useRef(null); + useEffect(() => { + Events.onboardingStart(); + }, []); + const handleNameSubmit = () => { if (!name.trim()) { setError('Please enter your name'); @@ -74,10 +89,24 @@ export default function OnboardingScreen() { setError('Please select a gender or skip'); return; } - setStage('explicit'); + 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('explicit'); + Events.onboardingStageComplete('purpose'); + }; + const handleExplicitContinue = () => { setStage('notifications'); Events.onboardingStageComplete('explicit'); @@ -115,6 +144,7 @@ export default function OnboardingScreen() { .update({ name: name.trim(), gender, + usage_reason: usageReason, show_explicit_content: showExplicit, onboarding_completed: true, updated_at: new Date().toISOString(), @@ -260,6 +290,82 @@ export default function OnboardingScreen() { ); + case 'purpose': + return ( + + + + + + What brings you here? + + This helps us personalize your experience + + + + + I'm using Sauci to... + + {PURPOSE_OPTIONS.map((option) => ( + handlePurposeSelect(option.value)} + > + + + + + {option.label} + + + {usageReason === option.value && } + + + ))} + + {error && ( + + + {error} + + )} + + + + + Continue + + + + ); + case 'explicit': return ( - + @@ -456,7 +562,8 @@ export default function OnboardingScreen() { style={styles.backButton} onPress={() => { 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'); }} > @@ -468,6 +575,7 @@ export default function OnboardingScreen() { + @@ -617,6 +725,43 @@ const styles = StyleSheet.create({ color: colors.primary, 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: { flexDirection: 'row', alignItems: 'center', @@ -696,6 +841,9 @@ const styles = StyleSheet.create({ borderBottomWidth: 1, borderBottomColor: colors.glass.border, }, + notificationFeatureLast: { + borderBottomWidth: 0, + }, notificationIcon: { width: 48, height: 48, diff --git a/apps/mobile/app/(app)/pairing.tsx b/apps/mobile/app/(app)/pairing.tsx index 6bb9f52..17b6d97 100755 --- a/apps/mobile/app/(app)/pairing.tsx +++ b/apps/mobile/app/(app)/pairing.tsx @@ -136,6 +136,7 @@ export default function PairingScreen() { const copyToClipboard = async () => { if (couple?.invite_code) { await Clipboard.setStringAsync(couple.invite_code); + Events.inviteCodeCopied(); Alert.alert("Copied", "Invite code copied to clipboard"); } }; diff --git a/apps/mobile/app/(app)/profile.tsx b/apps/mobile/app/(app)/profile.tsx index 82e53ed..39af3e5 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, 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 { useAuthStore, useMatchStore, useMessageStore, usePacksStore, useSubscriptionStore } from "../../src/store"; import { Ionicons } from "@expo/vector-icons"; @@ -6,6 +6,9 @@ import { LinearGradient } from "expo-linear-gradient"; import Animated, { FadeInDown } from "react-native-reanimated"; import { supabase } from "../../src/lib/supabase"; 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 { isBiometricAvailable, isBiometricEnabled, @@ -16,6 +19,7 @@ import { router } from "expo-router"; import { GradientBackground, GlassCard } from "../../src/components/ui"; import { FeedbackModal } from "../../src/components/FeedbackModal"; import { Paywall } from "../../src/components/Paywall"; +import { Events } from "../../src/lib/analytics"; import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; const MAX_CONTENT_WIDTH = 500; @@ -40,12 +44,23 @@ export default function ProfileScreen() { const [biometricEnabled, setBiometricEnabledState] = useState(false); const [biometricType, setBiometricType] = useState("Face ID"); 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 const hasPremiumAccess = useMemo(() => { return 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 useEffect(() => { 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 = () => { 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) await useAuthStore.getState().fetchUser(); + Events.relationshipEnded(); setShowDeleteModal(false); setDeleteConfirmText(""); Alert.alert("Success", "All relationship data has been deleted."); @@ -227,20 +418,90 @@ export default function ProfileScreen() { > - - - - {user?.name?.[0]?.toUpperCase() || "U"} - - - + + {user?.avatar_url ? ( + + ) : ( + + + {user?.name?.[0]?.toUpperCase() || "U"} + + + )} + + {isUploadingAvatar ? ( + + + + ) : ( + + + + )} + - {user?.name || "User"} + {isEditingName ? ( + + + + + + + + {isUpdatingName ? ( + + ) : ( + + )} + + + + ) : ( + setIsEditingName(true)} + activeOpacity={0.7} + > + {user?.name || "User"} + + + + + )} {user?.email} @@ -764,15 +1025,100 @@ const styles = StyleSheet.create({ fontWeight: "bold", 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: { alignItems: "center", marginTop: spacing.lg, }, + nameContainer: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + }, name: { ...typography.title2, color: colors.text, 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: { ...typography.subhead, color: colors.textSecondary, diff --git a/apps/mobile/app/(app)/swipe.tsx b/apps/mobile/app/(app)/swipe.tsx index 95660b1..64d13a3 100755 --- a/apps/mobile/app/(app)/swipe.tsx +++ b/apps/mobile/app/(app)/swipe.tsx @@ -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 { useLocalSearchParams, router } from "expo-router"; import { Ionicons } from "@expo/vector-icons"; @@ -22,12 +22,22 @@ export default function SwipeScreen() { const [showTutorial, setShowTutorial] = useState(false); const { enabledPackIds, fetchPacks } = usePacksStore(); const { partner, couple } = useAuthStore(); + const hasTrackedExhausted = useRef(false); useEffect(() => { fetchPacks().then(() => fetchQuestions()); checkTutorial(); + hasTrackedExhausted.current = false; // Reset when pack changes }, [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 seen = await hasSeenSwipeTutorial(); if (!seen) { diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index d1f8113..3bf709c 100755 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -5,8 +5,8 @@ import "../src/polyfills/web"; import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry"; initSentry(); -// Initialize Firebase Analytics -import { initAnalytics, setUserId, clearUserId } from "../src/lib/analytics"; +// Initialize Analytics +import { initAnalytics, setUserId, clearUserId, logScreenView } from "../src/lib/analytics"; initAnalytics(); // 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"; // GestureHandlerRootView is only needed on native platforms // On web, we use a simple View to avoid findNodeHandle errors @@ -49,6 +49,14 @@ const queryClient = new QueryClient(); export default function RootLayout() { const { fetchUser, setUser } = useAuthStore(); + const pathname = usePathname(); + + // Track screen views + useEffect(() => { + if (pathname) { + logScreenView(pathname); + } + }, [pathname]); useEffect(() => { // Fetch user on mount diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index 8b6a26b..3f1a8d4 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -145,4 +145,25 @@ export const Events = { // Pack events packEnabled: (packId: string) => logEvent("pack_enabled", { 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"), }; diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 1d7e602..498fbe5 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -4,6 +4,7 @@ import { Platform } from "react-native"; import { router } from "expo-router"; import { supabase } from "./supabase"; import { captureError } from "./sentry"; +import { Events } from "./analytics"; // Configure notification behavior for foreground // We don't show system alerts since the app handles notifications with in-app toasts @@ -41,6 +42,12 @@ export async function registerForPushNotificationsAsync(): Promise((set, get) => ({ }, signOut: async () => { + Events.signOut(); // Clear local state FIRST to ensure UI updates even if Supabase call fails set({ user: null, @@ -337,6 +339,13 @@ export const usePacksStore = create((set, get) => ({ return { success: false, reason: "error" }; } + // Track pack enable/disable + if (newValue) { + Events.packEnabled(packId); + } else { + Events.packDisabled(packId); + } + return { success: true }; },