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:
@@ -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 || "",
|
||||
|
||||
Reference in New Issue
Block a user