diff --git a/backend/market/serializers.py b/backend/market/serializers.py index 91604b8..93ab683 100644 --- a/backend/market/serializers.py +++ b/backend/market/serializers.py @@ -133,6 +133,7 @@ class ListingSerializer(ListingTypeMixin, ModelSerializer): seller = UserSerializer(read_only=True) listing_type = SerializerMethodField() additional_data = SerializerMethodField() + is_favorited = SerializerMethodField() external_link = URLField(required=False, allow_blank=True, allow_null=True) negotiable = BooleanField(required=False, default=True) expires_at = DateTimeField(required=False, allow_null=True) @@ -155,6 +156,7 @@ class Meta: "images", "listing_type", "additional_data", + "is_favorited", ] read_only_fields = [ "id", @@ -196,6 +198,12 @@ def validate(self, attrs): return super().validate(attrs) + def get_is_favorited(self, obj): + request = self.context.get("request") + if not request or not request.user or not request.user.is_authenticated: + return False + return request.user.listings_favorited.filter(id=obj.id).exists() + def validate_title(self, value): if self.contains_profanity(value): raise ValidationError("The title contains inappropriate language.") @@ -327,6 +335,7 @@ def _update_sublet(self, instance, additional_data): class ListingSerializerPublic(ListingTypeMixin, ModelSerializer): buyer_count = SerializerMethodField() favorite_count = SerializerMethodField() + is_favorited = SerializerMethodField() tags = SlugRelatedField(many=True, slug_field="name", queryset=Tag.objects.all()) images = ListingImageURLSerializer(many=True) seller = UserSerializer(read_only=True) @@ -350,6 +359,7 @@ class Meta: "favorite_count", "listing_type", "additional_data", + "is_favorited", ] read_only_fields = fields @@ -359,6 +369,12 @@ def get_buyer_count(self, obj): def get_favorite_count(self, obj): return obj.favorites.count() + def get_is_favorited(self, obj): + request = self.context.get("request") + if not request or not request.user or not request.user.is_authenticated: + return False + return request.user.listings_favorited.filter(id=obj.id).exists() + # Read-only serializer for use when pulling all listings /etc class ListingSerializerList(ListingTypeMixin, ModelSerializer): diff --git a/backend/market/views.py b/backend/market/views.py index 697a022..c85974c 100644 --- a/backend/market/views.py +++ b/backend/market/views.py @@ -55,7 +55,7 @@ class UserFavorites(ListAPIView, DefaultOrderMixin): def get_queryset(self): user = self.request.user - return user.listings_favorited + return user.listings_favorited.all() # TODO: Can add feature to filter for active offers only @@ -255,26 +255,32 @@ class Favorites( def get_queryset(self): user = self.request.user - return user.listings_favorited + return user.listings_favorited.all() def create(self, request, *args, **kwargs): listing_id = int(self.kwargs["listing_id"]) - queryset = self.get_queryset() - if queryset.filter(id=listing_id).exists(): - raise exceptions.ValidationError("Favorite already exists") + favorites = request.user.listings_favorited + if favorites.filter(id=listing_id).exists(): + return Response( + {"liked": True, "detail": "User has already liked the listing"}, + status=status.HTTP_409_CONFLICT, + ) listing = get_object_or_404(Listing, id=listing_id) - self.get_queryset().add(listing) - return Response(status=status.HTTP_201_CREATED) + favorites.add(listing) + return Response({"liked": True}, status=status.HTTP_201_CREATED) def destroy(self, request, *args, **kwargs): listing_id = int(self.kwargs["listing_id"]) listing = get_object_or_404(Listing, id=listing_id) if listing not in request.user.listings_favorited.all(): - raise exceptions.NotFound("Favorite does not exist.") + return Response( + {"liked": False, "detail": "User hasn't liked the listing yet"}, + status=status.HTTP_404_NOT_FOUND, + ) - self.get_queryset().remove(listing) - return Response(status=status.HTTP_204_NO_CONTENT) + request.user.listings_favorited.remove(listing) + return Response({"liked": False}, status=status.HTTP_200_OK) class Offers(viewsets.ModelViewSet): diff --git a/frontend/app/items/[id]/page.tsx b/frontend/app/items/[id]/page.tsx index eb7c641..1f5e426 100644 --- a/frontend/app/items/[id]/page.tsx +++ b/frontend/app/items/[id]/page.tsx @@ -5,5 +5,5 @@ export default async function ItemPage({ params }: { params: Promise<{ id: strin const { id } = await params; const item = await getListing(id); - return ; + return ; } diff --git a/frontend/app/sublets/[id]/page.tsx b/frontend/app/sublets/[id]/page.tsx index be6c6b4..b508ef4 100644 --- a/frontend/app/sublets/[id]/page.tsx +++ b/frontend/app/sublets/[id]/page.tsx @@ -5,5 +5,5 @@ export default async function SubletPage({ params }: { params: Promise<{ id: str const { id } = await params; const sublet = await getListing(id); - return ; + return ; } diff --git a/frontend/components/listings/detail/ListingDetail.tsx b/frontend/components/listings/detail/ListingDetail.tsx index d5ea412..5fb63f9 100644 --- a/frontend/components/listings/detail/ListingDetail.tsx +++ b/frontend/components/listings/detail/ListingDetail.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { addToUsersFavorites, deleteFromUsersFavorites } from "@/lib/actions"; import { Heart, Share } from "lucide-react"; import { Item, Sublet } from "@/lib/types"; import { ListingActions } from "@/components/listings/detail/ListingActions"; @@ -8,12 +12,47 @@ import { BackButton } from "@/components/listings/detail/BackButton"; interface Props { listing: Item | Sublet; + initialIsFavorited: boolean; } -export const ListingDetail = ({ listing }: Props) => { +export const ListingDetail = ({ listing, initialIsFavorited }: Props) => { const listingType = listing.listing_type; const priceLabel = listingType === "sublet" ? "/mo" : undefined; const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner"; + const queryClient = useQueryClient(); + const favoritesQuery = useQuery({ + queryKey: ["favorite", listing.id], + queryFn: async () => initialIsFavorited, + initialData: initialIsFavorited, + staleTime: Infinity, + }); + + const isFavorited = favoritesQuery.data ?? false; + + const toggleFavoriteMutation = useMutation({ + mutationFn: async (shouldFavorite: boolean) => { + if (shouldFavorite) { + await addToUsersFavorites(listing.id); + } else { + await deleteFromUsersFavorites(listing.id); + } + }, + onMutate: async (shouldFavorite: boolean) => { + await queryClient.cancelQueries({ queryKey: ["favorite", listing.id] }); + const previous = queryClient.getQueryData(["favorite", listing.id]); + queryClient.setQueryData(["favorite", listing.id], shouldFavorite); + return { previous }; + }, + onError: (_error, _shouldFavorite, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(["favorite", listing.id], context.previous); + } + }, + }); + + const handleToggleFavorite = async () => { + toggleFavoriteMutation.mutate(!isFavorited); + }; return (
@@ -21,7 +60,15 @@ export const ListingDetail = ({ listing }: Props) => {
- +
diff --git a/frontend/lib/actions.ts b/frontend/lib/actions.ts index 111632b..ff22058 100644 --- a/frontend/lib/actions.ts +++ b/frontend/lib/actions.ts @@ -210,6 +210,25 @@ export async function verifyPhoneCode(phoneNumber: string, code: string) { body: JSON.stringify({ phone_number: phoneNumber, code }), }); } +// ------------------------------------------------------------ +// adding and removing listings from favorites +// ------------------------------------------------------------ + +export async function addToUsersFavorites(listingId: number) { + const res = await serverFetch(`/market/listings/${listingId}/favorites/`, { + method: "POST", + }); + return res; +} +export async function deleteFromUsersFavorites(listingId: number) { + return await serverFetch(`/market/listings/${listingId}/favorites/`, { + method: "DELETE", + }); +} + +export async function getUsersFavorites() { + return await serverFetch>("/market/favorites/"); +} // ------------------------------------------------------------ // creating new listings diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 84d5f68..2c16154 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -61,6 +61,7 @@ type BaseListing = { images: string[]; tags: string[]; favorite_count: number; + is_favorited?: boolean; seller: User; };