Files
botino/public/components/zone-map-editor.js
Lucas Tettamanti c93955fa55 Limpiar legacy delivery_* + arreglar carga del mapa en shadow DOM
Backend cleanup (todo el delivery vive ahora en delivery_zones.zones[]):
- Migration drop columns delivery_enabled / delivery_days / delivery_hours_start /
  delivery_hours_end / delivery_min_order y limpieza de schedule.delivery JSONB.
- settingsRepo: SELECT/INSERT/UPDATE sólo con campos vigentes, formatScheduleHours
  trabaja sobre pickup.
- handlers/settings: defaults sin legacy, validateSchedule sólo para pickup,
  validateDeliveryZones nuevo (estructura GeoJSON + días).
- seed_piaf_settings_and_replies + tenant_settings migrations alineadas: schedule
  sólo tiene pickup, delivery_zones queda en {} para reconfigurar via UI.

Frontend cleanup:
- settings-crud: borrado el panel "Delivery (Envío a domicilio)" + minOrder,
  toggle deliveryEnabled, y el grid schedule.delivery. collectScheduleFromInputs
  ahora sólo procesa pickup. save() ya no envía delivery_enabled/min_order.

Fix mapa (no cargaba):
- zone-map-editor: los <link> a leaflet.css/leaflet-geoman.css se inyectaban en
  document.head, que NO cruza el shadow DOM de settings-crud, por lo que las
  reglas de Leaflet no aplicaban al div del mapa. Ahora los <link> se anclan
  como hijos del propio web component; al estar en light DOM dentro del shadow
  root del padre, sí aplican.
- Espera explícita a que el stylesheet cargue antes de instanciar L.map.
- ResizeObserver + invalidateSize() para cuando el contenedor cambia tamaño
  (router muestra/oculta panel, tabs, etc).

Smoke E2E sin regresión: 1kg vacío + envío → location en Centro → "martes 12hs"
→ orden confirmada con $28.000 (producto + envío). 157/157 tests verde.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:37:47 -03:00

394 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* <zone-map-editor> — editor de zonas de delivery sobre un mapa.
*
* Light DOM (sin shadow) para que Leaflet (que asume un DOM normal con CSS
* global) funcione bien dentro de paneles que sí usan shadow DOM (settings-crud).
*
* Carga Leaflet 1.9 + leaflet-geoman desde CDN al montar. Las primeras
* instancias inyectan los <script>/<link>; las siguientes esperan a que
* window.L y window.L.PM estén disponibles.
*
* API pública:
* set zones(arr) // [{id, name, polygon, delivery_cost, delivery_days, ...}]
* get zones() // serializado actual con coordenadas GeoJSON
* set selectedId(id)
* on("change", e => ...) // dispara cuando se crea/edita/borra/renombra
* on("select", e => ...) // dispara cuando se hace click en un polígono
*
* Uso:
* const ed = document.createElement("zone-map-editor");
* ed.zones = [...];
* ed.addEventListener("change", e => console.log(e.detail.zones));
*/
const LEAFLET_VERSION = "1.9.4";
const GEOMAN_VERSION = "2.18.3";
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist/leaflet.css`;
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist/leaflet.js`;
const GEOMAN_CSS = `https://unpkg.com/@geoman-io/leaflet-geoman-free@${GEOMAN_VERSION}/dist/leaflet-geoman.css`;
const GEOMAN_JS = `https://unpkg.com/@geoman-io/leaflet-geoman-free@${GEOMAN_VERSION}/dist/leaflet-geoman.min.js`;
const DEFAULT_CENTER = [-34.6037, -58.3816]; // Obelisco (lat, lng — Leaflet usa [lat,lng])
const DEFAULT_ZOOM = 12;
const ZONE_PALETTE = [
"--chart-blue", "--chart-green", "--chart-purple",
"--chart-orange", "--chart-pink", "--chart-gray",
];
let _scriptsPromise = null;
function ensureLeafletScripts() {
if (window.L && window.L.PM) return Promise.resolve();
if (_scriptsPromise) return _scriptsPromise;
_scriptsPromise = (async () => {
if (!window.L) await loadScript(LEAFLET_JS);
if (!window.L.PM) await loadScript(GEOMAN_JS);
})();
return _scriptsPromise;
}
function loadScript(src) {
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
if (existing.dataset.loaded === "1") { resolve(); return; }
existing.addEventListener("load", () => resolve());
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)));
return;
}
const s = document.createElement("script");
s.src = src;
s.async = true;
s.onload = () => { s.dataset.loaded = "1"; resolve(); };
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
}
function waitForCSS(linkEl, timeoutMs = 4000) {
if (linkEl.sheet) return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(resolve, timeoutMs);
linkEl.addEventListener("load", () => { clearTimeout(timer); resolve(); }, { once: true });
linkEl.addEventListener("error", () => { clearTimeout(timer); resolve(); }, { once: true });
});
}
function cssVar(name, fallback = "#0ea5e9") {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
function slugify(name) {
return String(name || "")
.toLowerCase()
.normalize("NFD").replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40) || `zona-${Date.now().toString(36)}`;
}
class ZoneMapEditor extends HTMLElement {
constructor() {
super();
this._zones = []; // estado interno (con polygon GeoJSON)
this._layersById = new Map(); // id -> leaflet layer
this._selectedId = null;
this._map = null;
this._mapDiv = null;
this._ready = false;
}
connectedCallback() {
this.style.display = "block";
this.style.position = "relative";
this.style.width = "100%";
this.style.height = this.getAttribute("height") || "480px";
// El web component vive en light DOM dentro del shadow root del padre.
// Los <link> en document.head NO cruzan shadow boundaries, así que los
// anclamos como hijos del propio elemento (sí cruzan, porque slot
// composition trae nuestros children al árbol del padre con sus assets).
const linkLeaflet = document.createElement("link");
linkLeaflet.rel = "stylesheet";
linkLeaflet.href = LEAFLET_CSS;
this.appendChild(linkLeaflet);
const linkGeoman = document.createElement("link");
linkGeoman.rel = "stylesheet";
linkGeoman.href = GEOMAN_CSS;
this.appendChild(linkGeoman);
this._mapDiv = document.createElement("div");
this._mapDiv.style.width = "100%";
this._mapDiv.style.height = "100%";
this._mapDiv.style.borderRadius = "var(--r-md, 10px)";
this._mapDiv.style.overflow = "hidden";
this._mapDiv.style.border = "1px solid var(--border, #e2e8f0)";
this._mapDiv.style.background = "#e2e8f0";
this.appendChild(this._mapDiv);
ensureLeafletScripts()
.then(() => waitForCSS(linkLeaflet))
.then(() => this._initMap())
.catch((err) => {
this._mapDiv.innerHTML = `<div style="padding:16px;color:var(--err);font-family:var(--font-sans);">No se pudo cargar el mapa: ${err.message}</div>`;
});
// Si el host estaba oculto al montar (router/visibility), Leaflet calcula
// 0×0 y los tiles no se piden. Observamos el resize para recalcular.
if (typeof ResizeObserver !== "undefined") {
this._ro = new ResizeObserver(() => {
if (this._map) this._map.invalidateSize();
});
this._ro.observe(this);
}
}
disconnectedCallback() {
if (this._ro) { this._ro.disconnect(); this._ro = null; }
if (this._map) {
this._map.remove();
this._map = null;
}
this._layersById.clear();
this._ready = false;
}
// ───────────── API pública ─────────────
get zones() {
// Devolver el estado interno con polígonos sincronizados desde los layers.
return this._zones.map((z) => ({ ...z, polygon: this._serializePolygon(z.id) || z.polygon || null }));
}
set zones(arr) {
const list = Array.isArray(arr) ? arr : [];
this._zones = list.map((z) => ({
id: z.id || slugify(z.name || ""),
name: z.name || "",
polygon: z.polygon || null,
delivery_cost: z.delivery_cost ?? 0,
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : ["lun","mar","mie","jue","vie","sab"],
delivery_hours: z.delivery_hours || { start: "10:00", end: "20:00" },
min_order_amount: z.min_order_amount ?? 0,
enabled: z.enabled !== false,
}));
if (this._ready) this._renderLayers();
}
get selectedId() { return this._selectedId; }
set selectedId(id) {
this._selectedId = id || null;
if (!this._ready) return;
for (const [zid, layer] of this._layersById) {
this._applyLayerStyle(zid, layer, zid === this._selectedId);
}
if (id && this._layersById.has(id)) {
const layer = this._layersById.get(id);
try { this._map.fitBounds(layer.getBounds().pad(0.25)); } catch {}
}
}
upsertZone(zone) {
const idx = this._zones.findIndex((z) => z.id === zone.id);
if (idx >= 0) this._zones[idx] = { ...this._zones[idx], ...zone };
else this._zones.push(zone);
this._renderLayers();
this._emit("change");
}
removeZone(id) {
this._zones = this._zones.filter((z) => z.id !== id);
if (this._layersById.has(id)) {
this._map.removeLayer(this._layersById.get(id));
this._layersById.delete(id);
}
if (this._selectedId === id) this._selectedId = null;
this._emit("change");
}
startDrawing() {
if (!this._ready) return;
this._map.pm.enableDraw("Polygon", {
snappable: true,
templineStyle: { color: cssVar("--chart-blue") },
hintlineStyle: { color: cssVar("--chart-blue"), dashArray: [5, 5] },
pathOptions: this._defaultPathOptions(),
});
}
// ───────────── Internals ─────────────
_initMap() {
const L = window.L;
const center = DEFAULT_CENTER;
this._map = L.map(this._mapDiv, {
center, zoom: DEFAULT_ZOOM, zoomControl: true, attributionControl: true,
});
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(this._map);
// Geoman: solo botón "draw polygon" + edit/delete contextual desde la UI nuestra.
this._map.pm.addControls({
position: "topright",
drawCircle: false, drawCircleMarker: false, drawMarker: false,
drawPolyline: false, drawRectangle: false, drawText: false,
cutPolygon: false, rotateMode: false,
drawPolygon: true, editMode: true, dragMode: false, removalMode: true,
});
this._map.pm.setLang("es");
this._map.pm.setGlobalOptions({
pathOptions: this._defaultPathOptions(),
snappable: true,
});
// Listeners globales de geoman.
this._map.on("pm:create", (e) => this._handleCreate(e));
this._map.on("pm:remove", (e) => this._handleRemove(e));
this._ready = true;
this._renderLayers();
// Forzar un invalidateSize después del primer paint por si el contenedor
// recién obtuvo tamaño (panel oculto inicial / tabs / etc).
requestAnimationFrame(() => this._map && this._map.invalidateSize());
setTimeout(() => this._map && this._map.invalidateSize(), 250);
}
_renderLayers() {
if (!this._ready) return;
const L = window.L;
// Borrar layers que ya no existen.
for (const [id, layer] of this._layersById) {
if (!this._zones.some((z) => z.id === id)) {
this._map.removeLayer(layer);
this._layersById.delete(id);
}
}
// Crear/actualizar.
this._zones.forEach((z, i) => {
if (!z.polygon || !Array.isArray(z.polygon.coordinates)) return;
const latlngs = z.polygon.coordinates[0].map(([lng, lat]) => [lat, lng]);
const existing = this._layersById.get(z.id);
if (existing) {
existing.setLatLngs(latlngs);
this._applyLayerStyle(z.id, existing, z.id === this._selectedId);
return;
}
const color = cssVar(ZONE_PALETTE[i % ZONE_PALETTE.length]);
const layer = L.polygon(latlngs, this._pathOptions(color, z.id === this._selectedId)).addTo(this._map);
layer.bindTooltip(z.name || z.id, { sticky: true });
layer.on("click", () => this._select(z.id));
layer.on("pm:edit", () => this._handleEdit(z.id, layer));
this._layersById.set(z.id, layer);
});
}
_select(id) {
this._selectedId = id;
for (const [zid, layer] of this._layersById) {
this._applyLayerStyle(zid, layer, zid === id);
}
this._emit("select", { id });
}
_handleCreate(e) {
const layer = e.layer;
const id = `zona-${Date.now().toString(36)}`;
const i = this._zones.length;
const color = cssVar(ZONE_PALETTE[i % ZONE_PALETTE.length]);
layer.setStyle(this._pathOptions(color, true));
const polygon = this._toGeoJSONPolygon(layer.getLatLngs());
const zone = {
id,
name: `Zona ${i + 1}`,
polygon,
delivery_cost: 0,
delivery_days: ["lun","mar","mie","jue","vie","sab"],
delivery_hours: { start: "10:00", end: "20:00" },
min_order_amount: 0,
enabled: true,
};
this._zones.push(zone);
this._layersById.set(id, layer);
layer.bindTooltip(zone.name, { sticky: true });
layer.on("click", () => this._select(id));
layer.on("pm:edit", () => this._handleEdit(id, layer));
this._select(id);
this._emit("change");
this._emit("create", { id, zone });
}
_handleEdit(id, layer) {
const z = this._zones.find((x) => x.id === id);
if (!z) return;
z.polygon = this._toGeoJSONPolygon(layer.getLatLngs());
this._emit("change");
}
_handleRemove(e) {
const layer = e.layer;
let removedId = null;
for (const [id, l] of this._layersById) {
if (l === layer) { removedId = id; break; }
}
if (!removedId) return;
this._zones = this._zones.filter((z) => z.id !== removedId);
this._layersById.delete(removedId);
if (this._selectedId === removedId) this._selectedId = null;
this._emit("change");
}
_serializePolygon(id) {
const layer = this._layersById.get(id);
if (!layer) return null;
return this._toGeoJSONPolygon(layer.getLatLngs());
}
_toGeoJSONPolygon(latlngs) {
// Leaflet pasa anidado: [[{lat,lng}...]] para polígonos simples.
const ring = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
const coords = ring.map((p) => [p.lng, p.lat]);
// Cerrar el anillo si no está cerrado (GeoJSON lo requiere).
const first = coords[0];
const last = coords[coords.length - 1];
if (!first || !last || first[0] !== last[0] || first[1] !== last[1]) {
if (first) coords.push([first[0], first[1]]);
}
return { type: "Polygon", coordinates: [coords] };
}
_defaultPathOptions() {
return this._pathOptions(cssVar("--chart-blue"), false);
}
_pathOptions(color, selected) {
return {
color,
weight: selected ? 3 : 2,
opacity: 0.95,
fillColor: color,
fillOpacity: selected ? 0.28 : 0.18,
};
}
_applyLayerStyle(id, layer, selected) {
const i = this._zones.findIndex((z) => z.id === id);
const color = cssVar(ZONE_PALETTE[(i < 0 ? 0 : i) % ZONE_PALETTE.length]);
layer.setStyle(this._pathOptions(color, selected));
}
_emit(name, detail = {}) {
detail = { zones: this.zones, ...detail };
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
}
}
if (!customElements.get("zone-map-editor")) {
customElements.define("zone-map-editor", ZoneMapEditor);
}