Files
property-management-network/components/maps/property-map.tsx
Leon SerfatyandClaude Opus 4.8 c9968531e4 Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:34 -04:00

104 lines
3.3 KiB
TypeScript

"use client"
import { useEffect, useRef } from "react"
import type * as Leaflet from "leaflet"
import "leaflet/dist/leaflet.css"
import "./property-map.css"
export type MapMarker = {
id: string
name: string
lat: number
lng: number
subtitle?: string
href?: string
}
// Indigo pin as an inline SVG divIcon — avoids Leaflet's default marker image
// assets (which break under bundlers) and matches the app accent.
const PIN_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="28" fill="#6366f1" stroke="#0b0b12" stroke-width="1.2"><path d="M12 0.5C6.9 0.5 2.8 4.6 2.8 9.7c0 6.6 8.2 13.9 8.6 14.2a0.9 0.9 0 0 0 1.2 0c0.4-0.3 8.6-7.6 8.6-14.2C21.2 4.6 17.1 0.5 12 0.5z"/><circle cx="12" cy="9.7" r="3.3" fill="#0b0b12"/></svg>`
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string
)
}
/**
* Leaflet map (OpenStreetMap tiles) rendering property markers. Client-only:
* Leaflet touches `window`, so it's dynamically imported inside an effect.
*/
export function PropertyMap({
markers,
className = "h-72 w-full",
zoom = 15,
}: {
markers: MapMarker[]
className?: string
zoom?: number
}) {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<Leaflet.Map | null>(null)
useEffect(() => {
let cancelled = false
void (async () => {
const mod = await import("leaflet")
const L = ((mod as unknown as { default?: typeof Leaflet }).default ?? mod) as typeof Leaflet
if (cancelled || !containerRef.current || mapRef.current) return
const map = L.map(containerRef.current, {
scrollWheelZoom: false,
zoomControl: true,
})
mapRef.current = map
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map)
const icon = L.divIcon({
html: PIN_SVG,
className: "pmn-map-pin",
iconSize: [28, 28],
iconAnchor: [14, 28],
popupAnchor: [0, -26],
})
const latlngs: [number, number][] = []
for (const m of markers) {
if (!Number.isFinite(m.lat) || !Number.isFinite(m.lng)) continue
const marker = L.marker([m.lat, m.lng], { icon }).addTo(map)
const link = m.href
? `<div style="margin-top:4px"><a href="${escapeHtml(m.href)}">View details →</a></div>`
: ""
const sub = m.subtitle ? `<div style="color:#9ca3af">${escapeHtml(m.subtitle)}</div>` : ""
marker.bindPopup(`<div style="font-weight:600">${escapeHtml(m.name)}</div>${sub}${link}`)
latlngs.push([m.lat, m.lng])
}
if (latlngs.length === 1) {
map.setView(latlngs[0], zoom)
} else if (latlngs.length > 1) {
map.fitBounds(latlngs, { padding: [40, 40] })
} else {
map.setView([39.8283, -98.5795], 4) // continental US fallback
}
// Tiles can render blank if the container sized after init.
setTimeout(() => map.invalidateSize(), 0)
})()
return () => {
cancelled = true
mapRef.current?.remove()
mapRef.current = null
}
}, [markers, zoom])
return <div ref={containerRef} className={`pmn-map-dark ${className}`} />
}