From 5b917bd61953c1aa5457322d976fc0ec14a6ac5d Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Mon, 18 May 2026 13:34:57 +0200 Subject: [PATCH] feat: embedded interactive map in property + match detail views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates react-leaflet with OpenStreetMap tiles. PropertyDetailView and MatchDetail now show property photo + zoomable/pannable map above the tabs/content — always visible on card click, no redirect. Map renders at correct lat/lng with a location marker and popup. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 67 +++++++++++++++----- package.json | 3 + src/components/shared/PropertyMap.tsx | 60 ++++++++++++++++++ src/components/shared/index.ts | 1 + src/components/supply/PropertyDetailView.tsx | 23 +++++++ src/pages/demand/MatchDetail.tsx | 25 ++++++++ 6 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 src/components/shared/PropertyMap.tsx diff --git a/package-lock.json b/package-lock.json index 9aa405f..9a4502e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,11 @@ "@mui/icons-material": "^9.0.1", "@mui/material": "^9.0.1", "@tanstack/react-query": "^5.75.2", + "leaflet": "^1.9.4", "lucide-react": "^0.511.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-leaflet": "^5.0.0", "react-router": "^7.15.0", "zod": "^3.25.17", "zustand": "^5.0.5" @@ -23,6 +25,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.0", + "@types/leaflet": "^1.9.21", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -975,6 +978,17 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", @@ -1562,6 +1576,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1569,6 +1590,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "24.12.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", @@ -2776,6 +2807,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3405,6 +3442,20 @@ "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", "license": "MIT" }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/react-router": { "version": "7.15.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", @@ -3858,22 +3909,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "extraneous": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index d26f231..32f5626 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,11 @@ "@mui/icons-material": "^9.0.1", "@mui/material": "^9.0.1", "@tanstack/react-query": "^5.75.2", + "leaflet": "^1.9.4", "lucide-react": "^0.511.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-leaflet": "^5.0.0", "react-router": "^7.15.0", "zod": "^3.25.17", "zustand": "^5.0.5" @@ -25,6 +27,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.0", + "@types/leaflet": "^1.9.21", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/components/shared/PropertyMap.tsx b/src/components/shared/PropertyMap.tsx new file mode 100644 index 0000000..fd20395 --- /dev/null +++ b/src/components/shared/PropertyMap.tsx @@ -0,0 +1,60 @@ +import { useEffect } from 'react' +import { Box } from '@mui/material' +import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet' +import L from 'leaflet' +import 'leaflet/dist/leaflet.css' + +// Fix default marker icons in Vite build +delete (L.Icon.Default.prototype as unknown as Record)._getIconUrl +L.Icon.Default.mergeOptions({ + iconRetinaUrl: new URL('leaflet/dist/images/marker-icon-2x.png', import.meta.url).href, + iconUrl: new URL('leaflet/dist/images/marker-icon.png', import.meta.url).href, + shadowUrl: new URL('leaflet/dist/images/marker-shadow.png', import.meta.url).href, +}) + +interface Props { + lat: number + lng: number + label?: string + height?: number +} + +function RecenterOnChange({ lat, lng }: { lat: number; lng: number }) { + const map = useMap() + useEffect(() => { + map.setView([lat, lng]) + }, [lat, lng, map]) + return null +} + +export function PropertyMap({ lat, lng, label, height = 260 }: Props) { + return ( + + + + + + {label && {label}} + + + + ) +} diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index cbd14c5..8ab6fc4 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -1,4 +1,5 @@ export { ViewToggle } from './ViewToggle' export { LocationPreview } from './LocationPreview' +export { PropertyMap } from './PropertyMap' export { getScoreTier, SCORE_THEME } from './scoreTheme' export type { ScoreTier } from './scoreTheme' diff --git a/src/components/supply/PropertyDetailView.tsx b/src/components/supply/PropertyDetailView.tsx index 52e4ee5..b979090 100644 --- a/src/components/supply/PropertyDetailView.tsx +++ b/src/components/supply/PropertyDetailView.tsx @@ -13,6 +13,7 @@ import { import { X } from 'lucide-react' import { NegotiationInsightsPanel } from './NegotiationInsightsPanel' import { DataQualityPanel as DQPanel, ProvenancePanel } from '../data-quality' +import { LocationPreview, PropertyMap } from '../shared' import type { Property } from '../../domain/property' import type { Match } from '../../domain/match' import type { FutureSignal } from '../../domain/futureSignal' @@ -364,6 +365,28 @@ export function PropertyDetailView({ propertyId, onClose }: PropertyDetailViewPr )} + {/* Photo + Map */} + + {property.images?.[0] && ( + + )} + {property.location.coordinates && ( + + )} + + {/* Tabs */} + {/* Photo + Map */} + {!isFuture && property && ( + + {property.images?.[0] && ( + + )} + {property.location.coordinates && ( + + )} + + )} + {/* Main column */}