diff --git a/db/migrations/20260502181148_drop_legacy_zones_format.sql b/db/migrations/20260502181148_drop_legacy_zones_format.sql
new file mode 100644
index 0000000..5439bd4
--- /dev/null
+++ b/db/migrations/20260502181148_drop_legacy_zones_format.sql
@@ -0,0 +1,13 @@
+-- migrate:up
+-- Limpiar formato legacy de delivery_zones (caba.barrios, flat) y dejar solo
+-- el schema nuevo { zones: [...] }. Pre-prod: no preservamos data legacy.
+UPDATE tenant_settings
+SET delivery_zones = '{}'::jsonb
+WHERE
+ delivery_zones IS NULL
+ OR NOT (delivery_zones ? 'zones')
+ OR jsonb_typeof(delivery_zones->'zones') <> 'array';
+
+-- migrate:down
+-- noop: no preservamos el formato legacy.
+SELECT 1;
diff --git a/public/app.js b/public/app.js
index 0190944..77a3bc5 100644
--- a/public/app.js
+++ b/public/app.js
@@ -12,6 +12,7 @@ import "./components/quantities-crud.js";
import "./components/orders-crud.js";
import "./components/test-panel.js";
import "./components/takeovers-crud.js";
+import "./components/zone-map-editor.js";
import "./components/settings-crud.js";
import { connectSSE } from "./lib/sse.js";
import { initRouter } from "./lib/router.js";
diff --git a/public/components/settings-crud.js b/public/components/settings-crud.js
index b355632..39c542e 100644
--- a/public/components/settings-crud.js
+++ b/public/components/settings-crud.js
@@ -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 = `
@@ -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 `
No hay zonas dibujadas todavía. Tocá Crear zona y dibujá un polígono en el mapa.
`;
+ }
+ 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 `
-
-
-
-
${barrio}
-
-
- ${DAYS.map(d => `
-
${d.short}
- `).join("")}
-
-
- Costo:
-
-
-
+
+
+
+
${this.escapeHtml(z.name || z.id)}
+
${this.escapeHtml(meta || "sin configurar")}
`;
}).join("");
}
- renderZonesSummary() {
- const zones = this.settings?.delivery_zones || {};
- const activeZones = Object.entries(zones).filter(([k, v]) => v?.enabled);
-
- if (activeZones.length === 0) {
- return `
No hay zonas de entrega configuradas. Activá los barrios donde hacés delivery.
`;
+ renderZoneForm() {
+ const z = this.zones.find((x) => x.id === this.selectedZoneId);
+ if (!z) {
+ return `
Seleccioná una zona en la lista o dibujá una nueva en el mapa para configurarla.
`;
}
-
- return `
${activeZones.length} zona${activeZones.length > 1 ? 's' : ''} de entrega activa${activeZones.length > 1 ? 's' : ''}
`;
+ 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 `
+
+ `;
+ }
+
+ renderZonesSummary() {
+ const enabled = this.zones.filter((z) => z.enabled !== false);
+ if (!this.zones.length) {
+ return `
Dibujá al menos una zona en el mapa para que el bot pueda confirmar envíos. Sin zonas, todas las direcciones quedan sin validar.
`;
+ }
+ return `
${enabled.length} de ${this.zones.length} zona${this.zones.length > 1 ? "s" : ""} habilitada${enabled.length === 1 ? "" : "s"}.
`;
}
render() {
@@ -434,18 +453,31 @@ class SettingsCrud extends HTMLElement {
- Zonas de Entrega (Barrios CABA)
+ Zonas de Entrega
-
-
-
+
+ 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.
-
-
- ${this.renderZonesList()}
+
+
+
+
+
+ ${this.renderZonesList()}
+
+
+ ${this.renderZoneForm()}
+
+ ${this.renderZonesSummary()}
+
+
+
+
-
- ${this.renderZonesSummary()}
@@ -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 || "",
diff --git a/public/components/zone-map-editor.js b/public/components/zone-map-editor.js
new file mode 100644
index 0000000..8ef5385
--- /dev/null
+++ b/public/components/zone-map-editor.js
@@ -0,0 +1,363 @@
+/**
+ * — 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