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>
394 lines
14 KiB
JavaScript
394 lines
14 KiB
JavaScript
/**
|
||
* <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: '© <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);
|
||
}
|