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