Zonas de delivery por polígono + horarios + location share por WhatsApp

Schema delivery_zones JSONB pasa a { zones: [{ id, name, polygon (GeoJSON),
delivery_cost, delivery_days, delivery_hours, min_order_amount, enabled }] }.
Tirado el modelo legacy de 48 barrios CABA hardcoded.

Backend:
- lib/geo.js: pointInPolygon + findZoneForPoint (ray casting, sin deps) + 9 tests.
- storeContext.checkAddressInZone ahora valida con lat/lng (necesita ubicación
  del cliente; no geocodifica texto). buildZonesForLLM expone zonas resumidas
  para el agente. summarizeDeliveryZones genera prosa con costo+días+horas.
- settingsRepo expone delivery_zones (bug pre-existente: nunca se devolvía).
- pipeline: inboundLocation ⇒ persistir order.pending_location; orderModel
  acepta pending_location, matched_zone, delivery_window.

Intake:
- evolutionParser detecta locationMessage/liveLocationMessage (Baileys).
- evolution + sim handlers propagan inboundLocation al pipeline.

Agent (DeepSeek tool-calling):
- workingMemory inyecta store.delivery.zones[], store.pickup.schedule,
  order.pending_location/matched_zone/delivery_window.
- setAddress: matchea zona con la ubicación pendiente; sin location devuelve
  need_location y el LLM le pide el pin al cliente.
- setShipping: para delivery, indica requires_location si faltan coords.
- confirmOrder: valida día+hora contra zone.delivery_days/hours o pickup.schedule.
- nueva tool set_delivery_window(day, time?) para registrar el slot pedido.
- systemPrompt agrega instrucciones de envío/zonas + flujo location share.

Frontend:
- zone-map-editor: web component (light DOM) que carga Leaflet 1.9 +
  leaflet-geoman lazy desde CDN y permite dibujar/editar polígonos sobre OSM.
  API zones get/set, eventos change/select, paleta tomada de --chart-*.
- settings-crud: borrada lista CABA_BARRIOS, nueva UI con mapa al lado y
  formulario por zona seleccionada (nombre, costo, días, horario start/end,
  mínimo, habilitada). Save serializa al schema nuevo.

Smoke E2E manual:
- "1kg vacío + envío" → bot pide pin → location en Centro → matched_zone
  $1.500, lun-sab 10-20h → "martes 12hs" → confirma orden con total + envío.
- Location en Palermo Test → mar/jue 11-19h respetado.
- Location fuera de zonas → "no llegamos a esa zona" + lista de zonas válidas.
- Domingo en Centro → rechazado con días disponibles.

157/157 tests verde.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Tettamanti
2026-05-02 15:31:25 -03:00
parent 0bf26f8eb5
commit aed79078de
22 changed files with 1288 additions and 327 deletions

View File

@@ -10,19 +10,12 @@ const DAYS = [
{ id: "dom", label: "Domingo", short: "D" },
];
// Lista oficial de 48 barrios de CABA
const CABA_BARRIOS = [
"Agronomía", "Almagro", "Balvanera", "Barracas", "Belgrano", "Boedo",
"Caballito", "Chacarita", "Coghlan", "Colegiales", "Constitución",
"Flores", "Floresta", "La Boca", "La Paternal", "Liniers",
"Mataderos", "Monte Castro", "Montserrat", "Nueva Pompeya", "Núñez",
"Palermo", "Parque Avellaneda", "Parque Chacabuco", "Parque Chas",
"Parque Patricios", "Puerto Madero", "Recoleta", "Retiro", "Saavedra",
"San Cristóbal", "San Nicolás", "San Telmo", "Vélez Sársfield", "Versalles",
"Villa Crespo", "Villa del Parque", "Villa Devoto", "Villa General Mitre",
"Villa Lugano", "Villa Luro", "Villa Ortúzar", "Villa Pueyrredón",
"Villa Real", "Villa Riachuelo", "Villa Santa Rita", "Villa Soldati", "Villa Urquiza"
];
function makeZoneId(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 SettingsCrud extends HTMLElement {
constructor() {
@@ -31,6 +24,9 @@ class SettingsCrud extends HTMLElement {
this.settings = null;
this.loading = false;
this.saving = false;
this.zones = [];
this.selectedZoneId = null;
this._mapEditor = null;
this.shadowRoot.innerHTML = `
<style>
@@ -128,60 +124,40 @@ class SettingsCrud extends HTMLElement {
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid var(--border); }
/* Zonas de entrega */
.zones-search { margin-bottom:12px; }
.zones-search input {
width:100%; padding:10px 14px;
background:var(--panel-2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%236c7a89'%3E%3Cpath d='M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3C/svg%3E") no-repeat 12px center;
background-size:18px; padding-left:38px;
}
.zones-list { max-height:400px; overflow-y:auto; display:flex; flex-direction:column; gap:4px; }
.zone-row {
display:grid;
grid-template-columns:32px 1fr;
gap:12px;
align-items:start;
padding:10px 12px;
background:var(--panel-2);
border-radius:8px;
border:1px solid var(--border);
transition:border-color .2s;
/* Zonas de entrega — editor con mapa */
.zones-layout { display:grid; grid-template-columns:280px 1fr; gap:16px; }
.zones-side { display:flex; flex-direction:column; gap:8px; min-width:0; }
.zones-side-header { display:flex; align-items:center; justify-content:space-between; }
.zones-side-header h4 { margin:0; font-size:13px; color:var(--text); }
.zones-side-header button { padding:6px 10px; font-size:12px; }
.zones-list { display:flex; flex-direction:column; gap:6px; max-height:480px; overflow-y:auto; padding-right:4px; }
.zone-row {
display:flex; align-items:center; gap:10px;
padding:10px 12px; border-radius:var(--r-md, 10px);
background:var(--panel-2); border:1px solid var(--border);
cursor:pointer; transition:border-color .15s, background .15s;
}
.zone-row:hover { border-color:var(--border-hi); }
.zone-row.active { border-color:var(--accent); background:var(--accent-soft); }
.zone-row.hidden { display:none; }
.zone-toggle {
width:32px; height:18px; background:var(--border-hi); border-radius:9px;
cursor:pointer; position:relative; transition:background .2s; margin-top:2px;
}
.zone-toggle.active { background:var(--ok); }
.zone-toggle::after {
content:''; position:absolute; top:2px; left:2px;
width:14px; height:14px; background:#fff; border-radius:50%;
transition:transform .2s;
}
.zone-toggle.active::after { transform:translateX(14px); }
.zone-content { display:flex; flex-direction:column; gap:8px; }
.zone-name { font-size:14px; color:var(--text); font-weight:500; }
.zone-config { display:none; gap:16px; flex-wrap:wrap; align-items:center; }
.zone-row.active .zone-config { display:flex; }
.zone-days { display:flex; gap:4px; }
.zone-day {
width:28px; height:28px; border-radius:6px;
background:var(--border-hi); color:var(--text-muted);
display:flex; align-items:center; justify-content:center;
font-size:11px; font-weight:600; cursor:pointer;
transition:all .15s;
}
.zone-day.active { background:var(--accent); color:#fff; }
.zone-day:hover { background:var(--border-hi); }
.zone-day.active:hover { background:var(--accent-hover); }
.zone-cost { display:flex; align-items:center; gap:6px; }
.zone-cost label { font-size:12px; color:var(--text-muted); }
.zone-cost input { width:90px; padding:6px 10px; font-size:13px; text-align:right; }
.zones-summary {
margin-top:12px; padding:12px; background:var(--panel-2);
border-radius:8px; font-size:13px; color:var(--text-muted);
}
.zone-row.disabled { opacity:.55; }
.zone-swatch { width:14px; height:14px; border-radius:4px; flex-shrink:0; }
.zone-row-main { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
.zone-row-name { font-size:13px; font-weight:500; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.zone-row-meta { font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.zones-empty { padding:24px; text-align:center; color:var(--text-muted); font-size:13px; }
.zone-form { padding:14px; background:var(--panel-2); border:1px solid var(--border); border-radius:var(--r-md, 10px); display:flex; flex-direction:column; gap:12px; }
.zone-form .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.zone-form .row.three { grid-template-columns:2fr 1fr 1fr; }
.zone-form label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; }
.zone-form input { padding:8px 10px; font-size:13px; }
.zone-days-pick { display:flex; gap:4px; flex-wrap:wrap; }
.zone-day-pick { padding:4px 8px; border-radius:6px; font-size:11px; font-weight:600; cursor:pointer;
background:var(--border); color:var(--text-muted); border:1px solid transparent; }
.zone-day-pick.active { background:var(--accent); color:var(--text-on-acc, #fff); }
.zone-row-actions { display:flex; gap:8px; justify-content:flex-end; }
.zone-row-actions button { padding:6px 10px; font-size:12px; }
.zone-row-actions .danger { background:var(--err); }
.zones-summary { margin-top:8px; padding:10px 12px; background:var(--panel-2); border-radius:var(--r-md, 10px); font-size:12px; color:var(--text-muted); }
.zones-summary strong { color:var(--text); }
</style>
@@ -204,14 +180,12 @@ class SettingsCrud extends HTMLElement {
try {
this.settings = await api.getSettings();
// Asegurar que schedule existe
if (!this.settings.schedule) {
this.settings.schedule = { delivery: {}, pickup: {} };
}
// Asegurar que delivery_zones existe
if (!this.settings.delivery_zones) {
this.settings.delivery_zones = {};
}
const dz = this.settings.delivery_zones || {};
this.zones = Array.isArray(dz.zones) ? dz.zones.map((z) => ({ ...z })) : [];
this.selectedZoneId = this.zones[0]?.id || null;
this.loading = false;
this.render();
} catch (e) {
@@ -275,70 +249,115 @@ class SettingsCrud extends HTMLElement {
}).join("");
}
// Convierte nombre de barrio a key (ej: "Villa Crespo" -> "villa_crespo")
barrioToKey(name) {
return name.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // quitar acentos
.replace(/\s+/g, "_");
ZONE_PALETTE_VARS() {
return ["--chart-blue", "--chart-green", "--chart-purple", "--chart-orange", "--chart-pink", "--chart-gray"];
}
getZoneConfig(barrioKey) {
return this.settings?.delivery_zones?.[barrioKey] || null;
zoneSwatchColor(idx) {
const palette = this.ZONE_PALETTE_VARS();
return `var(${palette[idx % palette.length]})`;
}
setZoneConfig(barrioKey, config) {
if (!this.settings.delivery_zones) {
this.settings.delivery_zones = {};
}
if (config === null) {
delete this.settings.delivery_zones[barrioKey];
} else {
this.settings.delivery_zones[barrioKey] = config;
formatDaysShort(days) {
if (!Array.isArray(days) || !days.length) return "\u2014";
const order = ["lun","mar","mie","jue","vie","sab","dom"];
const idx = days.map((d) => order.indexOf(d)).filter((i) => i >= 0).sort((a, b) => a - b);
if (idx.length >= 3 && idx.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1)) {
return `${order[idx[0]]}-${order[idx[idx.length - 1]]}`;
}
return idx.map((i) => order[i]).join("/");
}
renderZonesList() {
return CABA_BARRIOS.map(barrio => {
const key = this.barrioToKey(barrio);
const config = this.getZoneConfig(key);
const isActive = config?.enabled === true;
const days = config?.days || [];
const cost = config?.delivery_cost || 0;
if (!this.zones.length) {
return `<div class="zones-empty">No hay zonas dibujadas todavía. Tocá <strong>Crear zona</strong> y dibujá un polígono en el mapa.</div>`;
}
return this.zones.map((z, i) => {
const cost = z.delivery_cost != null ? `$${Number(z.delivery_cost).toLocaleString("es-AR")}` : "";
const days = this.formatDaysShort(z.delivery_days);
const start = z.delivery_hours?.start?.slice(0, 5) || "";
const end = z.delivery_hours?.end?.slice(0, 5) || "";
const hours = start && end ? `${start}-${end}` : "";
const meta = [cost, days, hours].filter(Boolean).join(" · ");
const active = z.id === this.selectedZoneId ? "active" : "";
const disabled = z.enabled === false ? "disabled" : "";
return `
<div class="zone-row ${isActive ? 'active' : ''}" data-barrio="${key}">
<div class="zone-toggle ${isActive ? 'active' : ''}" data-barrio="${key}"></div>
<div class="zone-content">
<span class="zone-name">${barrio}</span>
<div class="zone-config">
<div class="zone-days">
${DAYS.map(d => `
<div class="zone-day ${days.includes(d.id) ? 'active' : ''}"
data-barrio="${key}" data-day="${d.id}"
title="${d.label}">${d.short}</div>
`).join("")}
</div>
<div class="zone-cost">
<label>Costo:</label>
<input type="number" class="zone-cost-input" data-barrio="${key}"
value="${cost}" min="0" step="100" placeholder="0" />
</div>
</div>
<div class="zone-row ${active} ${disabled}" data-zone-id="${z.id}">
<div class="zone-swatch" style="background:${this.zoneSwatchColor(i)}"></div>
<div class="zone-row-main">
<div class="zone-row-name">${this.escapeHtml(z.name || z.id)}</div>
<div class="zone-row-meta">${this.escapeHtml(meta || "sin configurar")}</div>
</div>
</div>
`;
}).join("");
}
renderZonesSummary() {
const zones = this.settings?.delivery_zones || {};
const activeZones = Object.entries(zones).filter(([k, v]) => v?.enabled);
if (activeZones.length === 0) {
return `<div class="zones-summary">No hay zonas de entrega configuradas. Activá los barrios donde hacés delivery.</div>`;
renderZoneForm() {
const z = this.zones.find((x) => x.id === this.selectedZoneId);
if (!z) {
return `<div class="zones-empty">Seleccioná una zona en la lista o dibujá una nueva en el mapa para configurarla.</div>`;
}
return `<div class="zones-summary"><strong>${activeZones.length}</strong> zona${activeZones.length > 1 ? 's' : ''} de entrega activa${activeZones.length > 1 ? 's' : ''}</div>`;
const days = z.delivery_days || [];
const start = z.delivery_hours?.start?.slice(0, 5) || "10:00";
const end = z.delivery_hours?.end?.slice(0, 5) || "20:00";
return `
<div class="zone-form" data-zone-id="${z.id}">
<div class="row three">
<div class="field">
<label>Nombre</label>
<input type="text" id="zoneName" value="${this.escapeHtml(z.name || "")}" maxlength="60" />
</div>
<div class="field">
<label>Costo de envío ($)</label>
<input type="number" id="zoneCost" value="${z.delivery_cost ?? 0}" min="0" step="100" />
</div>
<div class="field">
<label>Mín. pedido ($)</label>
<input type="number" id="zoneMin" value="${z.min_order_amount ?? 0}" min="0" step="100" />
</div>
</div>
<div class="field">
<label>Días de entrega</label>
<div class="zone-days-pick">
${DAYS.map((d) => `
<span class="zone-day-pick ${days.includes(d.id) ? "active" : ""}" data-day="${d.id}" title="${d.label}">${d.short}</span>
`).join("")}
</div>
</div>
<div class="row">
<div class="field">
<label>Hora de inicio</label>
<input type="time" id="zoneStart" value="${start}" />
</div>
<div class="field">
<label>Hora de fin</label>
<input type="time" id="zoneEnd" value="${end}" />
</div>
</div>
<div class="row">
<div class="field">
<label>Estado</label>
<select id="zoneEnabled">
<option value="true" ${z.enabled !== false ? "selected" : ""}>Habilitada</option>
<option value="false" ${z.enabled === false ? "selected" : ""}>Deshabilitada (no recibe pedidos)</option>
</select>
</div>
<div class="zone-row-actions" style="align-self:end;">
<button class="secondary" id="zoneFitBtn">Centrar en mapa</button>
<button class="danger" id="zoneDeleteBtn">Eliminar zona</button>
</div>
</div>
</div>
`;
}
renderZonesSummary() {
const enabled = this.zones.filter((z) => z.enabled !== false);
if (!this.zones.length) {
return `<div class="zones-summary">Dibujá al menos una zona en el mapa para que el bot pueda confirmar envíos. Sin zonas, todas las direcciones quedan sin validar.</div>`;
}
return `<div class="zones-summary"><strong>${enabled.length}</strong> de <strong>${this.zones.length}</strong> zona${this.zones.length > 1 ? "s" : ""} habilitada${enabled.length === 1 ? "" : "s"}.</div>`;
}
render() {
@@ -434,18 +453,31 @@ class SettingsCrud extends HTMLElement {
<div class="panel">
<div class="panel-title">
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
Zonas de Entrega (Barrios CABA)
Zonas de Entrega
</div>
<div class="zones-search">
<input type="text" id="zoneSearch" placeholder="Buscar barrio..." />
<div class="field-hint" style="margin-bottom:12px;">
Dibujá los polígonos de tus zonas en el mapa. Cada zona tiene su costo de envío, días y rango horario.
El bot valida la dirección del cliente con la ubicación que comparta por WhatsApp.
</div>
<div class="zones-list" id="zonesList">
${this.renderZonesList()}
<div class="zones-layout">
<div class="zones-side">
<div class="zones-side-header">
<h4>Zonas</h4>
<button id="zoneCreateBtn" type="button">+ Crear zona</button>
</div>
<div class="zones-list" id="zonesList">
${this.renderZonesList()}
</div>
<div id="zoneFormSlot">
${this.renderZoneForm()}
</div>
${this.renderZonesSummary()}
</div>
<div>
<zone-map-editor id="zoneMapEditor" height="520px"></zone-map-editor>
</div>
</div>
${this.renderZonesSummary()}
</div>
<div class="actions">
@@ -518,70 +550,124 @@ class SettingsCrud extends HTMLElement {
// Reset button
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
// Zone search
this.shadowRoot.getElementById("zoneSearch")?.addEventListener("input", (e) => {
const query = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
this.shadowRoot.querySelectorAll(".zone-row").forEach(row => {
const barrio = row.dataset.barrio;
const barrioName = CABA_BARRIOS.find(b => this.barrioToKey(b) === barrio) || "";
const normalized = barrioName.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
row.classList.toggle("hidden", query && !normalized.includes(query));
this.setupZoneEditor();
}
setupZoneEditor() {
const editor = this.shadowRoot.getElementById("zoneMapEditor");
if (!editor) return;
this._mapEditor = editor;
editor.zones = this.zones;
if (this.selectedZoneId) editor.selectedId = this.selectedZoneId;
editor.addEventListener("change", (e) => {
const next = e.detail.zones || [];
const merged = [];
for (const z of next) {
const existing = this.zones.find((x) => x.id === z.id);
if (existing) merged.push({ ...existing, polygon: z.polygon });
else merged.push(z);
}
this.zones = merged;
this.refreshZonesPanel();
});
editor.addEventListener("select", (e) => {
this.selectedZoneId = e.detail.id || null;
this.refreshZonesPanel();
});
this.shadowRoot.getElementById("zoneCreateBtn")?.addEventListener("click", () => {
this._mapEditor?.startDrawing();
});
this.attachZoneSideListeners();
}
attachZoneSideListeners() {
this.shadowRoot.querySelectorAll(".zone-row[data-zone-id]").forEach((row) => {
row.addEventListener("click", () => {
const id = row.dataset.zoneId;
this.selectedZoneId = id;
if (this._mapEditor) this._mapEditor.selectedId = id;
this.refreshZonesPanel();
});
});
// Zone toggles
this.shadowRoot.querySelectorAll(".zone-toggle").forEach(toggle => {
toggle.addEventListener("click", () => {
const barrio = toggle.dataset.barrio;
const config = this.getZoneConfig(barrio);
if (config?.enabled) {
// Desactivar zona
this.setZoneConfig(barrio, null);
} else {
// Activar zona con días default (lun-sab)
this.setZoneConfig(barrio, {
enabled: true,
days: ["lun", "mar", "mie", "jue", "vie", "sab"],
delivery_cost: 0
});
}
this.render();
});
const z = this.zones.find((x) => x.id === this.selectedZoneId);
if (!z) return;
const onChange = () => this.refreshZonesList();
const nameEl = this.shadowRoot.getElementById("zoneName");
nameEl?.addEventListener("input", () => { z.name = nameEl.value; onChange(); });
const costEl = this.shadowRoot.getElementById("zoneCost");
costEl?.addEventListener("change", () => { z.delivery_cost = Number(costEl.value) || 0; onChange(); });
const minEl = this.shadowRoot.getElementById("zoneMin");
minEl?.addEventListener("change", () => { z.min_order_amount = Number(minEl.value) || 0; });
const startEl = this.shadowRoot.getElementById("zoneStart");
startEl?.addEventListener("change", () => {
z.delivery_hours = { ...(z.delivery_hours || {}), start: startEl.value };
onChange();
});
const endEl = this.shadowRoot.getElementById("zoneEnd");
endEl?.addEventListener("change", () => {
z.delivery_hours = { ...(z.delivery_hours || {}), end: endEl.value };
onChange();
});
// Zone day toggles
this.shadowRoot.querySelectorAll(".zone-day").forEach(dayBtn => {
dayBtn.addEventListener("click", () => {
const barrio = dayBtn.dataset.barrio;
const day = dayBtn.dataset.day;
const config = this.getZoneConfig(barrio);
if (!config) return;
const days = config.days || [];
const enabledEl = this.shadowRoot.getElementById("zoneEnabled");
enabledEl?.addEventListener("change", () => {
z.enabled = enabledEl.value === "true";
onChange();
});
this.shadowRoot.querySelectorAll(".zone-day-pick").forEach((btn) => {
btn.addEventListener("click", () => {
const day = btn.dataset.day;
const days = z.delivery_days || [];
const idx = days.indexOf(day);
if (idx >= 0) {
days.splice(idx, 1);
} else {
days.push(day);
}
config.days = days;
this.setZoneConfig(barrio, config);
// Update UI without full re-render
dayBtn.classList.toggle("active", days.includes(day));
if (idx >= 0) days.splice(idx, 1);
else days.push(day);
z.delivery_days = days;
btn.classList.toggle("active", days.includes(day));
onChange();
});
});
// Zone cost inputs
this.shadowRoot.querySelectorAll(".zone-cost-input").forEach(input => {
input.addEventListener("change", () => {
const barrio = input.dataset.barrio;
const config = this.getZoneConfig(barrio);
if (!config) return;
config.delivery_cost = parseFloat(input.value) || 0;
this.setZoneConfig(barrio, config);
this.shadowRoot.getElementById("zoneFitBtn")?.addEventListener("click", () => {
if (this._mapEditor) this._mapEditor.selectedId = z.id;
});
this.shadowRoot.getElementById("zoneDeleteBtn")?.addEventListener("click", () => {
if (this._mapEditor) this._mapEditor.removeZone(z.id);
this.zones = this.zones.filter((x) => x.id !== z.id);
if (this.selectedZoneId === z.id) this.selectedZoneId = this.zones[0]?.id || null;
this.refreshZonesPanel();
});
}
refreshZonesPanel() {
const list = this.shadowRoot.getElementById("zonesList");
const formSlot = this.shadowRoot.getElementById("zoneFormSlot");
if (list) list.innerHTML = this.renderZonesList();
if (formSlot) formSlot.innerHTML = this.renderZoneForm();
this.attachZoneSideListeners();
}
refreshZonesList() {
const list = this.shadowRoot.getElementById("zonesList");
if (list) list.innerHTML = this.renderZonesList();
this.shadowRoot.querySelectorAll(".zone-row[data-zone-id]").forEach((row) => {
row.addEventListener("click", () => {
const id = row.dataset.zoneId;
this.selectedZoneId = id;
if (this._mapEditor) this._mapEditor.selectedId = id;
this.refreshZonesPanel();
});
});
}
@@ -610,9 +696,31 @@ class SettingsCrud extends HTMLElement {
async save() {
// Collect schedule from inputs
const schedule = this.collectScheduleFromInputs();
// Collect delivery zones (already in settings from event handlers)
const delivery_zones = this.settings.delivery_zones || {};
// Antes de serializar, refrescar polígonos desde el editor por si el
// usuario editó vértices y no llegó a disparar otro evento change.
if (this._mapEditor) {
const live = this._mapEditor.zones;
this.zones = this.zones.map((z) => {
const fromMap = live.find((x) => x.id === z.id);
return fromMap ? { ...z, polygon: fromMap.polygon } : z;
});
}
const cleanZones = this.zones
.filter((z) => z.polygon && Array.isArray(z.polygon.coordinates))
.map((z) => ({
id: z.id,
name: z.name || z.id,
polygon: z.polygon,
delivery_cost: Number(z.delivery_cost) || 0,
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : [],
delivery_hours: z.delivery_hours || { start: "10:00", end: "20:00" },
min_order_amount: Number(z.min_order_amount) || 0,
enabled: z.enabled !== false,
}));
const delivery_zones = { zones: cleanZones };
const data = {
store_name: this.shadowRoot.getElementById("storeName")?.value || "",

View File

@@ -0,0 +1,363 @@
/**
* <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 _libsPromise = null;
function ensureLeaflet() {
if (window.L && window.L.PM) return Promise.resolve();
if (_libsPromise) return _libsPromise;
_libsPromise = (async () => {
if (!document.querySelector(`link[href="${LEAFLET_CSS}"]`)) {
const l1 = document.createElement("link");
l1.rel = "stylesheet"; l1.href = LEAFLET_CSS;
document.head.appendChild(l1);
}
if (!document.querySelector(`link[href="${GEOMAN_CSS}"]`)) {
const l2 = document.createElement("link");
l2.rel = "stylesheet"; l2.href = GEOMAN_CSS;
document.head.appendChild(l2);
}
if (!window.L) {
await loadScript(LEAFLET_JS);
}
if (!window.L.PM) {
await loadScript(GEOMAN_JS);
}
})();
return _libsPromise;
}
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) {
resolve();
return;
}
const s = document.createElement("script");
s.src = src;
s.async = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
}
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";
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.appendChild(this._mapDiv);
ensureLeaflet().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>`;
});
}
disconnectedCallback() {
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();
}
_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);
}