From 173fe0d8eab61f2603557511554b7dc5abbeddbc Mon Sep 17 00:00:00 2001 From: James Doh Date: Fri, 3 Apr 2026 13:17:04 -0400 Subject: [PATCH] Rename favorites to saved across full stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all "favorites" terminology with "saved" — backend model field, serializers, views, API endpoints, frontend types, actions, and UI component. Swap Heart icon for Bookmark. Add migration using RenameField to preserve existing data. Co-Authored-By: Claude Opus 4.6 --- .../0006_rename_favorites_to_saved.py | 24 ++++ backend/market/models.py | 8 +- backend/market/serializers.py | 39 +++-- backend/market/urls.py | 18 +-- backend/market/views.py | 26 ++-- backend/tests/market/test_market.py | 134 +++++++++--------- .../listings/detail/ListingDetail.tsx | 34 ++--- frontend/lib/actions.ts | 14 +- frontend/lib/types.ts | 4 +- 9 files changed, 165 insertions(+), 136 deletions(-) create mode 100644 backend/market/migrations/0006_rename_favorites_to_saved.py diff --git a/backend/market/migrations/0006_rename_favorites_to_saved.py b/backend/market/migrations/0006_rename_favorites_to_saved.py new file mode 100644 index 0000000..860a125 --- /dev/null +++ b/backend/market/migrations/0006_rename_favorites_to_saved.py @@ -0,0 +1,24 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("market", "0005_sublet_true_latitude_sublet_true_longitude"), + ] + + operations = [ + migrations.RenameField( + model_name="listing", + old_name="favorites", + new_name="saved", + ), + migrations.AlterField( + model_name="listing", + name="saved", + field=models.ManyToManyField( + blank=True, related_name="listings_saved", to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/backend/market/models.py b/backend/market/models.py index 7812865..6826151 100644 --- a/backend/market/models.py +++ b/backend/market/models.py @@ -47,6 +47,7 @@ class Meta: def __str__(self): return f"Offer for {self.listing} made by {self.user}" + class Category(models.Model): name = models.CharField(max_length=100, unique=True) @@ -81,9 +82,7 @@ class Meta: User, through=Offer, related_name="listings_offered", blank=True ) tags = models.ManyToManyField(Tag, blank=True) - favorites = models.ManyToManyField( - User, related_name="listings_favorited", blank=True - ) + saved = models.ManyToManyField(User, related_name="listings_saved", blank=True) title = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) @@ -170,7 +169,8 @@ def _calculate_approximate_location(self, latitude, longitude): def approximate_location(self): if self.latitude is not None and self.longitude is not None: approximate_location = self._calculate_approximate_location( - self.latitude, self.longitude) + self.latitude, self.longitude + ) return approximate_location return None, None diff --git a/backend/market/serializers.py b/backend/market/serializers.py index c129131..e408247 100644 --- a/backend/market/serializers.py +++ b/backend/market/serializers.py @@ -1,4 +1,3 @@ - from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as ModelValidationError from profanity_check import predict @@ -126,6 +125,7 @@ def get_longitude(self, obj): return float(approx_lon) return None + # Unified serializer for all listing types (Items and Sublets); used for CRUD operations class ListingSerializer(ListingTypeMixin, ModelSerializer): LISTING_TYPE_CONFIG = { @@ -156,7 +156,7 @@ class ListingSerializer(ListingTypeMixin, ModelSerializer): seller = UserSerializer(read_only=True) listing_type = SerializerMethodField() additional_data = SerializerMethodField() - is_favorited = SerializerMethodField() + is_saved = 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) @@ -168,7 +168,7 @@ class Meta: "seller", "buyers", "tags", - "favorites", + "saved", "title", "description", "external_link", @@ -179,7 +179,7 @@ class Meta: "images", "listing_type", "additional_data", - "is_favorited", + "is_saved", ] read_only_fields = [ "id", @@ -187,7 +187,7 @@ class Meta: "seller", "buyers", "images", - "favorites", + "saved", ] def validate(self, attrs): @@ -221,11 +221,11 @@ def validate(self, attrs): return super().validate(attrs) - def get_is_favorited(self, obj): + def get_is_saved(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() + return request.user.listings_saved.filter(id=obj.id).exists() def validate_title(self, value): if self.contains_profanity(value): @@ -291,7 +291,6 @@ def _create_sublet(self, validated_data, additional_data): latitude = additional_data.get("latitude") longitude = additional_data.get("longitude") - if latitude is not None: latitude = float(latitude) if longitude is not None: @@ -374,8 +373,8 @@ def _update_sublet(self, instance, additional_data): # Read-only serializer for use when reading a single listing class ListingSerializerPublic(ListingTypeMixin, ModelSerializer): buyer_count = SerializerMethodField() - favorite_count = SerializerMethodField() - is_favorited = SerializerMethodField() + saved_count = SerializerMethodField() + is_saved = SerializerMethodField() tags = SlugRelatedField(many=True, slug_field="name", queryset=Tag.objects.all()) images = ListingImageURLSerializer(many=True) seller = UserSerializer(read_only=True) @@ -396,29 +395,29 @@ class Meta: "negotiable", "expires_at", "images", - "favorite_count", + "saved_count", "listing_type", "additional_data", - "is_favorited", + "is_saved", ] read_only_fields = fields def get_buyer_count(self, obj): return obj.buyers.count() - def get_favorite_count(self, obj): - return obj.favorites.count() + def get_saved_count(self, obj): + return obj.saved.count() - def get_is_favorited(self, obj): + def get_is_saved(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() + return request.user.listings_saved.filter(id=obj.id).exists() # Read-only serializer for use when pulling all listings /etc class ListingSerializerList(ListingTypeMixin, ModelSerializer): - favorite_count = SerializerMethodField() + saved_count = SerializerMethodField() tags = SlugRelatedField(many=True, slug_field="name", queryset=Tag.objects.all()) images = ListingImageURLSerializer(many=True) seller = UserSerializer(read_only=True) @@ -435,11 +434,11 @@ class Meta: "price", "expires_at", "images", - "favorite_count", + "saved_count", "listing_type", "additional_data", ] read_only_fields = fields - def get_favorite_count(self, obj): - return obj.favorites.count() + def get_saved_count(self, obj): + return obj.saved.count() diff --git a/backend/market/urls.py b/backend/market/urls.py index b00a13c..538dafc 100644 --- a/backend/market/urls.py +++ b/backend/market/urls.py @@ -4,13 +4,13 @@ from market.views import ( CreateImages, DeleteImage, - Favorites, Listings, Offers, OffersMade, OffersReceived, + SavedListings, Tags, - UserFavorites, + UserSavedListings, get_current_user, get_phone_status, send_verification_code, @@ -28,18 +28,18 @@ path("user/me/", get_current_user, name="current-user"), # List of all amenities path("tags/", Tags.as_view(), name="tags"), - # All favorites for user - path("favorites/", UserFavorites.as_view(), name="user-favorites"), + # All saved listings for user + path("saved/", UserSavedListings.as_view(), name="user-saved-listings"), # All offers made by user path("offers/made/", OffersMade.as_view(), name="offers-made"), # All offers for an listing owned by user path("offers/received/", OffersReceived.as_view(), name="offers-received"), - # Favorites - # post: add a listing to the user's favorites - # delete: remove a listing from the user's favorites + # Saved listings + # post: save a listing for the user + # delete: unsave a listing for the user path( - "listings//favorites/", - Favorites.as_view({"post": "create", "delete": "destroy"}), + "listings//saved/", + SavedListings.as_view({"post": "create", "delete": "destroy"}), ), # Offers # get: list all offers for an listing diff --git a/backend/market/views.py b/backend/market/views.py index d61d749..6582fca 100644 --- a/backend/market/views.py +++ b/backend/market/views.py @@ -48,14 +48,14 @@ def get_queryset(self): return Tag.objects.all() -class UserFavorites(ListAPIView, DefaultOrderMixin): +class UserSavedListings(ListAPIView, DefaultOrderMixin): serializer_class = ListingSerializerList permission_classes = [IsAuthenticated] pagination_class = PageSizeOffsetPagination def get_queryset(self): user = self.request.user - return user.listings_favorited.all() + return user.listings_saved.all() # TODO: Can add feature to filter for active offers only @@ -245,7 +245,7 @@ def destroy(self, request, *args, **kwargs): # TODO: We don't use the CreateModelMixin or DestroyModelMixin's functionality here. # Think about if there's a better way -class Favorites( +class SavedListings( mixins.DestroyModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet ): serializer_class = ListingSerializer @@ -255,32 +255,32 @@ class Favorites( def get_queryset(self): user = self.request.user - return user.listings_favorited.all() + return user.listings_saved.all() def create(self, request, *args, **kwargs): listing_id = int(self.kwargs["listing_id"]) - favorites = request.user.listings_favorited - if favorites.filter(id=listing_id).exists(): + saved = request.user.listings_saved + if saved.filter(id=listing_id).exists(): return Response( - {"liked": True, "detail": "User has already liked the listing"}, + {"saved": True, "detail": "User has already saved the listing"}, status=status.HTTP_409_CONFLICT, ) listing = get_object_or_404(Listing, id=listing_id) - favorites.add(listing) - return Response({"liked": True}, status=status.HTTP_201_CREATED) + saved.add(listing) + return Response({"saved": 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(): + if listing not in request.user.listings_saved.all(): return Response( - {"liked": False, "detail": "User hasn't liked the listing yet"}, + {"saved": False, "detail": "User hasn't saved the listing yet"}, status=status.HTTP_404_NOT_FOUND, ) - request.user.listings_favorited.remove(listing) - return Response({"liked": False}, status=status.HTTP_200_OK) + request.user.listings_saved.remove(listing) + return Response({"saved": False}, status=status.HTTP_200_OK) class Offers(viewsets.ModelViewSet): diff --git a/backend/tests/market/test_market.py b/backend/tests/market/test_market.py index 5318a81..55f61bc 100644 --- a/backend/tests/market/test_market.py +++ b/backend/tests/market/test_market.py @@ -202,7 +202,7 @@ def test_get_items(self): "price": 2000.0, "expires_at": "3000-08-12T01:00:00-04:00", "images": [], - "favorite_count": 0, + "saved_count": 0, "listing_type": "item", "additional_data": { "condition": "NEW", @@ -217,7 +217,7 @@ def test_get_items(self): "price": 20.0, "expires_at": "3000-12-12T00:00:00-05:00", "images": [], - "favorite_count": 0, + "saved_count": 0, "listing_type": "item", "additional_data": { "condition": "GOOD", @@ -232,7 +232,7 @@ def test_get_items(self): "price": 400.0, "expires_at": "3000-12-12T00:00:00-05:00", "images": [], - "favorite_count": 0, + "saved_count": 0, "listing_type": "item", "additional_data": { "condition": "FAIR", @@ -266,7 +266,7 @@ def test_get_item_seller(self): "price": 20.0, "expires_at": "3000-12-12T00:00:00-05:00", "images": [], - "favorite_count": 0, + "saved_count": 0, "listing_type": "item", "additional_data": { "condition": "GOOD", @@ -291,7 +291,7 @@ def test_get_single_item_own(self): "seller": 1, "buyers": [], "tags": ["Used", "Textbook"], - "favorites": [], + "saved": [], "title": "Math Textbook", "description": "2023 version", "external_link": "https://example.com/book", @@ -308,7 +308,7 @@ def test_get_single_item_own(self): response_json, expected_response, ["created_at"], - ["", "tags", "images", "buyers", "favorites"], + ["", "tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -332,7 +332,7 @@ def test_get_single_item_other(self): "negotiable": True, "expires_at": "3000-08-12T01:00:00-04:00", "images": [], - "favorite_count": 0, + "saved_count": 0, "listing_type": "item", "additional_data": {"condition": "NEW", "category": "Electronics"}, } @@ -369,7 +369,7 @@ def test_create_item_all_fields(self): "seller": 1, "buyers": [], "tags": ["New"], - "favorites": [], + "saved": [], "title": "Math Textbook", "description": "2023 version", "external_link": "https://example.com/listing", @@ -387,7 +387,7 @@ def test_create_item_all_fields(self): response.json(), expected_response, ["created_at"], - ["", "tags", "images", "buyers", "favorites"], + ["", "tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -402,7 +402,7 @@ def test_create_item_all_fields(self): response.json(), expected_response, ["created_at"], - ["", "tags", "images", "buyers", "favorites"], + ["", "tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -429,7 +429,7 @@ def test_create_item_exclude_unrequired(self): "seller": 1, "buyers": [], "tags": ["New"], - "favorites": [], + "saved": [], "title": "Math Textbook", "description": "2023 version", "external_link": None, @@ -447,7 +447,7 @@ def test_create_item_exclude_unrequired(self): response.json(), expected_response, ["created_at"], - ["", "tags", "images", "buyers", "favorites"], + ["", "tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -462,7 +462,7 @@ def test_create_item_exclude_unrequired(self): response.json(), expected_response, ["created_at"], - ["", "tags", "images", "buyers", "favorites"], + ["", "tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -570,7 +570,7 @@ def test_update_item_minimum_required(self): "seller": self.users[0].id, "buyers": [], "tags": ["Used", "Textbook"], - "favorites": [], + "saved": [], "title": "Physics Textbook", "description": "2023 version", "external_link": "https://example.com/book", @@ -588,7 +588,7 @@ def test_update_item_minimum_required(self): response.json(), expected_response, ["created_at"], - ["tags", "images", "favorites", "buyers"], + ["tags", "images", "saved", "buyers"], ) self.assertLessEqual( abs( @@ -603,7 +603,7 @@ def test_update_item_minimum_required(self): response.json(), expected_response, ["created_at"], - ["tags", "images", "favorites", "buyers"], + ["tags", "images", "saved", "buyers"], ) self.assertLessEqual( abs( @@ -638,7 +638,7 @@ def test_update_item_all_fields(self): "seller": self.users[0].id, "buyers": [], "tags": ["New"], - "favorites": [], + "saved": [], "title": "5 meal swipes", "description": "5 meal swipes for sale", "external_link": "https://example.com/meal-swipes", @@ -656,7 +656,7 @@ def test_update_item_all_fields(self): response.json(), expected_response, ["created_at"], - ["tags", "images", "buyers", "favorites"], + ["tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -671,7 +671,7 @@ def test_update_item_all_fields(self): response.json(), expected_response, ["created_at"], - ["tags", "images", "buyers", "favorites"], + ["tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -780,7 +780,7 @@ def test_get_sublets(self): "price": 1350.0, "expires_at": "3000-12-12T00:00:00-05:00", "images": [], - "favorite_count": 0, + "saved_count": 0, "listing_type": "sublet", "additional_data": { "address": "Cira Green, Philadelphia, PA", @@ -798,7 +798,7 @@ def test_get_sublets(self): "price": 1350.0, "expires_at": "3000-12-12T00:00:00-05:00", "images": [], - "favorite_count": 0, + "saved_count": 0, "listing_type": "sublet", "additional_data": { "address": "3901 Locust Walk, Philadelphia, PA", @@ -815,7 +815,7 @@ def test_get_sublets(self): response.json(), expected_response, ["created_at"], - ["results", "results.tags", "results.images", "results.favorites"], + ["results", "results.tags", "results.images", "results.saved"], ) def test_get_sublet_own(self): @@ -826,7 +826,7 @@ def test_get_sublet_own(self): "seller": self.users[0].id, "buyers": [], "tags": ["New"], - "favorites": [], + "saved": [], "title": "Cira Green Sublet", "description": ( "Fully furnished 3-bedroom apartment available for sublet " @@ -852,7 +852,7 @@ def test_get_sublet_own(self): response.json(), expected_response, ["created_at"], - ["", "tags", "images", "buyers", "favorites"], + ["", "tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -880,7 +880,7 @@ def test_get_sublet_other(self): "negotiable": False, "expires_at": "3000-12-12T00:00:00-05:00", "images": [], - "favorite_count": 0, + "saved_count": 0, "listing_type": "sublet", "additional_data": { "address": "3901 Locust Walk, Philadelphia, PA", @@ -894,7 +894,7 @@ def test_get_sublet_other(self): response.json(), expected_response, ["created_at"], - ["", "tags", "images", "buyers", "favorites"], + ["", "tags", "images", "buyers", "saved"], ) def test_get_single_sublet_invalid_id(self): @@ -923,7 +923,7 @@ def test_create_sublet(self): "category": "Sublet", "buyers": [], "tags": ["New"], - "favorites": [], + "saved": [], "listing_type": "sublet", "additional_data": { "address": "3901 Locust Walk, Philadelphia, PA", @@ -950,7 +950,7 @@ def test_create_sublet(self): "seller": self.users[0].id, "buyers": [], "tags": ["New"], - "favorites": [], + "saved": [], "listing_type": "sublet", "additional_data": { "address": "3901 Locust Walk, Philadelphia, PA", @@ -965,7 +965,7 @@ def test_create_sublet(self): response.json(), expected_response, ["created_at"], - ["tags", "images", "buyers", "favorites"], + ["tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -980,7 +980,7 @@ def test_create_sublet(self): response.json(), expected_response, ["created_at"], - ["tags", "images", "buyers", "favorites"], + ["tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -1016,7 +1016,7 @@ def test_update_sublet(self): "seller": self.users[0].id, "buyers": [], "tags": ["Apartment", "Used"], - "favorites": [], + "saved": [], "listing_type": "sublet", "additional_data": { "address": "3901 Locust Walk, Philadelphia, PA", @@ -1034,7 +1034,7 @@ def test_update_sublet(self): "seller": 1, "buyers": [], "tags": ["Used", "Apartment"], - "favorites": [], + "saved": [], "title": "Cira Green Sublet 2", "description": ( "Fully furnished 3-bedroom apartment available for sublet " @@ -1060,7 +1060,7 @@ def test_update_sublet(self): response.json(), expected_response, ["created_at"], - ["tags", "images", "buyers", "favorites"], + ["tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -1075,7 +1075,7 @@ def test_update_sublet(self): response.json(), expected_response, ["created_at"], - ["tags", "images", "buyers", "favorites"], + ["tags", "images", "buyers", "saved"], ) self.assertLessEqual( abs( @@ -1091,7 +1091,7 @@ def test_update_sublet_non_sublet_category(self): "seller": 1, "buyers": [], "tags": ["Used", "Apartment"], - "favorites": [], + "saved": [], "title": "Cira Green Sublet 2", "description": ( "Fully furnished 3-bedroom apartment available for sublet " @@ -1127,7 +1127,7 @@ def test_update_sublet_invalid_date(self): "seller": 1, "buyers": [], "tags": ["Used", "Apartment"], - "favorites": [], + "saved": [], "title": "Cira Green Sublet 2", "description": ( "Fully furnished 3-bedroom apartment available for sublet " @@ -1380,23 +1380,23 @@ def test_delete_offer_nonexistent(self): self.assertEqual(response.status_code, 404) -class TestFavorites(BaseMarketTest): +class TestSavedListings(BaseMarketTest): def setUp(self): super().setUp() self.items = self.load_items( "tests/market/self_user_items.json", self.users[0] ) + self.load_items("tests/market/user_1_items.json", self.users[1]) - self.items[0].favorites.add(self.users[1]) - self.items[1].favorites.add(self.users[0]) + self.items[0].saved.add(self.users[1]) + self.items[1].saved.add(self.users[0]) - def test_get_favorites_for_item_owned(self): + def test_get_saved_for_item_owned(self): response = self.client.get(f"/market/listings/{self.items[0].id}/") expected_response = { "id": self.items[0].id, "seller": self.users[0].id, "buyers": [], "tags": ["Used", "Textbook"], - "favorites": [self.users[1].id], + "saved": [self.users[1].id], "title": "Math Textbook", "description": "2023 version", "external_link": "https://example.com/book", @@ -1413,10 +1413,10 @@ def test_get_favorites_for_item_owned(self): response.json(), expected_response, ["created_at"], - ["tags", "images", "buyers", "favorites"], + ["tags", "images", "buyers", "saved"], ) - def test_get_favorites_for_item_other(self): + def test_get_saved_for_item_other(self): response = self.client.get(f"/market/listings/{self.items[1].id}/") expected_response = { "id": self.items[1].id, @@ -1430,7 +1430,7 @@ def test_get_favorites_for_item_other(self): "negotiable": True, "expires_at": "3000-08-12T01:00:00-04:00", "images": [], - "favorite_count": 1, + "saved_count": 1, "listing_type": "item", "additional_data": {"condition": "NEW", "category": "Electronics"}, } @@ -1439,40 +1439,46 @@ def test_get_favorites_for_item_other(self): response.json(), expected_response, ["created_at"], - ["tags", "images", "buyers", "favorites"], + ["tags", "images", "buyers", "saved"], ) - def test_post_favorite(self): - response = self.client.post(f"/market/listings/{self.items[2].id}/favorites/") + def test_save_listing(self): + response = self.client.post(f"/market/listings/{self.items[2].id}/saved/") self.assertEqual(response.status_code, 201) - self.assertEqual(Item.objects.get(id=self.items[2].id).favorites.count(), 1) + self.assertEqual(Item.objects.get(id=self.items[2].id).saved.count(), 1) self.assertEqual( - Item.objects.get(id=self.items[2].id).favorites.first(), + Item.objects.get(id=self.items[2].id).saved.first(), self.users[0], ) - def test_post_favorite_existing(self): - response = self.client.post(f"/market/listings/{self.items[1].id}/favorites/") - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), ["Favorite already exists"]) - self.assertEqual(Item.objects.get(id=self.items[1].id).favorites.count(), 1) + def test_save_listing_existing(self): + response = self.client.post(f"/market/listings/{self.items[1].id}/saved/") + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + {"saved": True, "detail": "User has already saved the listing"}, + ) + self.assertEqual(Item.objects.get(id=self.items[1].id).saved.count(), 1) - def test_delete_favorite(self): - response = self.client.delete(f"/market/listings/{self.items[1].id}/favorites/") - self.assertEqual(response.status_code, 204) - self.assertEqual(Item.objects.get(id=self.items[1].id).favorites.count(), 0) + def test_unsave_listing(self): + response = self.client.delete(f"/market/listings/{self.items[1].id}/saved/") + self.assertEqual(response.status_code, 200) + self.assertEqual(Item.objects.get(id=self.items[1].id).saved.count(), 0) - def test_delete_nonexistent_favorite(self): - response = self.client.delete(f"/market/listings/{self.items[2].id}/favorites/") + def test_unsave_nonexistent_listing(self): + response = self.client.delete(f"/market/listings/{self.items[2].id}/saved/") self.assertEqual(response.status_code, 404) - self.assertEqual(Item.objects.get(id=self.items[2].id).favorites.count(), 0) - self.assertEqual(response.json(), {"detail": "Favorite does not exist."}) + self.assertEqual(Item.objects.get(id=self.items[2].id).saved.count(), 0) + self.assertEqual( + response.json(), + {"saved": False, "detail": "User hasn't saved the listing yet"}, + ) - def test_delete_favorite_nonexistent_item(self): + def test_unsave_nonexistent_item(self): invalid_id = 1 while Listing.objects.filter(id=invalid_id).exists(): invalid_id += 1 - response = self.client.delete(f"/market/listings/{invalid_id}/favorites/") + response = self.client.delete(f"/market/listings/{invalid_id}/saved/") self.assertEqual(response.status_code, 404) self.assertEqual( response.json(), {"detail": "No Listing matches the given query."} diff --git a/frontend/components/listings/detail/ListingDetail.tsx b/frontend/components/listings/detail/ListingDetail.tsx index 358023e..6fa3588 100644 --- a/frontend/components/listings/detail/ListingDetail.tsx +++ b/frontend/components/listings/detail/ListingDetail.tsx @@ -1,9 +1,9 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { addToUsersFavorites, deleteFromUsersFavorites, getListing } from "@/lib/actions"; +import { saveListing, unsaveListing, getListing } from "@/lib/actions"; import { queryKeys } from "@/lib/queryKeys"; -import { Heart, Share } from "lucide-react"; +import { Bookmark, Share } from "lucide-react"; import { Item, Sublet } from "@/lib/types"; import { ListingActions } from "@/components/listings/detail/ListingActions"; import { ListingImageGallery } from "@/components/listings/detail/ListingImageGallery"; @@ -25,34 +25,34 @@ export const ListingDetail = ({ listingId }: Props) => { queryFn: () => getListing(String(listingId)), }); - const isFavorited = listing?.is_favorited ?? false; + const isSaved = listing?.is_saved ?? false; - const toggleFavoriteMutation = useMutation({ + const toggleSaveMutation = useMutation({ meta: { suppressErrorToast: true }, // since it's noisy to show error toast on top of optimistic update - mutationFn: async (shouldFavorite: boolean) => { - if (shouldFavorite) { - await addToUsersFavorites(listingId); + mutationFn: async (shouldSave: boolean) => { + if (shouldSave) { + await saveListing(listingId); } else { - await deleteFromUsersFavorites(listingId); + await unsaveListing(listingId); } }, - onMutate: async (shouldFavorite: boolean) => { + onMutate: async (shouldSave: boolean) => { await queryClient.cancelQueries({ queryKey }); const previous = queryClient.getQueryData(queryKey); if (previous) { - queryClient.setQueryData(queryKey, { ...previous, is_favorited: shouldFavorite }); + queryClient.setQueryData(queryKey, { ...previous, is_saved: shouldSave }); } return { previous }; }, - onError: (_error, _shouldFavorite, context) => { + onError: (_error, _shouldSave, context) => { if (context?.previous) { queryClient.setQueryData(queryKey, context.previous); } }, }); - const handleToggleFavorite = async () => { - toggleFavoriteMutation.mutate(!isFavorited); + const handleToggleSave = async () => { + toggleSaveMutation.mutate(!isSaved); }; if (!listing) return null; @@ -73,11 +73,11 @@ export const ListingDetail = ({ listingId }: Props) => { diff --git a/frontend/lib/actions.ts b/frontend/lib/actions.ts index 439c970..e64243e 100644 --- a/frontend/lib/actions.ts +++ b/frontend/lib/actions.ts @@ -242,21 +242,21 @@ export async function verifyPhoneCode(phoneNumber: string, code: string) { } // ------------------------------------------------------------ -// adding and removing listings from favorites +// saving and unsaving listings // ------------------------------------------------------------ -export async function addToUsersFavorites(listingId: number) { - return await serverFetch(`/market/listings/${listingId}/favorites/`, { +export async function saveListing(listingId: number) { + return await serverFetch(`/market/listings/${listingId}/saved/`, { method: "POST", }); } -export async function deleteFromUsersFavorites(listingId: number) { - return await serverFetch(`/market/listings/${listingId}/favorites/`, { +export async function unsaveListing(listingId: number) { + return await serverFetch(`/market/listings/${listingId}/saved/`, { method: "DELETE", }); } -export async function getUsersFavorites() { - return await serverFetch>("/market/favorites/"); +export async function getSavedListings() { + return await serverFetch>("/market/saved/"); } // ------------------------------------------------------------ diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index c21cf62..ed49621 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -123,8 +123,8 @@ type BaseListing = { expires_at: string; images: string[]; tags: string[]; - favorite_count: number; - is_favorited?: boolean; + saved_count: number; + is_saved?: boolean; seller: User; };