From 6e0252a82c6c0cbb399a7531306e2e99256a9347 Mon Sep 17 00:00:00 2001 From: Anthony Li Date: Fri, 27 Mar 2026 17:45:49 -0400 Subject: [PATCH 1/6] Add Leaflet map to sublet page --- docker-compose.yml | 3 ++ .../listings/detail/ListingDetail.tsx | 15 ++++++ .../components/listings/detail/SubletMap.tsx | 18 +++++++ .../listings/detail/SubletMapContent.tsx | 37 +++++++++++++ frontend/lib/constants.ts | 9 +++- frontend/lib/types.ts | 2 + frontend/package.json | 3 ++ frontend/pnpm-lock.yaml | 53 +++++++++++++++++++ 8 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 frontend/components/listings/detail/SubletMap.tsx create mode 100644 frontend/components/listings/detail/SubletMapContent.tsx diff --git a/docker-compose.yml b/docker-compose.yml index 8b98363..626a572 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,7 +68,10 @@ services: - penn-marketplace-frontend-pnpm-store:/app/.pnpm-store environment: - NODE_ENV=development + # Browser on your machine → API on published port - NEXT_PUBLIC_API_URL=http://localhost:8000 + # Next server inside this container → Django service name + - API_BASE_URL=http://backend:8000 volumes: penn-marketplace-postgres-data: diff --git a/frontend/components/listings/detail/ListingDetail.tsx b/frontend/components/listings/detail/ListingDetail.tsx index d5ea412..26b7338 100644 --- a/frontend/components/listings/detail/ListingDetail.tsx +++ b/frontend/components/listings/detail/ListingDetail.tsx @@ -5,6 +5,7 @@ import { ListingImageGallery } from "@/components/listings/detail/ListingImageGa import { ListingInfo } from "@/components/listings/detail/ListingInfo"; import { UserCard } from "@/components/listings/detail/UserCard"; import { BackButton } from "@/components/listings/detail/BackButton"; +import { SubletMap } from "@/components/listings/detail/SubletMap"; interface Props { listing: Item | Sublet; @@ -15,6 +16,11 @@ export const ListingDetail = ({ listing }: Props) => { const priceLabel = listingType === "sublet" ? "/mo" : undefined; const listingOwnerLabel = listingType === "item" ? "Seller" : "Owner"; + const subletCoords = + listingType === "sublet" ? listing.additional_data : null; + const hasLocation = + subletCoords?.latitude != null && subletCoords?.longitude != null; + return (
@@ -41,6 +47,15 @@ export const ListingDetail = ({ listing }: Props) => { priceLabel={priceLabel} listingOwnerLabel={listingOwnerLabel} /> + {hasLocation && ( +
+

Where you'll be living

+ +
+ )}
diff --git a/frontend/components/listings/detail/SubletMap.tsx b/frontend/components/listings/detail/SubletMap.tsx new file mode 100644 index 0000000..40feaf9 --- /dev/null +++ b/frontend/components/listings/detail/SubletMap.tsx @@ -0,0 +1,18 @@ +"use client"; + +import dynamic from "next/dynamic"; + +interface Props { + latitude: number; + longitude: number; +} + +const LazyMap = dynamic( + () => + import("./SubletMapContent").then((m) => m.SubletMapContent), + { ssr: false }, +); + +export const SubletMap = ({ latitude, longitude }: Props) => { + return ; +}; diff --git a/frontend/components/listings/detail/SubletMapContent.tsx b/frontend/components/listings/detail/SubletMapContent.tsx new file mode 100644 index 0000000..a3a3203 --- /dev/null +++ b/frontend/components/listings/detail/SubletMapContent.tsx @@ -0,0 +1,37 @@ +"use client"; + +import "leaflet/dist/leaflet.css"; +import { MapContainer, TileLayer, Circle } from "react-leaflet"; + +interface Props { + latitude: number; + longitude: number; +} + +const CIRCLE_RADIUS_METERS = 200; + +export const SubletMapContent = ({ latitude, longitude }: Props) => { + return ( + + + + + ); +}; diff --git a/frontend/lib/constants.ts b/frontend/lib/constants.ts index 9d95517..757338c 100644 --- a/frontend/lib/constants.ts +++ b/frontend/lib/constants.ts @@ -9,8 +9,15 @@ import { export const BASE_URL = process.env.NODE_ENV === "production" ? "REPLACE WITH PROD BASE URL" : "http://localhost:3000"; +/** + * Server-side fetches (RSC, Route Handlers, middleware). + * - Local `pnpm dev`: default http://localhost:8000 (backend on host port). + * - Docker frontend service: set API_BASE_URL=http://backend:8000 in compose. + */ export const API_BASE_URL = - process.env.NODE_ENV === "production" ? "REPLACE WITH PROD API URL" : "http://backend:8000"; // can't be localhost because server fetch happens in container + process.env.NODE_ENV === "production" + ? process.env.NEXT_PUBLIC_API_URL ?? "REPLACE WITH PROD API URL" + : (process.env.API_BASE_URL ?? "http://localhost:8000"); export const PLATFORM_URL = process.env.PLATFORM_URL; export const CLIENT_ID = process.env.CLIENT_ID; diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 84d5f68..62cd81f 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -44,6 +44,8 @@ export type SubletAdditionalData = { baths: number; start_date: string; end_date: string; + latitude: number | null; + longitude: number | null; }; // ------------------------------------------------------------ diff --git a/frontend/package.json b/frontend/package.json index ad36825..4c25790 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "jose": "^3.20.4", + "leaflet": "^1.9.4", "lucide-react": "^0.546.0", "next": "15.5.4", "next-themes": "^0.4.6", @@ -36,6 +37,7 @@ "react-dom": "19.1.0", "react-hook-form": "^7.69.0", "react-intersection-observer": "^9.16.0", + "react-leaflet": "^5.0.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "zod": "^4.2.1" @@ -43,6 +45,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 3a02a3a..047fa8a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: jose: specifier: ^3.20.4 version: 3.20.4 + leaflet: + specifier: ^1.9.4 + version: 1.9.4 lucide-react: specifier: ^0.546.0 version: 0.546.0(react@19.1.0) @@ -65,6 +68,9 @@ importers: react-intersection-observer: specifier: ^9.16.0 version: 9.16.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-leaflet: + specifier: ^5.0.0 + version: 5.0.0(leaflet@1.9.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -81,6 +87,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.1.13 + '@types/leaflet': + specifier: ^1.9.21 + version: 1.9.21 '@types/node': specifier: ^20 version: 20.19.17 @@ -723,6 +732,13 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-leaflet/core@3.0.0': + resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==} + peerDependencies: + leaflet: ^1.9.0 + react: ^19.0.0 + react-dom: ^19.0.0 + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -846,12 +862,18 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/leaflet@1.9.21': + resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/node@20.19.17': resolution: {integrity: sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==} @@ -1695,6 +1717,9 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2050,6 +2075,13 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-leaflet@5.0.0: + resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==} + peerDependencies: + leaflet: ^1.9.0 + react: ^19.0.0 + react-dom: ^19.0.0 + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -2914,6 +2946,12 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + leaflet: 1.9.4 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.12.0': {} @@ -3018,10 +3056,16 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/leaflet@1.9.21': + dependencies: + '@types/geojson': 7946.0.16 + '@types/node@20.19.17': dependencies: undici-types: 6.21.0 @@ -4042,6 +4086,8 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + leaflet@1.9.4: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -4308,6 +4354,13 @@ snapshots: react-is@16.13.1: {} + react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + leaflet: 1.9.4 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll-bar@2.3.8(@types/react@19.1.15)(react@19.1.0): dependencies: react: 19.1.0 From 76fbc0fafbf91b6bf2d8bb27563fe5cade69a493 Mon Sep 17 00:00:00 2001 From: Anthony Li Date: Fri, 27 Mar 2026 17:56:28 -0400 Subject: [PATCH 2/6] fix formatting --- frontend/components/listings/detail/ListingDetail.tsx | 2 +- frontend/lib/types.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/listings/detail/ListingDetail.tsx b/frontend/components/listings/detail/ListingDetail.tsx index 26b7338..4570f54 100644 --- a/frontend/components/listings/detail/ListingDetail.tsx +++ b/frontend/components/listings/detail/ListingDetail.tsx @@ -49,7 +49,7 @@ export const ListingDetail = ({ listing }: Props) => { /> {hasLocation && (
-

Where you'll be living

+

{"Where you'll be living"}

Date: Sat, 28 Mar 2026 15:43:40 -0400 Subject: [PATCH 3/6] Change latitude/longitude type --- frontend/lib/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 05ea7da..7274a0a 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -44,8 +44,8 @@ export type SubletAdditionalData = { baths: number; start_date: string; end_date: string; - latitude?: number | null; - longitude?: number | null; + latitude?: number; + longitude?: number; }; // ------------------------------------------------------------ From cf2f97a80f7f4c198d59385f8211603a197d049c Mon Sep 17 00:00:00 2001 From: Anthony Li Date: Sat, 28 Mar 2026 15:58:06 -0400 Subject: [PATCH 4/6] Change latitude/longitude type --- frontend/lib/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 72c3cca..4ae3e4b 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -40,8 +40,6 @@ export type ItemAdditionalData = { export type SubletAdditionalData = { street_address: string; - latitude: number; - longitude: number; beds: number; baths: number; start_date: string; From c9d7a373ee06a72396a0cca35e6793ea9747ef94 Mon Sep 17 00:00:00 2001 From: Anthony Li Date: Sun, 29 Mar 2026 20:55:45 -0400 Subject: [PATCH 5/6] Change map placement and description and address comments --- backend/market/models.py | 8 ++++++- backend/market/serializers.py | 21 +++++++++++++------ docker-compose.yml | 3 --- .../listings/detail/ListingDetail.tsx | 19 ++++++++++------- .../components/listings/detail/SubletMap.tsx | 2 +- frontend/lib/constants.ts | 10 ++------- frontend/lib/types.ts | 4 ++-- 7 files changed, 39 insertions(+), 28 deletions(-) diff --git a/backend/market/models.py b/backend/market/models.py index 5c50ca3..c9d581c 100644 --- a/backend/market/models.py +++ b/backend/market/models.py @@ -1,10 +1,12 @@ import hashlib import math +import hmac from django.contrib.auth.models import AbstractUser from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models +from django.conf import settings from phonenumber_field.modelfields import PhoneNumberField @@ -146,7 +148,11 @@ def _calculate_approximate_location(self, latitude, longitude): lat_str = f"{float(latitude):.9f}" lon_str = f"{float(longitude):.9f}" - seed = hashlib.md5(f"{lat_str}{lon_str}".encode()).hexdigest() + seed = hmac.new( + settings.SECRET_KEY.encode(), + f"{lat_str}{lon_str}".encode(), + hashlib.sha256, + ).hexdigest() offset_factor = int(seed[:8], 16) / 0xFFFFFFFF diff --git a/backend/market/serializers.py b/backend/market/serializers.py index 7230532..c129131 100644 --- a/backend/market/serializers.py +++ b/backend/market/serializers.py @@ -104,17 +104,26 @@ class SubletDataSerializer(ModelSerializer): class Meta: model = Sublet - fields = ["street_address", "beds", "baths", "start_date", "end_date", - "latitude", "longitude"] + fields = [ + "street_address", + "beds", + "baths", + "start_date", + "end_date", + "latitude", + "longitude", + ] def get_latitude(self, obj): - if obj.approximate_location is not None: - return float(obj.approximate_location[0]) + approx_lat, _ = obj.approximate_location + if approx_lat is not None: + return float(approx_lat) return None def get_longitude(self, obj): - if obj.approximate_location is not None: - return float(obj.approximate_location[1]) + _, approx_lon = obj.approximate_location + if approx_lon is not None: + return float(approx_lon) return None # Unified serializer for all listing types (Items and Sublets); used for CRUD operations diff --git a/docker-compose.yml b/docker-compose.yml index 626a572..8b98363 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,10 +68,7 @@ services: - penn-marketplace-frontend-pnpm-store:/app/.pnpm-store environment: - NODE_ENV=development - # Browser on your machine → API on published port - NEXT_PUBLIC_API_URL=http://localhost:8000 - # Next server inside this container → Django service name - - API_BASE_URL=http://backend:8000 volumes: penn-marketplace-postgres-data: diff --git a/frontend/components/listings/detail/ListingDetail.tsx b/frontend/components/listings/detail/ListingDetail.tsx index aa7b448..fb61972 100644 --- a/frontend/components/listings/detail/ListingDetail.tsx +++ b/frontend/components/listings/detail/ListingDetail.tsx @@ -89,21 +89,26 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => { {...listing.additional_data} /> - {hasLocation && (
-

{"Where you'll be living"}

+
+

{"Where you'll be living"}

+

+ Approximate location shown. The exact location will be shared once you connect with the owner. +

+
)} +
diff --git a/frontend/components/listings/detail/SubletMap.tsx b/frontend/components/listings/detail/SubletMap.tsx index 40feaf9..e724883 100644 --- a/frontend/components/listings/detail/SubletMap.tsx +++ b/frontend/components/listings/detail/SubletMap.tsx @@ -9,7 +9,7 @@ interface Props { const LazyMap = dynamic( () => - import("./SubletMapContent").then((m) => m.SubletMapContent), + import("@/components/listings/detail/SubletMapContent").then((m) => m.SubletMapContent), { ssr: false }, ); diff --git a/frontend/lib/constants.ts b/frontend/lib/constants.ts index 757338c..6c1e00a 100644 --- a/frontend/lib/constants.ts +++ b/frontend/lib/constants.ts @@ -9,15 +9,9 @@ import { export const BASE_URL = process.env.NODE_ENV === "production" ? "REPLACE WITH PROD BASE URL" : "http://localhost:3000"; -/** - * Server-side fetches (RSC, Route Handlers, middleware). - * - Local `pnpm dev`: default http://localhost:8000 (backend on host port). - * - Docker frontend service: set API_BASE_URL=http://backend:8000 in compose. - */ export const API_BASE_URL = - process.env.NODE_ENV === "production" - ? process.env.NEXT_PUBLIC_API_URL ?? "REPLACE WITH PROD API URL" - : (process.env.API_BASE_URL ?? "http://localhost:8000"); + process.env.NODE_ENV === "production" ? "REPLACE WITH PROD API URL" : "http://backend:8000"; // can't be localhost because server fetch happens in container + export const PLATFORM_URL = process.env.PLATFORM_URL; export const CLIENT_ID = process.env.CLIENT_ID; diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 4ae3e4b..c21cf62 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -44,8 +44,8 @@ export type SubletAdditionalData = { baths: number; start_date: string; end_date: string; - latitude?: number; - longitude?: number; + latitude: number; + longitude: number; }; // ------------------------------------------------------------ From 8935fab678dac7545968bc5f8264055f6f7f2f4d Mon Sep 17 00:00:00 2001 From: Anthony Li Date: Sun, 29 Mar 2026 21:01:49 -0400 Subject: [PATCH 6/6] fix style check --- backend/market/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/market/models.py b/backend/market/models.py index c9d581c..7812865 100644 --- a/backend/market/models.py +++ b/backend/market/models.py @@ -1,12 +1,12 @@ import hashlib -import math import hmac +import math +from django.conf import settings from django.contrib.auth.models import AbstractUser from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models -from django.conf import settings from phonenumber_field.modelfields import PhoneNumberField