Skip to content
16 changes: 16 additions & 0 deletions backend/market/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -155,6 +156,7 @@ class Meta:
"images",
"listing_type",
"additional_data",
"is_favorited",
]
read_only_fields = [
"id",
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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)
Expand All @@ -350,6 +359,7 @@ class Meta:
"favorite_count",
"listing_type",
"additional_data",
"is_favorited",
]
read_only_fields = fields

Expand All @@ -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):
Expand Down
26 changes: 16 additions & 10 deletions backend/market/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_200_OK,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to see if 200 makes sense here and if not, maybe use a better status code

)
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):
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/items/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ export default async function ItemPage({ params }: { params: Promise<{ id: strin
const { id } = await params;
const item = await getListing(id);

return <ListingDetail listing={item} />;
return <ListingDetail listing={item} initialIsFavorited={item.is_favorited ?? false} />;
}
2 changes: 1 addition & 1 deletion frontend/app/sublets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ export default async function SubletPage({ params }: { params: Promise<{ id: str
const { id } = await params;
const sublet = await getListing(id);

return <ListingDetail listing={sublet} />;
return <ListingDetail listing={sublet} initialIsFavorited={sublet.is_favorited ?? false} />;
}
53 changes: 51 additions & 2 deletions frontend/components/listings/detail/ListingDetail.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -8,20 +12,65 @@ 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 isInsideFavorites = favoritesQuery.data ?? false;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe isLiked or isFavorited? isInsideFavorites sounds p confusing


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<boolean>(["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(!isInsideFavorites);
};

return (
<div className="mx-auto flex w-full max-w-[96rem] flex-col p-8 px-4 sm:px-12">
<div className="mb-4 flex items-center justify-between">
<BackButton />
<div className="flex items-center gap-3">
<Share className="h-5 w-5" />
<Heart className="h-5 w-5" />
<button
type="button"
className="cursor-pointer"
onClick={handleToggleFavorite}
aria-pressed={isInsideFavorites}
aria-label={isInsideFavorites ? "Remove from favorites" : "Add to favorites"}
>
<Heart
className={isInsideFavorites ? "h-5 w-5 fill-red-500 text-red-500" : "h-5 w-5"}
/>
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
Expand Down
19 changes: 19 additions & 0 deletions frontend/lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(`/market/listings/${listingId}/favorites/`, {
method: "POST",
});
return res;
}
export async function deleteFromUsersFavorites(listingId: number) {
return await serverFetch<void>(`/market/listings/${listingId}/favorites/`, {
method: "DELETE",
});
}

export async function getUsersFavorites() {
return await serverFetch<PaginatedResponse<Item | Sublet>>("/market/favorites/");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im just noticing this, but why are you using PaginatedResponse? This is usually for a very large list of data, and you want to load them in chunks (like we do it for list of items or sublets in the main grid view).

Also, this is my b for also just bringing this up, but ideally, we should not fetch the entire list of user's favorites just to see if one current item/sublet is favorited (this is inefficient). Don't remove this function (since we'll prob use it later), but ideally, we should be able to do this on a single listing object. One way to do this is computing and returning is_favorited as one of the fields when you fetch an item or sublet. That way, you only need one backend call when you enter the details page (as opposed to two calls we had for fetching both item/sublet AND also favorites data). This will involve backend changes, so lmk if you want to take a stab at it

If you decide to go with this approach, your useQuery logic in ListingDetail.tsx will become much simpler too: since the server already includes is_favorited when we fetch the listing, we don’t actually need a separate request just to get that boolean value again. Instead, we can just simply treat Tanstack Query as a place to store that value on the client side

What that means in practical terms:

  • The listing query fetches the listing (including is_favorited)
  • We use that server-provided value as the initial state
  • We set staleTime: Infinity so Tanstack Query doesn’t try to refetch it automatically
  • The queryFn can just return a resolved promise (e.g. Promise.resolve(initialIsFavorited)) because we’re not actually fetching anything new (i believe we still need to define this per useQuery API contract)

So at this point, the query isn’t being used to “fetch” the data anymore - it’s just holding the current value in the cache. Then when favorite/unfavorite mutation happens:

  • onMutate immediately updates the cached value thru optimistic update
  • onError restores the previous value if something fails (likely not cause we are no longer making any actual network request but still safe to have it)

So the flow becomes:

  • Server sends the correct initial value
  • Tanstack Query stores it
  • Mutations update it optimistically
  • No extra background refetch needed

If you have questions, we can talk about it tmrw during gbm

}

// ------------------------------------------------------------
// creating new listings
Expand Down
1 change: 1 addition & 0 deletions frontend/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type BaseListing = {
images: string[];
tags: string[];
favorite_count: number;
is_favorited?: boolean;
seller: User;
};

Expand Down