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
+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 }}>