Skip to content

Commit 2a2ed16

Browse files
jamesdoh0109LautaroJBeck
authored andcommitted
Adding favorite items functionality
2 parents 739c323 + 2b56d83 commit 2a2ed16

8 files changed

Lines changed: 143 additions & 25 deletions

File tree

.github/workflows/build.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Build Marketplace
2+
3+
on: push
4+
5+
jobs:
6+
backend-check:
7+
name: Backend Checks
8+
runs-on: ubuntu-latest
9+
container:
10+
image: ghcr.io/astral-sh/uv:python3.11-bookworm
11+
steps:
12+
- uses: actions/checkout@v4
13+
- name: Install dependencies
14+
run: |-
15+
cd backend
16+
uv sync --frozen
17+
- name: Run ruff check
18+
run: |-
19+
cd backend
20+
uv run ruff check .
21+
- name: Run ruff format
22+
run: |-
23+
cd backend
24+
uv run ruff format .
25+
26+
frontend-check:
27+
name: Frontend Checks
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v4
31+
- name: Setup pnpm
32+
uses: pnpm/action-setup@v4
33+
with:
34+
version: 9
35+
- name: Setup Node.js
36+
uses: actions/setup-node@v4
37+
with:
38+
node-version: 24
39+
cache: "pnpm"
40+
cache-dependency-path: frontend/pnpm-lock.yaml
41+
- name: Install dependencies
42+
run: |-
43+
cd frontend
44+
pnpm install --frozen-lockfile
45+
- name: Run ESLint
46+
run: |-
47+
cd frontend
48+
pnpm lint
49+
- name: Run type check
50+
run: |-
51+
cd frontend
52+
pnpm typecheck

backend/market/views.py

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

5555
def get_queryset(self):
5656
user = self.request.user
57-
return user.listings_favorited
57+
return user.listings_favorited.all()
5858

5959

6060
# TODO: Can add feature to filter for active offers only
@@ -250,26 +250,32 @@ class Favorites(
250250

251251
def get_queryset(self):
252252
user = self.request.user
253-
return user.listings_favorited
253+
return user.listings_favorited.all()
254254

255255
def create(self, request, *args, **kwargs):
256256
listing_id = int(self.kwargs["listing_id"])
257-
queryset = self.get_queryset()
258-
if queryset.filter(id=listing_id).exists():
259-
raise exceptions.ValidationError("Favorite already exists")
257+
favorites = request.user.listings_favorited
258+
if favorites.filter(id=listing_id).exists():
259+
return Response(
260+
{"favorited": True, "detail": "Favorite already exists"},
261+
status=status.HTTP_200_OK,
262+
)
260263
listing = get_object_or_404(Listing, id=listing_id)
261-
self.get_queryset().add(listing)
262-
return Response(status=status.HTTP_201_CREATED)
264+
favorites.add(listing)
265+
return Response({"favorited": True}, status=status.HTTP_200_OK)
263266

264267
def destroy(self, request, *args, **kwargs):
265268
listing_id = int(self.kwargs["listing_id"])
266269
listing = get_object_or_404(Listing, id=listing_id)
267270

268271
if listing not in request.user.listings_favorited.all():
269-
raise exceptions.NotFound("Favorite does not exist.")
272+
return Response(
273+
{"favorited": False, "detail": "Favorite does not exist"},
274+
status=status.HTTP_200_OK,
275+
)
270276

271-
self.get_queryset().remove(listing)
272-
return Response(status=status.HTTP_204_NO_CONTENT)
277+
request.user.listings_favorited.remove(listing)
278+
return Response({"favorited": False}, status=status.HTTP_200_OK)
273279

274280

275281
class Offers(viewsets.ModelViewSet):

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
2-
import { getListing } from "@/lib/actions";
2+
import { getListing, getUsersFavorites } from "@/lib/actions";
33

44
export default async function ItemPage({ params }: { params: Promise<{ id: string }> }) {
55
const { id } = await params;
66
const item = await getListing(id);
7+
const favorites = await getUsersFavorites().catch(() => null);
8+
const isFavorited = Boolean(favorites?.results?.some((favorite) => favorite.id === item.id));
79

8-
return <ListingDetail listing={item} />;
10+
return <ListingDetail listing={item} initialIsFavorited={isFavorited} />;
911
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { ListingDetail } from "@/components/listings/detail/ListingDetail";
2-
import { getListing } from "@/lib/actions";
2+
import { getListing, getUsersFavorites } from "@/lib/actions";
33

44
export default async function SubletPage({ params }: { params: Promise<{ id: string }> }) {
55
const { id } = await params;
66
const sublet = await getListing(id);
7+
const favorites = await getUsersFavorites().catch(() => null);
8+
const isFavorited = Boolean(favorites?.results?.some((favorite) => favorite.id === sublet.id));
79

8-
return <ListingDetail listing={sublet} />;
10+
return <ListingDetail listing={sublet} initialIsFavorited={isFavorited} />;
911
}

frontend/components/listings/ListingsCard.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { Calendar } from "lucide-react";
44
import { Badge } from "@/components/ui/badge";
55
import { formatPrice, formatCondition, formatDate } from "@/lib/utils";
66
import { Item, Sublet } from "@/lib/types";
7-
import defaultImage from "@/public/images/default-image.jpg";
7+
8+
const DEFAULT_IMAGE = "/images/default-image.jpg";
89

910
interface Props {
1011
listing: Item | Sublet;
@@ -62,7 +63,7 @@ export const ListingsCard = ({ listing, previewImageUrl, href, isMyListing = fal
6263
{/* image container */}
6364
<div className="relative aspect-square w-full overflow-hidden bg-gray-100">
6465
<Image
65-
src={previewImageUrl || defaultImage}
66+
src={previewImageUrl || DEFAULT_IMAGE}
6667
alt={listing.title}
6768
fill
6869
className="object-cover transition-transform duration-200 group-hover:scale-105"

frontend/components/listings/detail/ListingDetail.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,57 @@
1+
"use client";
2+
3+
import { useState } from "react";
14
import { Heart, Share } from "lucide-react";
25
import { Item, Sublet } from "@/lib/types";
36
import { ListingActions } from "@/components/listings/detail/ListingActions";
47
import { ListingImageGallery } from "@/components/listings/detail/ListingImageGallery";
58
import { ListingInfo } from "@/components/listings/detail/ListingInfo";
69
import { UserCard } from "@/components/listings/detail/UserCard";
710
import { BackButton } from "@/components/listings/detail/BackButton";
11+
import { addToUsersFavorites, deleteFromUsersFavorites } from "@/lib/actions";
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 [isInsideFavorites, setIsInsideFavorites] = useState(initialIsFavorited);
23+
24+
const handleToggleFavorite = async () => {
25+
try {
26+
if (isInsideFavorites) {
27+
await deleteFromUsersFavorites(listing.id);
28+
setIsInsideFavorites(false);
29+
} else {
30+
await addToUsersFavorites(listing.id);
31+
setIsInsideFavorites(true);
32+
}
33+
} catch (err) {
34+
// Ignore favorite toggle errors in UI
35+
console.log(err);
36+
}
37+
};
1738

1839
return (
1940
<div className="mx-auto flex w-full max-w-[96rem] flex-col p-8 px-4 sm:px-12">
2041
<div className="mb-4 flex items-center justify-between">
2142
<BackButton />
2243
<div className="flex items-center gap-3">
2344
<Share className="h-5 w-5" />
24-
<Heart className="h-5 w-5" />
45+
<button
46+
type="button"
47+
onClick={handleToggleFavorite}
48+
aria-pressed={isInsideFavorites}
49+
aria-label={isInsideFavorites ? "Remove from favorites" : "Add to favorites"}
50+
>
51+
<Heart
52+
className={isInsideFavorites ? "h-5 w-5 fill-red-500 text-red-500" : "h-5 w-5"}
53+
/>
54+
</button>
2555
</div>
2656
</div>
2757
<div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">

frontend/components/listings/detail/ListingImageGallery.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import Image from "next/image";
44
import { useState } from "react";
5-
import defaultImage from "@/public/images/default-image.jpg";
65
import { cn } from "@/lib/utils";
76

7+
const DEFAULT_IMAGE = "/images/default-image.jpg";
8+
89
interface Props {
910
images: string[];
1011
}
@@ -17,7 +18,7 @@ export const ListingImageGallery = ({ images }: Props) => {
1718
<div className="relative h-[400px] overflow-hidden rounded-2xl md:h-[450px] lg:h-[500px]">
1819
{/* blurred background */}
1920
<Image
20-
src={images[selectedImage] || defaultImage}
21+
src={images[selectedImage] || DEFAULT_IMAGE}
2122
alt="Background"
2223
fill
2324
className="scale-110 object-cover opacity-50 blur-2xl"
@@ -27,7 +28,7 @@ export const ListingImageGallery = ({ images }: Props) => {
2728
{/* main selected image */}
2829
<div className="relative flex h-full w-full items-center justify-center">
2930
<Image
30-
src={images[selectedImage] || defaultImage}
31+
src={images[selectedImage] || DEFAULT_IMAGE}
3132
alt="Listing image"
3233
fill
3334
className="object-contain"

frontend/lib/actions.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,16 @@ import { cookies } from "next/headers";
44
import { FETCH_LISTINGS_LIMIT } from "@/constants/listings";
55
import { API_BASE_URL } from "@/lib/constants";
66
import { APIError, ErrorMessages } from "@/lib/errors";
7-
import { AuthTokens, CreateItemPayload, CreateSubletPayload, Item, Listing, PaginatedResponse, Sublet, User } from "@/lib/types";
7+
import {
8+
AuthTokens,
9+
CreateItemPayload,
10+
CreateSubletPayload,
11+
Item,
12+
Listing,
13+
PaginatedResponse,
14+
Sublet,
15+
User,
16+
} from "@/lib/types";
817

918
async function getTokensFromCookies(): Promise<AuthTokens | null> {
1019
try {
@@ -201,20 +210,35 @@ export async function verifyPhoneCode(phoneNumber: string, code: string) {
201210
body: JSON.stringify({ phone_number: phoneNumber, code }),
202211
});
203212
}
213+
// ------------------------------------------------------------
214+
// adding and removing listings from favorites
215+
// ------------------------------------------------------------
204216

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+
}
205228

229+
export async function getUsersFavorites() {
230+
return await serverFetch<PaginatedResponse<Item | Sublet>>("/market/favorites/");
231+
}
206232

207233
// ------------------------------------------------------------
208234
// creating new listings
209235
// ------------------------------------------------------------
210236

211-
212-
213237
export type CreateListingPayload = CreateItemPayload | CreateSubletPayload;
214238

215239
export async function createListing(payload: CreateListingPayload): Promise<Listing> {
216240
return await serverFetch<Listing>("/market/listings/", {
217241
method: "POST",
218242
body: JSON.stringify(payload),
219243
});
220-
}
244+
}

0 commit comments

Comments
 (0)