Skip to content

Commit e5ac44d

Browse files
authored
Merge pull request #47 from pennlabs/favorites-functionality
Favorites functionality
2 parents bcf14e6 + febbd34 commit e5ac44d

7 files changed

Lines changed: 103 additions & 14 deletions

File tree

backend/market/serializers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ class ListingSerializer(ListingTypeMixin, ModelSerializer):
133133
seller = UserSerializer(read_only=True)
134134
listing_type = SerializerMethodField()
135135
additional_data = SerializerMethodField()
136+
is_favorited = SerializerMethodField()
136137
external_link = URLField(required=False, allow_blank=True, allow_null=True)
137138
negotiable = BooleanField(required=False, default=True)
138139
expires_at = DateTimeField(required=False, allow_null=True)
@@ -155,6 +156,7 @@ class Meta:
155156
"images",
156157
"listing_type",
157158
"additional_data",
159+
"is_favorited",
158160
]
159161
read_only_fields = [
160162
"id",
@@ -196,6 +198,12 @@ def validate(self, attrs):
196198

197199
return super().validate(attrs)
198200

201+
def get_is_favorited(self, obj):
202+
request = self.context.get("request")
203+
if not request or not request.user or not request.user.is_authenticated:
204+
return False
205+
return request.user.listings_favorited.filter(id=obj.id).exists()
206+
199207
def validate_title(self, value):
200208
if self.contains_profanity(value):
201209
raise ValidationError("The title contains inappropriate language.")
@@ -327,6 +335,7 @@ def _update_sublet(self, instance, additional_data):
327335
class ListingSerializerPublic(ListingTypeMixin, ModelSerializer):
328336
buyer_count = SerializerMethodField()
329337
favorite_count = SerializerMethodField()
338+
is_favorited = SerializerMethodField()
330339
tags = SlugRelatedField(many=True, slug_field="name", queryset=Tag.objects.all())
331340
images = ListingImageURLSerializer(many=True)
332341
seller = UserSerializer(read_only=True)
@@ -350,6 +359,7 @@ class Meta:
350359
"favorite_count",
351360
"listing_type",
352361
"additional_data",
362+
"is_favorited",
353363
]
354364
read_only_fields = fields
355365

@@ -359,6 +369,12 @@ def get_buyer_count(self, obj):
359369
def get_favorite_count(self, obj):
360370
return obj.favorites.count()
361371

372+
def get_is_favorited(self, obj):
373+
request = self.context.get("request")
374+
if not request or not request.user or not request.user.is_authenticated:
375+
return False
376+
return request.user.listings_favorited.filter(id=obj.id).exists()
377+
362378

363379
# Read-only serializer for use when pulling all listings /etc
364380
class ListingSerializerList(ListingTypeMixin, ModelSerializer):

backend/market/views.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class UserFavorites(ListAPIView, DefaultOrderMixin):
5555

5656
def get_queryset(self):
5757
user = self.request.user
58-
return user.listings_favorited
58+
return user.listings_favorited.all()
5959

6060

6161
# TODO: Can add feature to filter for active offers only
@@ -255,26 +255,32 @@ class Favorites(
255255

256256
def get_queryset(self):
257257
user = self.request.user
258-
return user.listings_favorited
258+
return user.listings_favorited.all()
259259

260260
def create(self, request, *args, **kwargs):
261261
listing_id = int(self.kwargs["listing_id"])
262-
queryset = self.get_queryset()
263-
if queryset.filter(id=listing_id).exists():
264-
raise exceptions.ValidationError("Favorite already exists")
262+
favorites = request.user.listings_favorited
263+
if favorites.filter(id=listing_id).exists():
264+
return Response(
265+
{"liked": True, "detail": "User has already liked the listing"},
266+
status=status.HTTP_409_CONFLICT,
267+
)
265268
listing = get_object_or_404(Listing, id=listing_id)
266-
self.get_queryset().add(listing)
267-
return Response(status=status.HTTP_201_CREATED)
269+
favorites.add(listing)
270+
return Response({"liked": True}, status=status.HTTP_201_CREATED)
268271

269272
def destroy(self, request, *args, **kwargs):
270273
listing_id = int(self.kwargs["listing_id"])
271274
listing = get_object_or_404(Listing, id=listing_id)
272275

273276
if listing not in request.user.listings_favorited.all():
274-
raise exceptions.NotFound("Favorite does not exist.")
277+
return Response(
278+
{"liked": False, "detail": "User hasn't liked the listing yet"},
279+
status=status.HTTP_404_NOT_FOUND,
280+
)
275281

276-
self.get_queryset().remove(listing)
277-
return Response(status=status.HTTP_204_NO_CONTENT)
282+
request.user.listings_favorited.remove(listing)
283+
return Response({"liked": False}, status=status.HTTP_200_OK)
278284

279285

280286
class Offers(viewsets.ModelViewSet):

frontend/app/items/[id]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ export default async function ItemPage({ params }: { params: Promise<{ id: strin
55
const { id } = await params;
66
const item = await getListing(id);
77

8-
return <ListingDetail listing={item} />;
8+
return <ListingDetail listing={item} initialIsFavorited={item.is_favorited ?? false} />;
99
}

frontend/app/sublets/[id]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ export default async function SubletPage({ params }: { params: Promise<{ id: str
55
const { id } = await params;
66
const sublet = await getListing(id);
77

8-
return <ListingDetail listing={sublet} />;
8+
return <ListingDetail listing={sublet} initialIsFavorited={sublet.is_favorited ?? false} />;
99
}

frontend/components/listings/detail/ListingDetail.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
"use client";
2+
3+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4+
import { addToUsersFavorites, deleteFromUsersFavorites } from "@/lib/actions";
15
import { Heart, Share } from "lucide-react";
26
import { Item, Sublet } from "@/lib/types";
37
import { ListingActions } from "@/components/listings/detail/ListingActions";
@@ -8,20 +12,63 @@ import { BackButton } from "@/components/listings/detail/BackButton";
812

913
interface Props {
1014
listing: Item | Sublet;
15+
initialIsFavorited: boolean;
1116
}
1217

13-
export const ListingDetail = ({ listing }: Props) => {
18+
export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
1419
const listingType = listing.listing_type;
1520
const priceLabel = listingType === "sublet" ? "/mo" : undefined;
1621
const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner";
22+
const queryClient = useQueryClient();
23+
const favoritesQuery = useQuery({
24+
queryKey: ["favorite", listing.id],
25+
queryFn: async () => initialIsFavorited,
26+
initialData: initialIsFavorited,
27+
staleTime: Infinity,
28+
});
29+
30+
const isFavorited = favoritesQuery.data ?? false;
31+
32+
const toggleFavoriteMutation = useMutation({
33+
mutationFn: async (shouldFavorite: boolean) => {
34+
if (shouldFavorite) {
35+
await addToUsersFavorites(listing.id);
36+
} else {
37+
await deleteFromUsersFavorites(listing.id);
38+
}
39+
},
40+
onMutate: async (shouldFavorite: boolean) => {
41+
await queryClient.cancelQueries({ queryKey: ["favorite", listing.id] });
42+
const previous = queryClient.getQueryData<boolean>(["favorite", listing.id]);
43+
queryClient.setQueryData(["favorite", listing.id], shouldFavorite);
44+
return { previous };
45+
},
46+
onError: (_error, _shouldFavorite, context) => {
47+
if (context?.previous !== undefined) {
48+
queryClient.setQueryData(["favorite", listing.id], context.previous);
49+
}
50+
},
51+
});
52+
53+
const handleToggleFavorite = async () => {
54+
toggleFavoriteMutation.mutate(!isFavorited);
55+
};
1756

1857
return (
1958
<div className="mx-auto flex w-full max-w-[96rem] flex-col p-8 px-4 sm:px-12">
2059
<div className="mb-4 flex items-center justify-between">
2160
<BackButton />
2261
<div className="flex items-center gap-3">
2362
<Share className="h-5 w-5" />
24-
<Heart className="h-5 w-5" />
63+
<button
64+
type="button"
65+
className="cursor-pointer"
66+
onClick={handleToggleFavorite}
67+
aria-pressed={isFavorited}
68+
aria-label={isFavorited ? "Remove from favorites" : "Add to favorites"}
69+
>
70+
<Heart className={isFavorited ? "h-5 w-5 fill-red-500 text-red-500" : "h-5 w-5"} />
71+
</button>
2572
</div>
2673
</div>
2774
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">

frontend/lib/actions.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,25 @@ export async function verifyPhoneCode(phoneNumber: string, code: string) {
210210
body: JSON.stringify({ phone_number: phoneNumber, code }),
211211
});
212212
}
213+
// ------------------------------------------------------------
214+
// adding and removing listings from favorites
215+
// ------------------------------------------------------------
216+
217+
export async function addToUsersFavorites(listingId: number) {
218+
const res = await serverFetch<void>(`/market/listings/${listingId}/favorites/`, {
219+
method: "POST",
220+
});
221+
return res;
222+
}
223+
export async function deleteFromUsersFavorites(listingId: number) {
224+
return await serverFetch<void>(`/market/listings/${listingId}/favorites/`, {
225+
method: "DELETE",
226+
});
227+
}
228+
229+
export async function getUsersFavorites() {
230+
return await serverFetch<PaginatedResponse<Item | Sublet>>("/market/favorites/");
231+
}
213232

214233
// ------------------------------------------------------------
215234
// creating new listings

frontend/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type BaseListing = {
6161
images: string[];
6262
tags: string[];
6363
favorite_count: number;
64+
is_favorited?: boolean;
6465
seller: User;
6566
};
6667

0 commit comments

Comments
 (0)