map: clickable address + status chip + Bewerben/Ablehnen in Leaflet popups

- map_points payload now carries flat id, per-user status, can_apply, is_running
- Popup titles link to the listing; status chip mirrors the list (beworben /
  läuft… / fehlgeschlagen); Bewerben + Ablehnen submit via the same HTMX
  endpoints as the list, re-swapping #wohnungen-body
- csrf token rides on the script[data-csrf] sibling of #flats-map
- popupopen → htmx.process(popupEl) so hx-* on freshly injected DOM binds
- site-style .map-popup-* CSS hooked into Leaflet's popup wrapper

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-21 14:01:11 +02:00
parent 7f7cbb5b1f
commit 931e0bb8b7
4 changed files with 106 additions and 21 deletions

View file

@ -19,28 +19,84 @@ const BERLIN_ZOOM = 11;
function readMapData() {
const script = document.getElementById("flats-map-data");
if (!script) return [];
if (!script) return { csrf: "", flats: [] };
try {
const data = JSON.parse(script.textContent || "[]");
return Array.isArray(data) ? data : [];
const flats = JSON.parse(script.textContent || "[]");
return {
csrf: script.dataset.csrf || "",
flats: Array.isArray(flats) ? flats : [],
};
} catch (e) {
return [];
return { csrf: "", flats: [] };
}
}
function fingerprintOf(data) {
return data
.map((f) => `${f.lat},${f.lng},${f.address || ""},${f.rent || ""}`)
.map((f) =>
[f.id, f.lat, f.lng, (f.status && f.status.label) || "",
f.can_apply ? 1 : 0, f.is_running ? 1 : 0].join(","),
)
.join("|");
}
function renderMarkers(data) {
function escHtml(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function popupHtml(f, csrf) {
const addrText = f.address || f.link || "—";
const addr = escHtml(addrText);
const link = escHtml(f.link || "#");
const metaParts = [];
if (f.rooms) metaParts.push(escHtml(f.rooms) + " Zi");
if (f.size) metaParts.push(Math.round(f.size) + " m²");
if (f.rent) metaParts.push(Math.round(f.rent) + " €");
const meta = metaParts.join(" · ");
let html = `<div class="map-popup">` +
`<a class="map-popup-title" href="${link}" target="_blank" rel="noopener">${addr}</a>`;
if (meta) html += `<div class="map-popup-meta">${meta}</div>`;
if (f.status) {
html += `<div class="map-popup-status">` +
`<span class="chip chip-${escHtml(f.status.chip)}">${escHtml(f.status.label)}</span>` +
`</div>`;
}
html += `<div class="map-popup-actions">`;
if (f.can_apply) {
const confirm = `Bewerbung für ${addrText.replace(/"/g, "'")} starten?`;
html +=
`<form hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML">` +
`<input type="hidden" name="csrf" value="${escHtml(csrf)}">` +
`<input type="hidden" name="flat_id" value="${escHtml(f.id)}">` +
`<button class="btn btn-primary text-xs" type="submit"` +
(f.is_running ? " disabled" : "") +
` hx-confirm="${escHtml(confirm)}">` +
(f.is_running ? "läuft…" : "Bewerben") +
`</button>` +
`</form>`;
}
html +=
`<form hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">` +
`<input type="hidden" name="csrf" value="${escHtml(csrf)}">` +
`<input type="hidden" name="flat_id" value="${escHtml(f.id)}">` +
`<button class="btn btn-ghost text-xs" type="submit"` +
` hx-confirm="Ablehnen und aus der Liste entfernen?">Ablehnen</button>` +
`</form>`;
html += `</div></div>`;
return html;
}
function renderMarkers(payload) {
if (!mapInstance) return;
const fp = fingerprintOf(data);
const { csrf, flats } = payload;
const fp = fingerprintOf(flats);
if (fp === currentFingerprint) return;
currentFingerprint = fp;
const geo = data.filter((f) => typeof f.lat === "number" && typeof f.lng === "number").length;
console.log(`[lazyflat.map] rendering ${geo}/${data.length} markers`);
const geo = flats.filter((f) => typeof f.lat === "number" && typeof f.lng === "number").length;
console.log(`[lazyflat.map] rendering ${geo}/${flats.length} markers`);
if (markerLayer) {
markerLayer.clearLayers();
@ -48,18 +104,11 @@ function renderMarkers(data) {
markerLayer = L.layerGroup().addTo(mapInstance);
}
data.forEach((f) => {
flats.forEach((f) => {
if (typeof f.lat !== "number" || typeof f.lng !== "number") return;
const rent = f.rent ? Math.round(f.rent) + " €" : "";
const rooms = f.rooms ? f.rooms + " Zi" : "";
const size = f.size ? Math.round(f.size) + " m²" : "";
const meta = [rooms, size, rent].filter(Boolean).join(" · ");
const safeAddr = (f.address || "").replace(/</g, "&lt;");
const safeLink = (f.link || "#").replace(/"/g, "&quot;");
L.marker([f.lat, f.lng]).addTo(markerLayer).bindPopup(
`<b>${safeAddr}</b>` + (meta ? `<br>${meta}` : "") +
`<br><a href="${safeLink}" target="_blank" rel="noopener">Zur Anzeige →</a>`,
);
L.marker([f.lat, f.lng])
.addTo(markerLayer)
.bindPopup(popupHtml(f, csrf), { minWidth: 240, closeButton: true });
});
}
@ -80,6 +129,16 @@ function buildMap(el) {
maxZoom: 19,
}).addTo(mapInstance);
markerLayer = L.layerGroup().addTo(mapInstance);
// Leaflet injects popup HTML directly into the DOM — HTMX hasn't scanned it,
// so the hx-* attributes on Bewerben/Ablehnen wouldn't bind. Poke htmx.process
// at the popup element each time one opens.
mapInstance.on("popupopen", (e) => {
const el = e.popup && e.popup.getElement();
if (el && window.htmx) {
window.htmx.process(el);
}
});
}
function ensureMap() {