Skip to content

Commit 7c5bc49

Browse files
author
Anthony Li
committed
Merge remote-tracking branch 'origin/master' into frontend/sublet-map
2 parents a22f614 + 4657130 commit 7c5bc49

33 files changed

Lines changed: 1021 additions & 153 deletions

.github/workflows/build.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
name: Build Marketplace
22

3-
on: push
3+
on:
4+
push:
5+
pull_request:
46

57
jobs:
68
backend-check:

backend/market/serializers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ class ListingSerializer(ListingTypeMixin, ModelSerializer):
147147
seller = UserSerializer(read_only=True)
148148
listing_type = SerializerMethodField()
149149
additional_data = SerializerMethodField()
150+
is_favorited = SerializerMethodField()
150151
external_link = URLField(required=False, allow_blank=True, allow_null=True)
151152
negotiable = BooleanField(required=False, default=True)
152153
expires_at = DateTimeField(required=False, allow_null=True)
@@ -169,6 +170,7 @@ class Meta:
169170
"images",
170171
"listing_type",
171172
"additional_data",
173+
"is_favorited",
172174
]
173175
read_only_fields = [
174176
"id",
@@ -210,6 +212,12 @@ def validate(self, attrs):
210212

211213
return super().validate(attrs)
212214

215+
def get_is_favorited(self, obj):
216+
request = self.context.get("request")
217+
if not request or not request.user or not request.user.is_authenticated:
218+
return False
219+
return request.user.listings_favorited.filter(id=obj.id).exists()
220+
213221
def validate_title(self, value):
214222
if self.contains_profanity(value):
215223
raise ValidationError("The title contains inappropriate language.")
@@ -358,6 +366,7 @@ def _update_sublet(self, instance, additional_data):
358366
class ListingSerializerPublic(ListingTypeMixin, ModelSerializer):
359367
buyer_count = SerializerMethodField()
360368
favorite_count = SerializerMethodField()
369+
is_favorited = SerializerMethodField()
361370
tags = SlugRelatedField(many=True, slug_field="name", queryset=Tag.objects.all())
362371
images = ListingImageURLSerializer(many=True)
363372
seller = UserSerializer(read_only=True)
@@ -381,6 +390,7 @@ class Meta:
381390
"favorite_count",
382391
"listing_type",
383392
"additional_data",
393+
"is_favorited",
384394
]
385395
read_only_fields = fields
386396

@@ -390,6 +400,12 @@ def get_buyer_count(self, obj):
390400
def get_favorite_count(self, obj):
391401
return obj.favorites.count()
392402

403+
def get_is_favorited(self, obj):
404+
request = self.context.get("request")
405+
if not request or not request.user or not request.user.is_authenticated:
406+
return False
407+
return request.user.listings_favorited.filter(id=obj.id).exists()
408+
393409

394410
# Read-only serializer for use when pulling all listings /etc
395411
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/api/geocode/route.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import type { PhotonReponse, AddressResult, PhotonFeature } from "@/lib/types";
3+
4+
function photonFeatureToAddressResult(feature: PhotonFeature): AddressResult {
5+
const props = feature.properties;
6+
const [lon, lat] = feature.geometry.coordinates;
7+
8+
const addressParts = [
9+
props.housenumber && props.street
10+
? `${props.housenumber} ${props.street}`
11+
: (props.street ?? props.name),
12+
props.city,
13+
[props.state, props.postcode].filter(Boolean).join(" "),
14+
].filter(Boolean);
15+
16+
const displayName = addressParts.join(", ");
17+
18+
return {
19+
placeId: props.osm_id,
20+
lat: lat.toString(),
21+
lon: lon.toString(),
22+
displayName,
23+
address: {
24+
housenumber: props.housenumber,
25+
road: props.street,
26+
city: props.city,
27+
state: props.state,
28+
postCode: props.postcode,
29+
country: props.country,
30+
countryCode: props.countrycode,
31+
},
32+
};
33+
}
34+
35+
export async function GET(request: NextRequest) {
36+
const query = request.nextUrl.searchParams.get("q");
37+
38+
if (!query || query.trim().length < 3) {
39+
return NextResponse.json([]);
40+
}
41+
42+
try {
43+
// Philadelphia bounding box
44+
const bbox = "-75.28,39.87,-75.0,40.14";
45+
46+
const params = new URLSearchParams({
47+
q: query,
48+
limit: "5", // maximum number of results returned
49+
lang: "en",
50+
bbox: bbox,
51+
});
52+
53+
const response = await fetch(`https://photon.komoot.io/api/?${params.toString()}`, {
54+
headers: {
55+
Accept: "application/json",
56+
},
57+
});
58+
59+
if (!response.ok) {
60+
throw new Error(`HTTP error! status: ${response.status}`);
61+
}
62+
63+
const data: PhotonReponse = await response.json();
64+
const results = data.features.map(photonFeatureToAddressResult);
65+
66+
return NextResponse.json(results);
67+
} catch (error) {
68+
console.error("Geocode API error:", error);
69+
return NextResponse.json({ error: "Failed to fetch addresses" }, { status: 500 });
70+
}
71+
}

frontend/app/error.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"use client";
2+
3+
export default function Error({
4+
error,
5+
reset,
6+
}: {
7+
error: Error & { digest?: string };
8+
reset: () => void;
9+
}) {
10+
return (
11+
<div className="flex w-full flex-col items-center space-y-4 py-24">
12+
<h2 className="text-lg font-semibold">Something went wrong</h2>
13+
<p className="text-muted-foreground text-sm">An unexpected error occurred.</p>
14+
<button
15+
onClick={reset}
16+
className="bg-brand hover:bg-brand-hover rounded-md px-4 py-2 text-sm text-white"
17+
>
18+
Try again
19+
</button>
20+
{process.env.NODE_ENV === "development" && (
21+
<details className="text-muted-foreground mt-4 max-w-lg text-xs">
22+
<summary className="cursor-pointer">Error details</summary>
23+
<pre className="mt-2 overflow-auto rounded bg-gray-100 p-3 dark:bg-gray-900">
24+
{error.message}
25+
{error.digest && `\nDigest: ${error.digest}`}
26+
</pre>
27+
</details>
28+
)}
29+
</div>
30+
);
31+
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
5+
export default function ItemError({
6+
error,
7+
reset,
8+
}: {
9+
error: Error & { digest?: string };
10+
reset: () => void;
11+
}) {
12+
return (
13+
<div className="flex w-full flex-col items-center space-y-4 py-24">
14+
<h2 className="text-lg font-semibold">Something went wrong</h2>
15+
<p className="text-muted-foreground text-sm">Failed to load this listing.</p>
16+
<div className="flex gap-3">
17+
<button
18+
onClick={reset}
19+
className="bg-brand hover:bg-brand-hover rounded-md px-4 py-2 text-sm text-white"
20+
>
21+
Try again
22+
</button>
23+
<Link href="/items" className="rounded-md border px-4 py-2 text-sm">
24+
Back to items
25+
</Link>
26+
</div>
27+
{process.env.NODE_ENV === "development" && (
28+
<details className="text-muted-foreground mt-4 max-w-lg text-xs">
29+
<summary className="cursor-pointer">Error details</summary>
30+
<pre className="mt-2 overflow-auto rounded bg-gray-100 p-3 dark:bg-gray-900">
31+
{error.message}
32+
{error.digest && `\nDigest: ${error.digest}`}
33+
</pre>
34+
</details>
35+
)}
36+
</div>
37+
);
38+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Link from "next/link";
2+
3+
export default function ItemNotFound() {
4+
return (
5+
<div className="flex w-full flex-col items-center space-y-4 py-24 text-center">
6+
<h2 className="text-lg font-semibold">Listing not found</h2>
7+
<p className="text-muted-foreground text-sm">
8+
This listing may have been removed or doesn&apos;t exist.
9+
</p>
10+
<Link
11+
href="/items"
12+
className="bg-brand hover:bg-brand-hover rounded-md px-4 py-2 text-sm text-white"
13+
>
14+
Back to items
15+
</Link>
16+
</div>
17+
);
18+
}

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

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

44
export default async function ItemPage({ params }: { params: Promise<{ id: string }> }) {
55
const { id } = await params;
6-
const item = await getListing(id);
6+
const item = await getListingOrNotFound(id);
77

8-
return <ListingDetail listing={item} />;
8+
return <ListingDetail listing={item} initialIsFavorited={item.is_favorited ?? false} />;
99
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
5+
export default function SubletError({
6+
error,
7+
reset,
8+
}: {
9+
error: Error & { digest?: string };
10+
reset: () => void;
11+
}) {
12+
return (
13+
<div className="flex w-full flex-col items-center space-y-4 py-24">
14+
<h2 className="text-lg font-semibold">Something went wrong</h2>
15+
<p className="text-muted-foreground text-sm">Failed to load this listing.</p>
16+
<div className="flex gap-3">
17+
<button
18+
onClick={reset}
19+
className="bg-brand hover:bg-brand-hover rounded-md px-4 py-2 text-sm text-white"
20+
>
21+
Try again
22+
</button>
23+
<Link href="/sublets" className="rounded-md border px-4 py-2 text-sm">
24+
Back to sublets
25+
</Link>
26+
</div>
27+
{process.env.NODE_ENV === "development" && (
28+
<details className="text-muted-foreground mt-4 max-w-lg text-xs">
29+
<summary className="cursor-pointer">Error details</summary>
30+
<pre className="mt-2 overflow-auto rounded bg-gray-100 p-3 dark:bg-gray-900">
31+
{error.message}
32+
{error.digest && `\nDigest: ${error.digest}`}
33+
</pre>
34+
</details>
35+
)}
36+
</div>
37+
);
38+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Link from "next/link";
2+
3+
export default function SubletNotFound() {
4+
return (
5+
<div className="flex w-full flex-col items-center space-y-4 py-24 text-center">
6+
<h2 className="text-lg font-semibold">Listing not found</h2>
7+
<p className="text-muted-foreground text-sm">
8+
This listing may have been removed or doesn&apos;t exist.
9+
</p>
10+
<Link
11+
href="/sublets"
12+
className="bg-brand hover:bg-brand-hover rounded-md px-4 py-2 text-sm text-white"
13+
>
14+
Back to sublets
15+
</Link>
16+
</div>
17+
);
18+
}

0 commit comments

Comments
 (0)