mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10: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 { 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(() => {
|
||||
|
||||
@@ -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<Message[]>([]);
|
||||
const [match, setMatch] = useState<any>(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() {
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
</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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Stage>('name');
|
||||
const [name, setName] = useState(user?.name || '');
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<TextInput>(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() {
|
||||
</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':
|
||||
return (
|
||||
<Animated.View
|
||||
@@ -401,7 +507,7 @@ export default function OnboardingScreen() {
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.notificationFeature}>
|
||||
<View style={[styles.notificationFeature, styles.notificationFeatureLast]}>
|
||||
<View style={styles.notificationIcon}>
|
||||
<Ionicons name="chatbubble" size={24} color={colors.primary} />
|
||||
</View>
|
||||
@@ -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() {
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressDot, stage === 'name' && 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 === 'notifications' && styles.progressDotActive]} />
|
||||
</View>
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<GlassCard variant="elevated">
|
||||
<View style={styles.profileContent}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.avatarGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
<TouchableOpacity
|
||||
onPress={handleAvatarPress}
|
||||
disabled={isUploadingAvatar}
|
||||
activeOpacity={0.7}
|
||||
style={styles.avatarTouchable}
|
||||
>
|
||||
<View style={styles.avatarInner}>
|
||||
<Text style={styles.avatarText}>
|
||||
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.avatarGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
{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}>
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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<string | null
|
||||
if (existingStatus !== "granted") {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
finalStatus = status;
|
||||
// Track permission result (only when we actually requested)
|
||||
if (status === "granted") {
|
||||
Events.notificationPermissionGranted();
|
||||
} else {
|
||||
Events.notificationPermissionDenied();
|
||||
}
|
||||
}
|
||||
|
||||
if (finalStatus !== "granted") {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { Events } from "../lib/analytics";
|
||||
import revenueCatService, {
|
||||
SubscriptionState,
|
||||
PurchasesPackage,
|
||||
@@ -87,6 +88,7 @@ export const useAuthStore = create<AuthState>((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<PacksState>((set, get) => ({
|
||||
return { success: false, reason: "error" };
|
||||
}
|
||||
|
||||
// Track pack enable/disable
|
||||
if (newValue) {
|
||||
Events.packEnabled(packId);
|
||||
} else {
|
||||
Events.packDisabled(packId);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user