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:
Generated
+51
-16
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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='© <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,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}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user