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:
363
public/components/zone-map-editor.js
Normal file
363
public/components/zone-map-editor.js
Normal 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: '© <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);
|
||||
}
|
||||
Reference in New Issue
Block a user