feat: embedded interactive map in property + match detail views

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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-18 13:34:57 +02:00
parent c75d3c8121
commit 5b917bd619
6 changed files with 163 additions and 16 deletions
+51 -16
View File
@@ -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",
+3
View File
@@ -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",
+60
View File
@@ -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<string, unknown>)._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 (
<Box
sx={{
height,
width: '100%',
overflow: 'hidden',
flexShrink: 0,
// Ensure leaflet controls appear above MUI elements
'& .leaflet-container': { height: '100%', width: '100%', zIndex: 0 },
'& .leaflet-control': { zIndex: 1 },
}}
>
<MapContainer
center={[lat, lng]}
zoom={14}
scrollWheelZoom
style={{ height: '100%', width: '100%' }}
>
<RecenterOnChange lat={lat} lng={lng} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[lat, lng]}>
{label && <Popup>{label}</Popup>}
</Marker>
</MapContainer>
</Box>
)
}
+1
View File
@@ -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'
@@ -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
)}
</Box>
{/* Photo + Map */}
<Box sx={{ flexShrink: 0 }}>
{property.images?.[0] && (
<LocationPreview
imageUrl={property.images[0]}
lat={property.location.coordinates?.lat}
lng={property.location.coordinates?.lng}
address={`${property.address.street} ${property.address.houseNumber}, ${property.address.city}`}
cityLabel={property.location.city}
height={180}
/>
)}
{property.location.coordinates && (
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={240}
/>
)}
</Box>
{/* Tabs */}
<Tabs
value={tab}
+25
View File
@@ -9,6 +9,7 @@ import { useCompareStore } from '../../stores/compareStore'
import { useShortlistStore } from '../../stores/shortlistStore'
import { AddToShortlistDialog } from '../../components/shortlist'
import { MatchReasonList } from '../../components/match-card/MatchReasonList'
import { LocationPreview, PropertyMap } from '../../components/shared'
import {
LocationIntelligencePanel,
MatchDetailHeader,
@@ -142,6 +143,30 @@ export default function MatchDetail() {
onShortlist={handleShortlist}
/>
{/* Photo + Map */}
{!isFuture && property && (
<Box sx={{ mb: 2, borderRadius: 2, overflow: 'hidden', border: '1px solid #e2e8f0' }}>
{property.images?.[0] && (
<LocationPreview
imageUrl={property.images[0]}
lat={property.location.coordinates?.lat}
lng={property.location.coordinates?.lng}
address={`${property.address.street} ${property.address.houseNumber}, ${property.address.city}`}
cityLabel={property.location.city}
height={200}
/>
)}
{property.location.coordinates && (
<PropertyMap
lat={property.location.coordinates.lat}
lng={property.location.coordinates.lng}
label={property.title}
height={280}
/>
)}
</Box>
)}
<Box sx={{ display: 'flex', gap: 3, alignItems: 'flex-start' }}>
{/* Main column */}
<Box sx={{ flex: 1, minWidth: 0 }}>