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 || "",