diff --git a/app/(tabs)/forum.tsx b/app/(tabs)/forum.tsx index 74cabae..cbf25b5 100644 --- a/app/(tabs)/forum.tsx +++ b/app/(tabs)/forum.tsx @@ -1,23 +1,17 @@ import React, { useState, useEffect } from 'react'; import { View, Text, Image, TextInput, TouchableOpacity, FlatList, SafeAreaView, ActivityIndicator, Alert } from 'react-native'; -import { Feather, FontAwesome, Ionicons } from '@expo/vector-icons'; -import { router } from 'expo-router'; +import { Feather, FontAwesome } from '@expo/vector-icons'; +import { router, useFocusEffect } from 'expo-router'; import { useAuth } from '../../context/auth-context'; -import { getFoods } from '../../services/data/foods'; -import { - getLikesCount, - getSavesCount, - getCommentsCount, - createLike, - deleteLike, - createSave, - deleteSave, - checkUserLiked, - checkUserSaved -} from '../../services/data/forum'; -import { getProfile } from '../../services/data/profile'; -import { Food, Profile } from '../../types/index'; import { supabase } from '../../services/supabase'; +import { + useFoods, + useFoodStats, + useFoodCreators, + useUserInteractions, + useLikeMutation, + useSaveMutation +} from '../../hooks/use-foods'; // Categories for filtering const categories = [ @@ -37,13 +31,8 @@ export default function ForumScreen() { const { isAuthenticated } = useAuth(); const [currentUserId, setCurrentUserId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); - const [foods, setFoods] = useState([]); - const [loading, setLoading] = useState(true); const [selectedCategory, setSelectedCategory] = useState(''); const [selectedSort, setSelectedSort] = useState('rating'); - const [foodStats, setFoodStats] = useState<{[key: string]: {likes: number, saves: number, comments: number}}>({}); - const [foodCreators, setFoodCreators] = useState<{[key: string]: Profile}>({}); - const [userInteractions, setUserInteractions] = useState<{[key: string]: {liked: boolean, saved: boolean}}>({}); // Get current user ID from Supabase session useEffect(() => { @@ -61,169 +50,53 @@ export default function ForumScreen() { getCurrentUser(); }, [isAuthenticated]); - // Set up real-time subscription for likes and saves - useEffect(() => { - if (!isAuthenticated) return; - - const likesSubscription = supabase - .channel('food_likes_changes') - .on('postgres_changes', { - event: '*', - schema: 'public', - table: 'food_likes' - }, () => { - // Refresh stats when changes occur - loadFoods(); - }) - .subscribe(); - - const savesSubscription = supabase - .channel('food_saves_changes') - .on('postgres_changes', { - event: '*', - schema: 'public', - table: 'food_saves' - }, () => { - // Refresh stats when changes occur - loadFoods(); - }) - .subscribe(); - - return () => { - supabase.removeChannel(likesSubscription); - supabase.removeChannel(savesSubscription); - }; - }, [isAuthenticated]); + // Use React Query hooks + const { + data: foods = [], + isLoading: isLoadingFoods, + refetch: refetchFoods + } = useFoods(selectedCategory, searchQuery, selectedSort); - useEffect(() => { - loadFoods(); - }, [selectedCategory, selectedSort, currentUserId]); + const foodIds = foods.map(food => food.id); - const loadFoods = async () => { - setLoading(true); - try { - // In a real app, you would filter by category and sort accordingly - const { data, error } = await getFoods(undefined, true, searchQuery); - - if (error) { - console.error('Error loading foods:', error); - return; - } - - if (data) { - // Sort data based on selectedSort - let sortedData = [...data]; - if (selectedSort === 'rating') { - // Assuming higher calories means higher rating for demo purposes - sortedData.sort((a, b) => (b.calories ?? 0) - (a.calories ?? 0)); - } else if (selectedSort === 'newest') { - sortedData.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); - } else if (selectedSort === 'best') { - // Assuming higher ingredient_count means better for demo purposes - sortedData.sort((a, b) => (b.ingredient_count ?? 0) - (a.ingredient_count ?? 0)); - } - - setFoods(sortedData.map(food => ({ - ...food, - description: food.description || '', // Ensure description is always a string - ingredient_count: food.ingredient_count ?? 0, // Ensure ingredient_count is always a number - calories: food.calories ?? 0, // Ensure calories is always a number - image_url: food.image_url || '', // Ensure image_url is always a string - }))); - - // Load stats for each food - const statsPromises = sortedData.map(async (food) => { - const [likesRes, savesRes, commentsRes] = await Promise.all([ - getLikesCount(food.id), - getSavesCount(food.id), - getCommentsCount(food.id) - ]); - - return { - foodId: food.id, - likes: likesRes.count || 0, - saves: savesRes.count || 0, - comments: commentsRes.count || 0 - }; - }); - - const stats = await Promise.all(statsPromises); - const statsMap = stats.reduce((acc, stat) => { - acc[stat.foodId] = { - likes: stat.likes, - saves: stat.saves, - comments: stat.comments - }; - return acc; - }, {} as {[key: string]: {likes: number, saves: number, comments: number}}); - - setFoodStats(statsMap); - - // Load creator profiles - const creatorIds = sortedData - .filter(food => food.created_by) - .map(food => food.created_by as string); - - const uniqueCreatorIds = [...new Set(creatorIds)]; - - const creatorProfiles: {[key: string]: Profile} = {}; - - for (const creatorId of uniqueCreatorIds) { - const { data: profile } = await getProfile(creatorId); - if (profile) { - creatorProfiles[creatorId] = profile; - } - } - - setFoodCreators(creatorProfiles); - - // Check user interactions if authenticated - if (isAuthenticated && currentUserId) { - const interactionsPromises = sortedData.map(async (food) => { - const [likedRes, savedRes] = await Promise.all([ - checkUserLiked(food.id, currentUserId), - checkUserSaved(food.id, currentUserId) - ]); - - return { - foodId: food.id, - liked: !!likedRes.data, - saved: !!savedRes.data - }; - }); - - const interactions = await Promise.all(interactionsPromises); - const interactionsMap = interactions.reduce((acc, interaction) => { - acc[interaction.foodId] = { - liked: interaction.liked, - saved: interaction.saved - }; - return acc; - }, {} as {[key: string]: {liked: boolean, saved: boolean}}); - - setUserInteractions(interactionsMap); - } - } - } catch (error) { - console.error('Error:', error); - } finally { - setLoading(false); - } - }; + const { + data: foodStats = {}, + isLoading: isLoadingStats + } = useFoodStats(foodIds); + + const creatorIds = foods + .filter(food => food.created_by) + .map(food => food.created_by as string); + + const { + data: foodCreators = {}, + isLoading: isLoadingCreators + } = useFoodCreators(creatorIds); + + const { + data: userInteractions = {}, + isLoading: isLoadingInteractions + } = useUserInteractions(foodIds, currentUserId); + + const likeMutation = useLikeMutation(); + const saveMutation = useSaveMutation(); + + // Refetch data when the screen comes into focus + useFocusEffect( + React.useCallback(() => { + refetchFoods(); + }, [refetchFoods]) + ); const handleSearch = (text: string) => { setSearchQuery(text); - // Debounce search for better performance - setTimeout(() => { - loadFoods(); - }, 500); }; -const navigateToPostDetail = (food: Food) => { - router.push(`/post-detail/${food.id}`); -}; + const navigateToPostDetail = (food: { id: string }) => { + router.push(`/post-detail/${food.id}`); + }; - const handleLike = async (food: Food) => { + const handleLike = async (food: { id: string }) => { if (!isAuthenticated || !currentUserId) { Alert.alert('Authentication Required', 'Please log in to like posts.'); return; @@ -232,77 +105,18 @@ const navigateToPostDetail = (food: Food) => { try { const isLiked = userInteractions[food.id]?.liked || false; - // Optimistically update UI - setUserInteractions(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - liked: !isLiked - } - })); - - setFoodStats(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - likes: isLiked ? Math.max(0, prev[food.id].likes - 1) : prev[food.id].likes + 1 - } - })); - - if (isLiked) { - const { error } = await deleteLike(food.id, currentUserId); - if (error) { - console.error('Error deleting like:', error); - // Revert optimistic update if there's an error - setUserInteractions(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - liked: true - } - })); - - setFoodStats(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - likes: prev[food.id].likes + 1 - } - })); - - Alert.alert('Error', 'Failed to unlike. Please try again.'); - } - } else { - const { error } = await createLike(food.id, currentUserId); - if (error) { - console.error('Error creating like:', error); - // Revert optimistic update if there's an error - setUserInteractions(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - liked: false - } - })); - - setFoodStats(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - likes: Math.max(0, prev[food.id].likes - 1) - } - })); - - Alert.alert('Error', 'Failed to like. Please try again.'); - } - } + likeMutation.mutate({ + foodId: food.id, + userId: currentUserId, + isLiked + }); } catch (error) { console.error('Error toggling like:', error); Alert.alert('Error', 'Failed to update like. Please try again.'); } }; - const handleSave = async (food: Food) => { + const handleSave = async (food: { id: string }) => { if (!isAuthenticated || !currentUserId) { Alert.alert('Authentication Required', 'Please log in to save posts.'); return; @@ -311,77 +125,18 @@ const navigateToPostDetail = (food: Food) => { try { const isSaved = userInteractions[food.id]?.saved || false; - // Optimistically update UI - setUserInteractions(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - saved: !isSaved - } - })); - - setFoodStats(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - saves: isSaved ? Math.max(0, prev[food.id].saves - 1) : prev[food.id].saves + 1 - } - })); - - if (isSaved) { - const { error } = await deleteSave(food.id, currentUserId); - if (error) { - console.error('Error deleting save:', error); - // Revert optimistic update if there's an error - setUserInteractions(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - saved: true - } - })); - - setFoodStats(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - saves: prev[food.id].saves + 1 - } - })); - - Alert.alert('Error', 'Failed to unsave. Please try again.'); - } - } else { - const { error } = await createSave(food.id, currentUserId); - if (error) { - console.error('Error creating save:', error); - // Revert optimistic update if there's an error - setUserInteractions(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - saved: false - } - })); - - setFoodStats(prev => ({ - ...prev, - [food.id]: { - ...prev[food.id], - saves: Math.max(0, prev[food.id].saves - 1) - } - })); - - Alert.alert('Error', 'Failed to save. Please try again.'); - } - } + saveMutation.mutate({ + foodId: food.id, + userId: currentUserId, + isSaved + }); } catch (error) { console.error('Error toggling save:', error); Alert.alert('Error', 'Failed to update save. Please try again.'); } }; - const renderFoodItem = ({ item }: { item: Food }) => { + const renderFoodItem = ({ item }: { item: any }) => { // Get stats for this food const stats = foodStats[item.id] || { likes: 0, saves: 0, comments: 0 }; @@ -484,6 +239,8 @@ const navigateToPostDetail = (food: Food) => { ); }; + const isLoading = isLoadingFoods || isLoadingStats || isLoadingCreators || isLoadingInteractions; + return ( {/* Search Bar */} @@ -537,7 +294,7 @@ const navigateToPostDetail = (food: Food) => { {/* Food Posts */} - {loading ? ( + {isLoading ? ( diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx index f5f8806..5332c71 100644 --- a/app/(tabs)/profile.tsx +++ b/app/(tabs)/profile.tsx @@ -140,7 +140,7 @@ export default function ProfileScreen() { refetchMyRecipes() } else if (tab === "Likes") { refetchLikes() - } else if (tab === "Bookmark") { + } else if (tab === "Bookmarks") { refetchBookmarks() } } @@ -226,7 +226,7 @@ export default function ProfileScreen() { return { data: myRecipesData, isLoading: isMyRecipesLoading, error: myRecipesError } case "Likes": return { data: likesData, isLoading: isLikesLoading, error: likesError } - case "Bookmark": + case "Bookmarks": return { data: bookmarksData, isLoading: isBookmarksLoading, error: bookmarksError } default: return { data: myRecipesData, isLoading: isMyRecipesLoading, error: myRecipesError } @@ -334,7 +334,7 @@ export default function ProfileScreen() { {/* Tab Navigation */} - {["My Recipes", "Likes", "Bookmark"].map((tab) => ( + {["My Recipes", "Likes", "Bookmarks"].map((tab) => ( (null); - const [food, setFood] = useState(null); - const [foodCreator, setFoodCreator] = useState(null); - const [ingredients, setIngredients] = useState([]); - const [nutrients, setNutrients] = useState(null); - const [comments, setComments] = useState([]); - const [loading, setLoading] = useState(true); - const [isLiked, setIsLiked] = useState(false); - const [isSaved, setIsSaved] = useState(false); - const [showReviews, setShowReviews] = useState(true); const [commentText, setCommentText] = useState(''); const [submittingComment, setSubmittingComment] = useState(false); - const [stats, setStats] = useState({ - likes: 0, - saves: 0, - comments: 0 - }); + const [showReviews, setShowReviews] = useState(true); // Get current user ID from Supabase session useEffect(() => { @@ -62,14 +52,128 @@ export default function PostDetailScreen() { getCurrentUser(); }, [isAuthenticated]); - useEffect(() => { - if (foodId) { - console.log('Loading food details for ID:', foodId); - loadFoodDetails(); - } else { - console.error('No food ID provided'); - } - }, [foodId]); + // Fetch food details + const { + data: food, + isLoading: isLoadingFood, + error: foodError + } = useQuery({ + queryKey: queryKeys.foodDetails(foodId), + queryFn: async () => { + const { data, error } = await supabase + .from('foods') + .select('*') + .eq('id', foodId) + .single(); + + if (error) throw error; + + return { + ...data, + description: data.description || '', + ingredient_count: data.ingredient_count ?? 0, + calories: data.calories ?? 0, + image_url: data.image_url || '', + }; + }, + enabled: !!foodId, + }); + + // Fetch food creator + const { + data: foodCreator, + isLoading: isLoadingCreator + } = useQuery({ + queryKey: ['food-creator', food?.created_by], + queryFn: async () => { + if (!food?.created_by) return null; + + const { data, error } = await getProfile(food.created_by); + + if (error) throw error; + + return data; + }, + enabled: !!food?.created_by, + }); + + // Fetch food stats + const { + data: stats = { likes: 0, saves: 0, comments: 0 }, + isLoading: isLoadingStats, + refetch: refetchStats + } = useQuery({ + queryKey: ['food-stats', foodId], + queryFn: async () => { + const [likesRes, savesRes, commentsRes] = await Promise.all([ + getLikesCount(foodId), + getSavesCount(foodId), + getCommentsCount(foodId) + ]); + + return { + likes: likesRes.count || 0, + saves: savesRes.count || 0, + comments: commentsRes.count || 0 + }; + }, + enabled: !!foodId, + }); + + // Fetch user interactions + const { + data: interactions = { liked: false, saved: false }, + isLoading: isLoadingInteractions, + refetch: refetchInteractions + } = useQuery({ + queryKey: ['user-interactions', foodId, currentUserId], + queryFn: async () => { + if (!currentUserId) return { liked: false, saved: false }; + + const [likedRes, savedRes] = await Promise.all([ + checkUserLiked(foodId, currentUserId), + checkUserSaved(foodId, currentUserId) + ]); + + return { + liked: !!likedRes.data, + saved: !!savedRes.data + }; + }, + enabled: !!foodId && !!currentUserId, + }); + + // Fetch comments + const { + data: comments = [], + isLoading: isLoadingComments, + refetch: refetchComments + } = useQuery({ + queryKey: queryKeys.foodComments(foodId), + queryFn: async () => { + const { data, error } = await getComments(foodId); + + if (error) throw error; + + return data || []; + }, + enabled: !!foodId, + }); + + // Set up mutations + const likeMutation = useLikeMutation(); + const saveMutation = useSaveMutation(); + + const commentMutation = useMutation({ + mutationFn: async ({ foodId, userId, content }: { foodId: string, userId: string, content: string }) => { + return createComment(foodId, userId, content); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.foodComments(foodId) }); + queryClient.invalidateQueries({ queryKey: ['food-stats', foodId] }); + setCommentText(''); + }, + }); // Set up real-time subscription for comments useEffect(() => { @@ -84,167 +188,55 @@ export default function PostDetailScreen() { schema: 'public', table: 'food_comments', filter: `food_id=eq.${foodId}` - }, (payload) => { - console.log('Comment change detected:', payload); - // Refresh comments when changes occur - refreshComments(); + }, () => { + console.log('Comment change detected, refreshing comments'); + refetchComments(); + refetchStats(); }) .subscribe(); return () => { supabase.removeChannel(subscription); }; - }, [foodId]); + }, [foodId, refetchComments, refetchStats]); - // Check if user has liked/saved when user ID changes + // Set up real-time subscription for likes and saves useEffect(() => { - if (foodId && currentUserId && food) { - checkUserInteractions(); - } - }, [currentUserId, foodId, food]); - - const checkUserInteractions = async () => { - if (!currentUserId || !foodId) return; + if (!foodId) return; - try { - console.log('Checking user interactions with user ID:', currentUserId); + const likesSubscription = supabase + .channel(`food_likes:${foodId}`) + .on('postgres_changes', { + event: '*', + schema: 'public', + table: 'food_likes', + filter: `food_id=eq.${foodId}` + }, () => { + console.log('Like change detected, refreshing stats and interactions'); + refetchStats(); + refetchInteractions(); + }) + .subscribe(); - const [likedRes, savedRes] = await Promise.all([ - checkUserLiked(foodId, currentUserId), - checkUserSaved(foodId, currentUserId) - ]); + const savesSubscription = supabase + .channel(`food_saves:${foodId}`) + .on('postgres_changes', { + event: '*', + schema: 'public', + table: 'food_saves', + filter: `food_id=eq.${foodId}` + }, () => { + console.log('Save change detected, refreshing stats and interactions'); + refetchStats(); + refetchInteractions(); + }) + .subscribe(); - console.log('User liked:', !!likedRes.data); - console.log('User saved:', !!savedRes.data); - - setIsLiked(!!likedRes.data); - setIsSaved(!!savedRes.data); - } catch (error) { - console.error('Error checking user interactions:', error); - } - }; - - const refreshComments = async () => { - if (!foodId) { - console.error('Cannot refresh comments: No food ID'); - return; - } - - try { - console.log(`Refreshing comments for food_id: ${foodId}`); - - const { data: commentsData, error } = await getComments(foodId); - - if (error) { - console.error('Error refreshing comments:', error); - return; - } - - if (commentsData) { - console.log(`Refreshed ${commentsData.length} comments for food_id: ${foodId}`); - setComments(commentsData); - } - - const { count } = await getCommentsCount(foodId); - setStats(prev => ({ ...prev, comments: count || 0 })); - } catch (error) { - console.error('Error refreshing comments:', error); - } - }; - - const loadFoodDetails = async () => { - if (!foodId) { - console.error('Cannot load food details: No food ID'); - return; - } - - setLoading(true); - try { - console.log('Loading food details for ID:', foodId); - - // Get specific food by ID - const { data: foodData, error: foodError } = await supabase - .from('foods') - .select('*') - .eq('id', foodId) - .single(); - - if (foodError) { - console.error('Error loading food:', foodError); - return; - } - - if (foodData) { - const foodItem = { - ...foodData, - description: foodData.description || '', - ingredient_count: foodData.ingredient_count ?? 0, - calories: foodData.calories ?? 0, - image_url: foodData.image_url || '', - }; - - console.log('Food loaded:', foodItem.name); - setFood(foodItem); - - // Get food creator profile - if (foodItem.created_by) { - console.log('Loading creator profile for:', foodItem.created_by); - const { data: creatorProfile } = await getProfile(foodItem.created_by); - if (creatorProfile) { - setFoodCreator(creatorProfile); - } - } - - // Get ingredients - const { data: ingredientsData, error: ingredientsError } = await getIngredients(foodId); - - if (!ingredientsError && ingredientsData) { - setIngredients(ingredientsData); - } - - // Get nutrients - const { data: nutrientsData, error: nutrientsError } = await getNutrients(foodId); - - if (!nutrientsError && nutrientsData) { - setNutrients(nutrientsData); - } - - // Get comments for this specific food ID - const { data: commentsData, error: commentsError } = await getComments(foodId); - - if (commentsError) { - console.error('Error loading comments:', commentsError); - } else if (commentsData) { - console.log(`Loaded ${commentsData.length} comments for food_id: ${foodId}`); - setComments(commentsData); - } - - // Get stats - const [likesRes, savesRes, commentsRes] = await Promise.all([ - getLikesCount(foodId), - getSavesCount(foodId), - getCommentsCount(foodId) - ]); - - console.log('Stats loaded:', { - likes: likesRes.count || 0, - saves: savesRes.count || 0, - comments: commentsRes.count || 0 - }); - - setStats({ - likes: likesRes.count || 0, - saves: savesRes.count || 0, - comments: commentsRes.count || 0 - }); - } - } catch (error) { - console.error('Error loading food details:', error); - Alert.alert('Error', 'Failed to load food details. Please try again.'); - } finally { - setLoading(false); - } - }; + return () => { + supabase.removeChannel(likesSubscription); + supabase.removeChannel(savesSubscription); + }; + }, [foodId, refetchStats, refetchInteractions]); const handleLike = async () => { if (!isAuthenticated || !currentUserId || !food) { @@ -253,42 +245,13 @@ export default function PostDetailScreen() { } try { - console.log('Toggling like with user ID:', currentUserId, 'and food ID:', foodId); - - // Optimistically update UI - setIsLiked(!isLiked); - setStats(prev => ({ - ...prev, - likes: isLiked ? Math.max(0, prev.likes - 1) : prev.likes + 1 - })); - - if (isLiked) { - const { error } = await deleteLike(foodId, currentUserId); - if (error) { - console.error('Error deleting like:', error); - // Revert optimistic update if there's an error - setIsLiked(true); - setStats(prev => ({ ...prev, likes: prev.likes + 1 })); - Alert.alert('Error', 'Failed to unlike. Please try again.'); - } - } else { - const { error } = await createLike(foodId, currentUserId); - if (error) { - console.error('Error creating like:', error); - // Revert optimistic update if there's an error - setIsLiked(false); - setStats(prev => ({ ...prev, likes: Math.max(0, prev.likes - 1) })); - Alert.alert('Error', 'Failed to like. Please try again.'); - } - } + likeMutation.mutate({ + foodId, + userId: currentUserId, + isLiked: interactions.liked + }); } catch (error) { console.error('Error toggling like:', error); - // Revert optimistic update if there's an error - setIsLiked(!isLiked); - setStats(prev => ({ - ...prev, - likes: !isLiked ? Math.max(0, prev.likes - 1) : prev.likes + 1 - })); Alert.alert('Error', 'Failed to update like. Please try again.'); } }; @@ -300,42 +263,13 @@ export default function PostDetailScreen() { } try { - console.log('Toggling save with user ID:', currentUserId, 'and food ID:', foodId); - - // Optimistically update UI - setIsSaved(!isSaved); - setStats(prev => ({ - ...prev, - saves: isSaved ? Math.max(0, prev.saves - 1) : prev.saves + 1 - })); - - if (isSaved) { - const { error } = await deleteSave(foodId, currentUserId); - if (error) { - console.error('Error deleting save:', error); - // Revert optimistic update if there's an error - setIsSaved(true); - setStats(prev => ({ ...prev, saves: prev.saves + 1 })); - Alert.alert('Error', 'Failed to unsave. Please try again.'); - } - } else { - const { error } = await createSave(foodId, currentUserId); - if (error) { - console.error('Error creating save:', error); - // Revert optimistic update if there's an error - setIsSaved(false); - setStats(prev => ({ ...prev, saves: Math.max(0, prev.saves - 1) })); - Alert.alert('Error', 'Failed to save. Please try again.'); - } - } + saveMutation.mutate({ + foodId, + userId: currentUserId, + isSaved: interactions.saved + }); } catch (error) { console.error('Error toggling save:', error); - // Revert optimistic update if there's an error - setIsSaved(!isSaved); - setStats(prev => ({ - ...prev, - saves: !isSaved ? Math.max(0, prev.saves - 1) : prev.saves + 1 - })); Alert.alert('Error', 'Failed to update save. Please try again.'); } }; @@ -350,23 +284,11 @@ export default function PostDetailScreen() { setSubmittingComment(true); try { - console.log('Submitting comment with user ID:', currentUserId, 'and food ID:', foodId); - - const { error } = await createComment(foodId, currentUserId, commentText.trim()); - - if (error) { - console.error('Error creating comment:', error); - Alert.alert('Error', 'Failed to submit comment. Please try again.'); - return; - } - - // Clear comment text - setCommentText(''); - - // Refresh comments - await refreshComments(); - - console.log('Comment submitted successfully'); + await commentMutation.mutateAsync({ + foodId, + userId: currentUserId, + content: commentText.trim() + }); } catch (error) { console.error('Error submitting comment:', error); Alert.alert('Error', 'Failed to submit comment. Please try again.'); @@ -375,7 +297,9 @@ export default function PostDetailScreen() { } }; - if (loading) { + const isLoading = isLoadingFood || isLoadingCreator || isLoadingStats || isLoadingInteractions || isLoadingComments; + + if (isLoading) { return ( @@ -385,7 +309,7 @@ export default function PostDetailScreen() { ); } - if (!food) { + if (foodError || !food) { return ( @@ -436,13 +360,13 @@ export default function PostDetailScreen() { ) : ( - {foodCreator?.username?.charAt(0).toUpperCase() || '?'} + {foodCreator?.username?.charAt(0).toUpperCase() || food.created_by?.charAt(0).toUpperCase() || '?'} )} - {foodCreator?.username || foodCreator?.full_name || 'Unknown Chef'} + {foodCreator?.username || foodCreator?.full_name || 'Chef'} @@ -471,9 +395,13 @@ export default function PostDetailScreen() { {/* Interaction buttons */} - - - {stats.comments} + + + + {stats.likes} @@ -481,16 +409,9 @@ export default function PostDetailScreen() { {stats.comments} - - - {stats.likes} - - + @@ -518,14 +439,14 @@ export default function PostDetailScreen() { ) : ( - {comment.user?.username?.charAt(0).toUpperCase() || '?'} + {comment.user?.username?.charAt(0).toUpperCase() || comment.user_id?.charAt(0).toUpperCase() || '?'} )} - {comment.user?.username || comment.user?.full_name || 'Unknown User'} + {comment.user?.username || comment.user?.full_name || 'User'} {new Date(comment.created_at).toLocaleDateString()} diff --git a/hooks/use-foods.ts b/hooks/use-foods.ts new file mode 100644 index 0000000..2b36b2a --- /dev/null +++ b/hooks/use-foods.ts @@ -0,0 +1,287 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getFoods } from '../services/data/foods'; +import { + getLikesCount, + getSavesCount, + getCommentsCount, + createLike, + deleteLike, + createSave, + deleteSave, + checkUserLiked, + checkUserSaved +} from '../services/data/forum'; +import { getProfile } from '../services/data/profile'; +import { Food, Profile } from '../types/index'; + +// Query keys +export const queryKeys = { + foods: 'foods', + foodStats: 'food-stats', + foodCreators: 'food-creators', + userInteractions: 'user-interactions', + foodDetails: (id: string) => ['food-details', id], + foodComments: (id: string) => ['food-comments', id], +}; + +// Hook to fetch foods +export function useFoods(category?: string, search?: string, sort?: string) { + return useQuery({ + queryKey: [queryKeys.foods, category, search, sort], + queryFn: async () => { + const { data, error } = await getFoods(category, true, search); + + if (error) { + throw error; + } + + if (!data) { + return []; + } + + let sortedData = [...data]; + + if (sort === 'rating') { + sortedData.sort((a, b) => (b.calories ?? 0) - (a.calories ?? 0)); + } else if (sort === 'newest') { + sortedData.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + } else if (sort === 'best') { + sortedData.sort((a, b) => (b.ingredient_count ?? 0) - (a.ingredient_count ?? 0)); + } + + return sortedData.map(food => ({ + ...food, + description: food.description || '', + ingredient_count: food.ingredient_count ?? 0, + calories: food.calories ?? 0, + image_url: food.image_url || '', + })); + }, + }); +} + +// Hook to fetch food stats +export function useFoodStats(foodIds: string[]) { + return useQuery({ + queryKey: [queryKeys.foodStats, foodIds], + queryFn: async () => { + if (!foodIds.length) return {}; + + const statsPromises = foodIds.map(async (foodId) => { + const [likesRes, savesRes, commentsRes] = await Promise.all([ + getLikesCount(foodId), + getSavesCount(foodId), + getCommentsCount(foodId) + ]); + + return { + foodId, + likes: likesRes.count || 0, + saves: savesRes.count || 0, + comments: commentsRes.count || 0 + }; + }); + + const stats = await Promise.all(statsPromises); + + return stats.reduce((acc, stat) => { + acc[stat.foodId] = { + likes: stat.likes, + saves: stat.saves, + comments: stat.comments + }; + return acc; + }, {} as Record); + }, + enabled: foodIds.length > 0, + }); +} + +// Hook to fetch food creators +export function useFoodCreators(creatorIds: string[]) { + return useQuery({ + queryKey: [queryKeys.foodCreators, creatorIds], + queryFn: async () => { + if (!creatorIds.length) return {}; + + const uniqueCreatorIds = [...new Set(creatorIds)]; + const creatorProfiles: Record = {}; + + for (const creatorId of uniqueCreatorIds) { + const { data: profile } = await getProfile(creatorId); + if (profile) { + creatorProfiles[creatorId] = profile; + } + } + + return creatorProfiles; + }, + enabled: creatorIds.length > 0, + }); +} + +// Hook to fetch user interactions +export function useUserInteractions(foodIds: string[], userId: string | null) { + return useQuery({ + queryKey: [queryKeys.userInteractions, foodIds, userId], + queryFn: async () => { + if (!foodIds.length || !userId) return {}; + + const interactionsPromises = foodIds.map(async (foodId) => { + const [likedRes, savedRes] = await Promise.all([ + checkUserLiked(foodId, userId), + checkUserSaved(foodId, userId) + ]); + + return { + foodId, + liked: !!likedRes.data, + saved: !!savedRes.data + }; + }); + + const interactions = await Promise.all(interactionsPromises); + + return interactions.reduce((acc, interaction) => { + acc[interaction.foodId] = { + liked: interaction.liked, + saved: interaction.saved + }; + return acc; + }, {} as Record); + }, + enabled: foodIds.length > 0 && !!userId, + }); +} + +// Hook to like/unlike a food +export function useLikeMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ foodId, userId, isLiked }: { foodId: string, userId: string, isLiked: boolean }) => { + if (isLiked) { + return deleteLike(foodId, userId); + } else { + return createLike(foodId, userId); + } + }, + onMutate: async ({ foodId, isLiked }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: [queryKeys.foodStats] }); + await queryClient.cancelQueries({ queryKey: [queryKeys.userInteractions] }); + + // Snapshot the previous value + const previousStats = queryClient.getQueryData([queryKeys.foodStats]); + const previousInteractions = queryClient.getQueryData([queryKeys.userInteractions]); + + // Optimistically update + queryClient.setQueryData([queryKeys.foodStats], (old: any) => { + if (!old) return old; + + return { + ...old, + [foodId]: { + ...old[foodId], + likes: isLiked ? Math.max(0, old[foodId].likes - 1) : old[foodId].likes + 1 + } + }; + }); + + queryClient.setQueryData([queryKeys.userInteractions], (old: any) => { + if (!old) return old; + + return { + ...old, + [foodId]: { + ...old[foodId], + liked: !isLiked + } + }; + }); + + // Return a context object with the snapshotted value + return { previousStats, previousInteractions }; + }, + onError: (err, variables, context) => { + // If the mutation fails, use the context returned from onMutate to roll back + if (context?.previousStats) { + queryClient.setQueryData([queryKeys.foodStats], context.previousStats); + } + if (context?.previousInteractions) { + queryClient.setQueryData([queryKeys.userInteractions], context.previousInteractions); + } + }, + onSettled: () => { + // Always refetch after error or success + queryClient.invalidateQueries({ queryKey: [queryKeys.foodStats] }); + queryClient.invalidateQueries({ queryKey: [queryKeys.userInteractions] }); + }, + }); +} + +// Hook to save/unsave a food +export function useSaveMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ foodId, userId, isSaved }: { foodId: string, userId: string, isSaved: boolean }) => { + if (isSaved) { + return deleteSave(foodId, userId); + } else { + return createSave(foodId, userId); + } + }, + onMutate: async ({ foodId, isSaved }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: [queryKeys.foodStats] }); + await queryClient.cancelQueries({ queryKey: [queryKeys.userInteractions] }); + + // Snapshot the previous value + const previousStats = queryClient.getQueryData([queryKeys.foodStats]); + const previousInteractions = queryClient.getQueryData([queryKeys.userInteractions]); + + // Optimistically update + queryClient.setQueryData([queryKeys.foodStats], (old: any) => { + if (!old) return old; + + return { + ...old, + [foodId]: { + ...old[foodId], + saves: isSaved ? Math.max(0, old[foodId].saves - 1) : old[foodId].saves + 1 + } + }; + }); + + queryClient.setQueryData([queryKeys.userInteractions], (old: any) => { + if (!old) return old; + + return { + ...old, + [foodId]: { + ...old[foodId], + saved: !isSaved + } + }; + }); + + // Return a context object with the snapshotted value + return { previousStats, previousInteractions }; + }, + onError: (err, variables, context) => { + // If the mutation fails, use the context returned from onMutate to roll back + if (context?.previousStats) { + queryClient.setQueryData([queryKeys.foodStats], context.previousStats); + } + if (context?.previousInteractions) { + queryClient.setQueryData([queryKeys.userInteractions], context.previousInteractions); + } + }, + onSettled: () => { + // Always refetch after error or success + queryClient.invalidateQueries({ queryKey: [queryKeys.foodStats] }); + queryClient.invalidateQueries({ queryKey: [queryKeys.userInteractions] }); + }, + }); +} \ No newline at end of file diff --git a/services/data/forum.ts b/services/data/forum.ts index d370523..79724df 100644 --- a/services/data/forum.ts +++ b/services/data/forum.ts @@ -208,48 +208,54 @@ export const checkUserSaved = async (food_id: string, user_id: string) => { export const getComments = async (food_id: string) => { console.log('Getting comments for food_id:', food_id); - const { data, error } = await supabase - .from("food_comments") - .select(` - id, - created_at, - user_id, - food_id, - content - `) - .eq("food_id", food_id) - .order("created_at", { ascending: false }); - - if (error) { - console.error('Error getting comments:', error); + try { + const { data, error } = await supabase + .from("food_comments") + .select(` + id, + created_at, + user_id, + food_id, + content + `) + .eq("food_id", food_id) + .order("created_at", { ascending: false }); + + if (error) { + console.error('Error getting comments:', error); + return { data: [], error }; + } + + if (data && data.length > 0) { + // Get unique user IDs from comments + const userIds = [...new Set(data.map(comment => comment.user_id))]; + + // Fetch profiles for these users + const { data: profiles } = await getProfiles(userIds); + + // Add user profiles to comments + if (profiles && profiles.length > 0) { + const profileMap = profiles.reduce((acc, profile) => { + acc[profile.id] = profile; + return acc; + }, {} as Record); + + // Attach profiles to comments + const commentsWithProfiles = data.map(comment => ({ + ...comment, + user: profileMap[comment.user_id] || null + })); + + console.log(`Found ${commentsWithProfiles.length} comments for food_id: ${food_id}`); + return { data: commentsWithProfiles, error: null }; + } + } + + // If no profiles were found or no comments exist, return the original data + console.log(`Found ${data?.length || 0} comments for food_id: ${food_id}`); + return { data: data?.map(comment => ({ ...comment, user: null })) || [], error: null }; + } catch (error) { + console.error('Error in getComments:', error); return { data: [], error }; } - - if (data && data.length > 0) { - // Get unique user IDs from comments - const userIds = [...new Set(data.map(comment => comment.user_id))]; - - // Fetch profiles for these users - const { data: profiles } = await getProfiles(userIds); - - // Add user profiles to comments - if (profiles) { - const profileMap = profiles.reduce((acc, profile) => { - acc[profile.id] = profile; - return acc; - }, {} as Record); - - // Attach profiles to comments - const commentsWithProfiles = data.map(comment => ({ - ...comment, - user: profileMap[comment.user_id] - })); - - console.log(`Found ${commentsWithProfiles.length} comments for food_id: ${food_id}`); - return { data: commentsWithProfiles, error }; - } - } - - console.log(`Found ${data?.length || 0} comments for food_id: ${food_id}`); - return { data, error }; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/types/index.ts b/types/index.ts index e8db137..3e865a9 100644 --- a/types/index.ts +++ b/types/index.ts @@ -64,55 +64,3 @@ export interface Profile { avatar_url?: string; website?: string; } - -export interface Food { - id: string; - created_at: string; - name: string; - description: string; - time_to_cook_minutes: number; - skill_level: string; - ingredient_count: number; - calories: number; - image_url: string; - is_shared: boolean; - created_by: string; -} - -export interface FoodLike { - created_at: string; - user_id: string; - food_id: string; -} - -export interface FoodSave { - created_at: string; - user_id: string; - food_id: string; -} - -export interface FoodComment { - id: string; - created_at: string; - user_id: string; - food_id: string; - content: string; - user?: Profile; // Add user profile to comments -} - -export interface Nutrient { - food_id: string; - fat_g: number; - fiber_g: number; - protein_g: number; - carbs_g: number; - created_at: string; -} - -export interface Ingredient { - id: string; - food_id: string; - name: string; - emoji: string; - created_at: string; -} \ No newline at end of file