mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
1 parent
d1a2032724
commit
dfcf0ba289
10 files changed
+630
-28
No files matched your search
@@ -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(() => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user