diff --git a/backend/market/models.py b/backend/market/models.py
index 5c50ca3..7812865 100644
--- a/backend/market/models.py
+++ b/backend/market/models.py
@@ -1,6 +1,8 @@
import hashlib
+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
@@ -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/frontend/components/listings/detail/ListingDetail.tsx b/frontend/components/listings/detail/ListingDetail.tsx
index a4eb4e5..fb61972 100644
--- a/frontend/components/listings/detail/ListingDetail.tsx
+++ b/frontend/components/listings/detail/ListingDetail.tsx
@@ -9,6 +9,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;
@@ -55,6 +56,11 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
toggleFavoriteMutation.mutate(!isFavorited);
};
+ const subletCoords =
+ listingType === "sublet" ? listing.additional_data : null;
+ const hasLocation =
+ subletCoords?.latitude != null && subletCoords?.longitude != null;
+
return (
@@ -83,6 +89,20 @@ export const ListingDetail = ({ listing, initialIsFavorited }: Props) => {
{...listing.additional_data}
/>
+ {hasLocation && (
+
+
+
{"Where you'll be living"}
+
+ Approximate location shown. The exact location will be shared once you connect with the owner.
+
+
+
+
+ )}
+ import("@/components/listings/detail/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..6c1e00a 100644
--- a/frontend/lib/constants.ts
+++ b/frontend/lib/constants.ts
@@ -11,6 +11,7 @@ export const BASE_URL =
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
+
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 69342ad..c21cf62 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -40,12 +40,12 @@ export type ItemAdditionalData = {
export type SubletAdditionalData = {
street_address: string;
- latitude: number;
- longitude: number;
beds: number;
baths: number;
start_date: string;
end_date: string;
+ latitude: number;
+ longitude: number;
};
// ------------------------------------------------------------
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