fix: make can like in forum page

This commit is contained in:
Tantikon Phasanphaengsi 2025-05-10 23:55:58 +07:00
parent 5212429fb1
commit 7190731cdc
6 changed files with 599 additions and 680 deletions

View File

@ -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<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [foods, setFoods] = useState<Food[]>([]);
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;
// Use React Query hooks
const {
data: foods = [],
isLoading: isLoadingFoods,
refetch: refetchFoods
} = useFoods(selectedCategory, searchQuery, selectedSort);
const likesSubscription = supabase
.channel('food_likes_changes')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'food_likes'
}, () => {
// Refresh stats when changes occur
loadFoods();
})
.subscribe();
const foodIds = foods.map(food => food.id);
const savesSubscription = supabase
.channel('food_saves_changes')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'food_saves'
}, () => {
// Refresh stats when changes occur
loadFoods();
})
.subscribe();
const {
data: foodStats = {},
isLoading: isLoadingStats
} = useFoodStats(foodIds);
return () => {
supabase.removeChannel(likesSubscription);
supabase.removeChannel(savesSubscription);
};
}, [isAuthenticated]);
useEffect(() => {
loadFoods();
}, [selectedCategory, selectedSort, currentUserId]);
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
const creatorIds = foods
.filter(food => food.created_by)
.map(food => food.created_by as string);
const uniqueCreatorIds = [...new Set(creatorIds)];
const {
data: foodCreators = {},
isLoading: isLoadingCreators
} = useFoodCreators(creatorIds);
const creatorProfiles: {[key: string]: Profile} = {};
const {
data: userInteractions = {},
isLoading: isLoadingInteractions
} = useUserInteractions(foodIds, currentUserId);
for (const creatorId of uniqueCreatorIds) {
const { data: profile } = await getProfile(creatorId);
if (profile) {
creatorProfiles[creatorId] = profile;
}
}
const likeMutation = useLikeMutation();
const saveMutation = useSaveMutation();
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);
}
};
// 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) => {
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 (
<SafeAreaView className="flex-1 bg-white">
{/* Search Bar */}
@ -537,7 +294,7 @@ const navigateToPostDetail = (food: Food) => {
</View>
{/* Food Posts */}
{loading ? (
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#ffd60a" />
</View>

View File

@ -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 */}
<View className="flex-row justify-around py-3 border-b border-gray-200">
{["My Recipes", "Likes", "Bookmark"].map((tab) => (
{["My Recipes", "Likes", "Bookmarks"].map((tab) => (
<TouchableOpacity
key={tab}
className={`py-2 px-4 ${activeTab === tab ? "border-b-2 border-[#333]" : ""}`}

View File

@ -3,6 +3,8 @@ import { View, Text, Image, TouchableOpacity, ScrollView, SafeAreaView, Activity
import { Feather, FontAwesome } from '@expo/vector-icons';
import { useLocalSearchParams, router } from 'expo-router';
import { useAuth } from '../../context/auth-context';
import { supabase } from '../../services/supabase';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getFoods, getIngredients, getNutrients } from '../../services/data/foods';
import {
createLike,
@ -19,32 +21,20 @@ import {
} from '../../services/data/forum';
import { getProfile } from '../../services/data/profile';
import { Food, Ingredient, Nutrient, FoodComment, Profile } from '../../types/index';
import { supabase } from '../../services/supabase';
import { queryKeys, useLikeMutation, useSaveMutation } from '../../hooks/use-foods';
export default function PostDetailScreen() {
const { id } = useLocalSearchParams();
const foodId = typeof id === 'string' ? id : '';
const queryClient = useQueryClient();
console.log('Post detail screen - Food ID:', foodId);
const { isAuthenticated } = useAuth();
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [food, setFood] = useState<Food | null>(null);
const [foodCreator, setFoodCreator] = useState<Profile | null>(null);
const [ingredients, setIngredients] = useState<Ingredient[]>([]);
const [nutrients, setNutrients] = useState<Nutrient | null>(null);
const [comments, setComments] = useState<FoodComment[]>([]);
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]);
if (!foodId) return;
const checkUserInteractions = async () => {
if (!currentUserId || !foodId) return;
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();
try {
console.log('Checking user interactions with user ID:', 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();
const [likedRes, savedRes] = await Promise.all([
checkUserLiked(foodId, currentUserId),
checkUserSaved(foodId, currentUserId)
]);
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 (
<SafeAreaView className="flex-1 bg-white">
<View className="flex-1 items-center justify-center">
@ -385,7 +309,7 @@ export default function PostDetailScreen() {
);
}
if (!food) {
if (foodError || !food) {
return (
<SafeAreaView className="flex-1 bg-white">
<View className="flex-1 items-center justify-center">
@ -436,13 +360,13 @@ export default function PostDetailScreen() {
) : (
<View className="w-full h-full bg-gray-300 items-center justify-center">
<Text className="text-lg font-bold text-gray-600">
{foodCreator?.username?.charAt(0).toUpperCase() || '?'}
{foodCreator?.username?.charAt(0).toUpperCase() || food.created_by?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
<Text className="ml-3 text-lg font-bold">
{foodCreator?.username || foodCreator?.full_name || 'Unknown Chef'}
{foodCreator?.username || foodCreator?.full_name || 'Chef'}
</Text>
</View>
<View className="flex-row items-center">
@ -471,9 +395,13 @@ export default function PostDetailScreen() {
{/* Interaction buttons */}
<View className="flex-row justify-between px-4 py-4 border-b border-gray-200">
<TouchableOpacity className="flex-row items-center">
<Feather name="message-square" size={22} color="#333" />
<Text className="ml-2 text-lg">{stats.comments}</Text>
<TouchableOpacity
className="flex-row items-center"
onPress={handleLike}
>
<Feather name="heart" size={22} color={interactions.liked ? "#E91E63" : "#333"} />
<Text className="ml-2 text-lg">{stats.likes}</Text>
</TouchableOpacity>
<TouchableOpacity className="flex-row items-center">
@ -481,16 +409,9 @@ export default function PostDetailScreen() {
<Text className="ml-2 text-lg">{stats.comments}</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center"
onPress={handleLike}
>
<Feather name="heart" size={22} color={isLiked ? "#E91E63" : "#333"} />
<Text className="ml-2 text-lg">{stats.likes}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleSave}>
<Feather name="bookmark" size={22} color={isSaved ? "#ffd60a" : "#333"} />
<Feather name="bookmark" size={22} color={interactions.saved ? "#ffd60a" : "#333"} />
</TouchableOpacity>
</View>
@ -518,14 +439,14 @@ export default function PostDetailScreen() {
) : (
<View className="w-full h-full bg-gray-300 items-center justify-center">
<Text className="text-base font-bold text-gray-600">
{comment.user?.username?.charAt(0).toUpperCase() || '?'}
{comment.user?.username?.charAt(0).toUpperCase() || comment.user_id?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
<View className="flex-row items-center justify-between flex-1">
<Text className="font-bold">
{comment.user?.username || comment.user?.full_name || 'Unknown User'}
{comment.user?.username || comment.user?.full_name || 'User'}
</Text>
<Text className="text-gray-500 text-xs">
{new Date(comment.created_at).toLocaleDateString()}

287
hooks/use-foods.ts Normal file
View File

@ -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<string, { likes: number, saves: number, comments: number }>);
},
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<string, Profile> = {};
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<string, { liked: boolean, saved: boolean }>);
},
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] });
},
});
}

View File

@ -208,6 +208,7 @@ 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);
try {
const { data, error } = await supabase
.from("food_comments")
.select(`
@ -233,7 +234,7 @@ export const getComments = async (food_id: string) => {
const { data: profiles } = await getProfiles(userIds);
// Add user profiles to comments
if (profiles) {
if (profiles && profiles.length > 0) {
const profileMap = profiles.reduce((acc, profile) => {
acc[profile.id] = profile;
return acc;
@ -242,14 +243,19 @@ export const getComments = async (food_id: string) => {
// Attach profiles to comments
const commentsWithProfiles = data.map(comment => ({
...comment,
user: profileMap[comment.user_id]
user: profileMap[comment.user_id] || null
}));
console.log(`Found ${commentsWithProfiles.length} comments for food_id: ${food_id}`);
return { data: commentsWithProfiles, error };
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, error };
return { data: data?.map(comment => ({ ...comment, user: null })) || [], error: null };
} catch (error) {
console.error('Error in getComments:', error);
return { data: [], error };
}
};

View File

@ -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;
}