@@ -490,13 +466,6 @@ class SettingsCrud extends HTMLElement {
}
setupEventListeners() {
- // Toggle delivery
- const deliveryToggle = this.shadowRoot.getElementById("deliveryToggle");
- deliveryToggle?.addEventListener("click", () => {
- this.settings.delivery_enabled = !this.settings.delivery_enabled;
- this.render();
- });
-
// Toggle pickup
const pickupToggle = this.shadowRoot.getElementById("pickupToggle");
pickupToggle?.addEventListener("click", () => {
@@ -673,23 +642,19 @@ class SettingsCrud extends HTMLElement {
}
collectScheduleFromInputs() {
- const schedule = { delivery: {}, pickup: {} };
-
- for (const type of ["delivery", "pickup"]) {
- this.shadowRoot.querySelectorAll(`.hour-start[data-type="${type}"]`).forEach(input => {
- const day = input.dataset.day;
- const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="${type}"][data-day="${day}"]`);
- const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="${type}"][data-day="${day}"]`);
-
- if (toggle?.classList.contains("active")) {
- schedule[type][day] = {
- start: input.value.trim() || (type === "delivery" ? "09:00" : "08:00"),
- end: endInput?.value.trim() || (type === "delivery" ? "18:00" : "20:00"),
- };
- }
- });
- }
-
+ // Sólo pickup: el horario de delivery vive ahora dentro de cada zona.
+ const schedule = { pickup: {} };
+ this.shadowRoot.querySelectorAll(`.hour-start[data-type="pickup"]`).forEach((input) => {
+ const day = input.dataset.day;
+ const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="pickup"][data-day="${day}"]`);
+ const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="pickup"][data-day="${day}"]`);
+ if (toggle?.classList.contains("active")) {
+ schedule.pickup[day] = {
+ start: input.value.trim() || "08:00",
+ end: endInput?.value.trim() || "20:00",
+ };
+ }
+ });
return schedule;
}
@@ -727,9 +692,7 @@ class SettingsCrud extends HTMLElement {
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
store_address: this.shadowRoot.getElementById("storeAddress")?.value || "",
store_phone: this.shadowRoot.getElementById("storePhone")?.value || "",
- delivery_enabled: this.settings.delivery_enabled,
pickup_enabled: this.settings.pickup_enabled,
- delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
schedule,
delivery_zones,
};
diff --git a/public/components/zone-map-editor.js b/public/components/zone-map-editor.js
index 8ef5385..fc8a589 100644
--- a/public/components/zone-map-editor.js
+++ b/public/components/zone-map-editor.js
@@ -37,46 +37,44 @@ const ZONE_PALETTE = [
"--chart-orange", "--chart-pink", "--chart-gray",
];
-let _libsPromise = null;
-function ensureLeaflet() {
+let _scriptsPromise = null;
+function ensureLeafletScripts() {
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);
- }
+ if (_scriptsPromise) return _scriptsPromise;
+ _scriptsPromise = (async () => {
+ if (!window.L) await loadScript(LEAFLET_JS);
+ if (!window.L.PM) await loadScript(GEOMAN_JS);
})();
- return _libsPromise;
+ return _scriptsPromise;
}
function loadScript(src) {
return new Promise((resolve, reject) => {
- if (document.querySelector(`script[src="${src}"]`)) {
- resolve();
+ const existing = document.querySelector(`script[src="${src}"]`);
+ if (existing) {
+ if (existing.dataset.loaded === "1") { resolve(); return; }
+ existing.addEventListener("load", () => resolve());
+ existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)));
return;
}
const s = document.createElement("script");
s.src = src;
s.async = true;
- s.onload = () => resolve();
+ s.onload = () => { s.dataset.loaded = "1"; resolve(); };
s.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(s);
});
}
+function waitForCSS(linkEl, timeoutMs = 4000) {
+ if (linkEl.sheet) return Promise.resolve();
+ return new Promise((resolve) => {
+ const timer = setTimeout(resolve, timeoutMs);
+ linkEl.addEventListener("load", () => { clearTimeout(timer); resolve(); }, { once: true });
+ linkEl.addEventListener("error", () => { clearTimeout(timer); resolve(); }, { once: true });
+ });
+}
+
function cssVar(name, fallback = "#0ea5e9") {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
@@ -107,20 +105,48 @@ class ZoneMapEditor extends HTMLElement {
this.style.position = "relative";
this.style.width = "100%";
this.style.height = this.getAttribute("height") || "480px";
+
+ // El web component vive en light DOM dentro del shadow root del padre.
+ // Los
en document.head NO cruzan shadow boundaries, así que los
+ // anclamos como hijos del propio elemento (sí cruzan, porque slot
+ // composition trae nuestros children al árbol del padre con sus assets).
+ const linkLeaflet = document.createElement("link");
+ linkLeaflet.rel = "stylesheet";
+ linkLeaflet.href = LEAFLET_CSS;
+ this.appendChild(linkLeaflet);
+ const linkGeoman = document.createElement("link");
+ linkGeoman.rel = "stylesheet";
+ linkGeoman.href = GEOMAN_CSS;
+ this.appendChild(linkGeoman);
+
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._mapDiv.style.background = "#e2e8f0";
this.appendChild(this._mapDiv);
- ensureLeaflet().then(() => this._initMap()).catch((err) => {
- this._mapDiv.innerHTML = `
No se pudo cargar el mapa: ${err.message}
`;
- });
+ ensureLeafletScripts()
+ .then(() => waitForCSS(linkLeaflet))
+ .then(() => this._initMap())
+ .catch((err) => {
+ this._mapDiv.innerHTML = `
No se pudo cargar el mapa: ${err.message}
`;
+ });
+
+ // Si el host estaba oculto al montar (router/visibility), Leaflet calcula
+ // 0×0 y los tiles no se piden. Observamos el resize para recalcular.
+ if (typeof ResizeObserver !== "undefined") {
+ this._ro = new ResizeObserver(() => {
+ if (this._map) this._map.invalidateSize();
+ });
+ this._ro.observe(this);
+ }
}
disconnectedCallback() {
+ if (this._ro) { this._ro.disconnect(); this._ro = null; }
if (this._map) {
this._map.remove();
this._map = null;
@@ -226,6 +252,10 @@ class ZoneMapEditor extends HTMLElement {
this._ready = true;
this._renderLayers();
+ // Forzar un invalidateSize después del primer paint por si el contenedor
+ // recién obtuvo tamaño (panel oculto inicial / tabs / etc).
+ requestAnimationFrame(() => this._map && this._map.invalidateSize());
+ setTimeout(() => this._map && this._map.invalidateSize(), 250);
}
_renderLayers() {
diff --git a/src/modules/0-ui/db/settingsRepo.js b/src/modules/0-ui/db/settingsRepo.js
index caa151a..f8d3a18 100644
--- a/src/modules/0-ui/db/settingsRepo.js
+++ b/src/modules/0-ui/db/settingsRepo.js
@@ -3,19 +3,20 @@ import { pool } from "../../shared/db/pool.js";
// ─────────────────────────────────────────────────────────────
// Tenant Settings - CRUD
// ─────────────────────────────────────────────────────────────
+//
+// Modelo actual:
+// - Datos del comercio: store_name, bot_name, store_address, store_phone.
+// - Pickup (retiro en tienda): pickup_enabled + pickup_days CSV +
+// pickup_hours_start/end TIME, y `schedule.pickup` JSONB para horario por día.
+// - Delivery: TODO vive en `delivery_zones.zones[]` (polígonos GeoJSON con
+// costo, días y rango horario por zona). El bot valida zona usando la
+// ubicación que el cliente comparte por WhatsApp.
-/**
- * Obtiene la configuración del tenant
- */
export async function getSettings({ tenantId }) {
const sql = `
- SELECT
+ SELECT
id, tenant_id,
store_name, bot_name, store_address, store_phone,
- delivery_enabled, delivery_days,
- delivery_hours_start::text as delivery_hours_start,
- delivery_hours_end::text as delivery_hours_end,
- delivery_min_order,
pickup_enabled, pickup_days,
pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
@@ -30,20 +31,12 @@ export async function getSettings({ tenantId }) {
return rows[0] || null;
}
-/**
- * Crea o actualiza la configuración del tenant (upsert)
- */
export async function upsertSettings({ tenantId, settings }) {
const {
store_name,
bot_name,
store_address,
store_phone,
- delivery_enabled,
- delivery_days,
- delivery_hours_start,
- delivery_hours_end,
- delivery_min_order,
pickup_enabled,
pickup_days,
pickup_hours_start,
@@ -55,21 +48,15 @@ export async function upsertSettings({ tenantId, settings }) {
const sql = `
INSERT INTO tenant_settings (
tenant_id, store_name, bot_name, store_address, store_phone,
- delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order,
pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end,
schedule, delivery_zones
)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (tenant_id) DO UPDATE SET
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
store_address = COALESCE(EXCLUDED.store_address, tenant_settings.store_address),
store_phone = COALESCE(EXCLUDED.store_phone, tenant_settings.store_phone),
- delivery_enabled = COALESCE(EXCLUDED.delivery_enabled, tenant_settings.delivery_enabled),
- delivery_days = COALESCE(EXCLUDED.delivery_days, tenant_settings.delivery_days),
- delivery_hours_start = COALESCE(EXCLUDED.delivery_hours_start, tenant_settings.delivery_hours_start),
- delivery_hours_end = COALESCE(EXCLUDED.delivery_hours_end, tenant_settings.delivery_hours_end),
- delivery_min_order = COALESCE(EXCLUDED.delivery_min_order, tenant_settings.delivery_min_order),
pickup_enabled = COALESCE(EXCLUDED.pickup_enabled, tenant_settings.pickup_enabled),
pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days),
pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start),
@@ -77,13 +64,9 @@ export async function upsertSettings({ tenantId, settings }) {
schedule = COALESCE(EXCLUDED.schedule, tenant_settings.schedule),
delivery_zones = COALESCE(EXCLUDED.delivery_zones, tenant_settings.delivery_zones),
updated_at = NOW()
- RETURNING
+ RETURNING
id, tenant_id,
store_name, bot_name, store_address, store_phone,
- delivery_enabled, delivery_days,
- delivery_hours_start::text as delivery_hours_start,
- delivery_hours_end::text as delivery_hours_end,
- delivery_min_order,
pickup_enabled, pickup_days,
pickup_hours_start::text as pickup_hours_start,
pickup_hours_end::text as pickup_hours_end,
@@ -98,11 +81,6 @@ export async function upsertSettings({ tenantId, settings }) {
bot_name || null,
store_address || null,
store_phone || null,
- delivery_enabled ?? null,
- delivery_days || null,
- delivery_hours_start || null,
- delivery_hours_end || null,
- delivery_min_order ?? null,
pickup_enabled ?? null,
pickup_days || null,
pickup_hours_start || null,
@@ -110,150 +88,103 @@ export async function upsertSettings({ tenantId, settings }) {
schedule ? JSON.stringify(schedule) : null,
delivery_zones ? JSON.stringify(delivery_zones) : null,
];
-
- const { rows } = await pool.query(sql, params);
+ const { rows } = await pool.query(sql, params);
return rows[0];
}
/**
- * Formatea horarios desde schedule JSONB para mostrar de forma natural
- * Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs, Sáb de 9 a 13hs"
+ * Formatea schedule.pickup ({ lun: { start, end } }) en prosa para mostrar al
+ * cliente. Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs".
*/
-function formatScheduleHours(scheduleType, enabled) {
- if (!enabled || !scheduleType || typeof scheduleType !== "object") {
+function formatScheduleHours(scheduleObj, enabled) {
+ if (!enabled || !scheduleObj || typeof scheduleObj !== "object") {
return enabled === false ? "No disponible" : "";
}
const dayOrder = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
const dayNames = {
- lun: "Lunes", mar: "Martes", mie: "Miércoles",
- jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo"
+ lun: "Lunes", mar: "Martes", mie: "Miércoles",
+ jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo",
};
- // Agrupar días por horario
const groups = {};
for (const day of dayOrder) {
- const slot = scheduleType[day];
+ const slot = scheduleObj[day];
if (!slot || !slot.start || !slot.end) continue;
-
const key = `${slot.start}-${slot.end}`;
- if (!groups[key]) {
- groups[key] = { start: slot.start, end: slot.end, days: [] };
- }
+ if (!groups[key]) groups[key] = { start: slot.start, end: slot.end, days: [] };
groups[key].days.push(day);
}
- if (Object.keys(groups).length === 0) return "";
+ if (!Object.keys(groups).length) return "";
- // Formatear cada grupo
- const parts = Object.values(groups).map(g => {
+ const parts = Object.values(groups).map((g) => {
const days = g.days;
let dayStr;
-
- // Detectar rangos consecutivos
if (days.length >= 3) {
- const indices = days.map(d => dayOrder.indexOf(d));
- const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i-1] + 1);
- if (isConsecutive) {
- dayStr = `${dayNames[days[0]]} a ${dayNames[days[days.length-1]]}`;
- } else {
- dayStr = days.map(d => dayNames[d]).join(", ");
- }
+ const indices = days.map((d) => dayOrder.indexOf(d));
+ const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1);
+ dayStr = isConsecutive
+ ? `${dayNames[days[0]]} a ${dayNames[days[days.length - 1]]}`
+ : days.map((d) => dayNames[d]).join(", ");
} else if (days.length === 2) {
dayStr = `${dayNames[days[0]]} y ${dayNames[days[1]]}`;
} else {
dayStr = dayNames[days[0]];
}
-
- const startH = g.start.slice(0, 5);
- const endH = g.end.slice(0, 5);
- return `${dayStr} de ${startH} a ${endH}`;
+ return `${dayStr} de ${g.start.slice(0, 5)} a ${g.end.slice(0, 5)}`;
});
return parts.join(", ");
}
+function formatLegacyPickupHours(enabled, days, start, end) {
+ if (!enabled) return "No disponible";
+ if (!days || !start || !end) return "";
+ const daysFormatted = days.split(",").map((d) => d.trim()).join(", ");
+ return `${daysFormatted} de ${start.slice(0, 5)} a ${end.slice(0, 5)}`;
+}
+
/**
- * Obtiene la configuración formateada para usar en prompts (storeConfig)
+ * Forma de la storeConfig que consume el agente y su workingMemory.
*/
export async function getStoreConfig({ tenantId }) {
const settings = await getSettings({ tenantId });
-
+
if (!settings) {
- // Valores por defecto si no hay configuración
return {
name: "la carnicería",
botName: "Piaf",
hours: "",
address: "",
phone: "",
- deliveryHours: "",
pickupHours: "",
schedule: null,
+ delivery_zones: {},
};
}
const schedule = settings.schedule || {};
- // Usar nuevo formato schedule si existe, sino legacy
- let deliveryHours, pickupHours;
-
- if (schedule.delivery && Object.keys(schedule.delivery).length > 0) {
- deliveryHours = formatScheduleHours(schedule.delivery, settings.delivery_enabled);
- } else {
- // Legacy format
- deliveryHours = formatLegacyHours(
- settings.delivery_enabled,
- settings.delivery_days,
- settings.delivery_hours_start,
- settings.delivery_hours_end
- );
- }
-
- if (schedule.pickup && Object.keys(schedule.pickup).length > 0) {
- pickupHours = formatScheduleHours(schedule.pickup, settings.pickup_enabled);
- } else {
- // Legacy format
- pickupHours = formatLegacyHours(
- settings.pickup_enabled,
- settings.pickup_days,
- settings.pickup_hours_start,
- settings.pickup_hours_end
- );
- }
-
- // Combinar horarios para store_hours (usa pickup como horario de tienda)
- let storeHours = "";
- if (settings.pickup_enabled) {
- storeHours = pickupHours;
- }
+ const pickupHours = schedule.pickup && Object.keys(schedule.pickup).length
+ ? formatScheduleHours(schedule.pickup, settings.pickup_enabled)
+ : formatLegacyPickupHours(
+ settings.pickup_enabled,
+ settings.pickup_days,
+ settings.pickup_hours_start,
+ settings.pickup_hours_end,
+ );
return {
name: settings.store_name || "la carnicería",
botName: settings.bot_name || "Piaf",
- hours: storeHours,
+ hours: settings.pickup_enabled ? pickupHours : "",
address: settings.store_address || "",
phone: settings.store_phone || "",
- deliveryHours,
pickupHours,
- deliveryEnabled: settings.delivery_enabled,
pickupEnabled: settings.pickup_enabled,
schedule,
delivery_zones: settings.delivery_zones || {},
};
}
-
-/**
- * Formatear horarios en formato legacy (días + rango único)
- */
-function formatLegacyHours(enabled, days, start, end) {
- if (!enabled) return "No disponible";
- if (!days || !start || !end) return "";
-
- const daysFormatted = days.split(",").map(d => d.trim()).join(", ");
- const startFormatted = start?.slice(0, 5) || "";
- const endFormatted = end?.slice(0, 5) || "";
-
- return `${daysFormatted} de ${startFormatted} a ${endFormatted}`;
-}
diff --git a/src/modules/0-ui/handlers/settings.js b/src/modules/0-ui/handlers/settings.js
index 86da45b..982f3ff 100644
--- a/src/modules/0-ui/handlers/settings.js
+++ b/src/modules/0-ui/handlers/settings.js
@@ -1,63 +1,42 @@
import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.js";
-// Días de la semana para validación
const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
-/**
- * Genera schedule por defecto con horarios uniformes
- */
-function createDefaultSchedule() {
- const defaultDays = ["lun", "mar", "mie", "jue", "vie", "sab"];
- const delivery = {};
+function defaultPickupSchedule() {
+ const days = ["lun", "mar", "mie", "jue", "vie", "sab"];
const pickup = {};
-
- for (const day of defaultDays) {
- delivery[day] = { start: "09:00", end: "18:00" };
- pickup[day] = { start: "08:00", end: "20:00" };
- }
-
- return { delivery, pickup };
+ for (const d of days) pickup[d] = { start: "08:00", end: "20:00" };
+ return { pickup };
}
-/**
- * Obtiene la configuración actual del tenant
- */
export async function handleGetSettings({ tenantId }) {
const settings = await getSettings({ tenantId });
-
- // Si no hay configuración, devolver defaults
+
if (!settings) {
return {
store_name: "Mi Negocio",
bot_name: "Piaf",
store_address: "",
store_phone: "",
- delivery_enabled: true,
- delivery_days: "lun,mar,mie,jue,vie,sab",
- delivery_hours_start: "09:00",
- delivery_hours_end: "18:00",
- delivery_min_order: 0,
pickup_enabled: true,
pickup_days: "lun,mar,mie,jue,vie,sab",
pickup_hours_start: "08:00",
pickup_hours_end: "20:00",
- schedule: createDefaultSchedule(),
+ schedule: defaultPickupSchedule(),
delivery_zones: {},
is_default: true,
};
}
- // Si no tiene schedule, generar desde datos legacy
+ // Si schedule está vacío pero tenemos los campos legacy de pickup, generar
+ // schedule.pickup para que la UI pueda editar el grid por día.
let schedule = settings.schedule;
- if (!schedule || Object.keys(schedule).length === 0) {
- schedule = buildScheduleFromLegacy(settings);
+ if (!schedule || !schedule.pickup || !Object.keys(schedule.pickup).length) {
+ schedule = buildPickupScheduleFromLegacy(settings);
}
return {
...settings,
- // Formatear horarios TIME a HH:MM
- delivery_hours_start: settings.delivery_hours_start?.slice(0, 5) || "09:00",
- delivery_hours_end: settings.delivery_hours_end?.slice(0, 5) || "18:00",
pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00",
pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00",
schedule,
@@ -66,171 +45,95 @@ export async function handleGetSettings({ tenantId }) {
};
}
-/**
- * Construye schedule desde datos legacy
- */
-function buildScheduleFromLegacy(settings) {
- const schedule = { delivery: {}, pickup: {} };
-
- // Delivery
- if (settings.delivery_enabled && settings.delivery_days) {
- const days = settings.delivery_days.split(",").map(d => d.trim());
- const start = settings.delivery_hours_start?.slice(0, 5) || "09:00";
- const end = settings.delivery_hours_end?.slice(0, 5) || "18:00";
- for (const day of days) {
- if (VALID_DAYS.includes(day)) {
- schedule.delivery[day] = { start, end };
- }
- }
- }
-
- // Pickup
- if (settings.pickup_enabled && settings.pickup_days) {
- const days = settings.pickup_days.split(",").map(d => d.trim());
+function buildPickupScheduleFromLegacy(settings) {
+ const out = { pickup: {} };
+ if (settings?.pickup_enabled && settings?.pickup_days) {
+ const days = settings.pickup_days.split(",").map((d) => d.trim());
const start = settings.pickup_hours_start?.slice(0, 5) || "08:00";
const end = settings.pickup_hours_end?.slice(0, 5) || "20:00";
for (const day of days) {
- if (VALID_DAYS.includes(day)) {
- schedule.pickup[day] = { start, end };
- }
+ if (VALID_DAYS.includes(day)) out.pickup[day] = { start, end };
}
}
-
- return schedule;
+ return out;
}
-/**
- * Valida la estructura del schedule
- */
function validateSchedule(schedule) {
if (!schedule || typeof schedule !== "object") return;
-
- // Acepta HH:MM o HH:MM:SS (la BD puede devolver con segundos)
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/;
-
- for (const type of ["delivery", "pickup"]) {
- const typeSchedule = schedule[type];
- if (!typeSchedule || typeof typeSchedule !== "object") continue;
-
- for (const [day, slot] of Object.entries(typeSchedule)) {
- if (!VALID_DAYS.includes(day)) {
- throw new Error(`Invalid day in schedule.${type}: ${day}`);
- }
-
- if (slot === null) continue; // null = no disponible
-
- if (typeof slot !== "object" || !slot.start || !slot.end) {
- throw new Error(`Invalid slot format for ${type}.${day}`);
- }
-
- if (!timeRegex.test(slot.start)) {
- throw new Error(`Invalid start time for ${type}.${day}: ${slot.start}`);
- }
- if (!timeRegex.test(slot.end)) {
- throw new Error(`Invalid end time for ${type}.${day}: ${slot.end}`);
+ const pickup = schedule.pickup;
+ if (!pickup || typeof pickup !== "object") return;
+ for (const [day, slot] of Object.entries(pickup)) {
+ if (!VALID_DAYS.includes(day)) throw new Error(`Invalid day in schedule.pickup: ${day}`);
+ if (slot === null) continue;
+ if (typeof slot !== "object" || !slot.start || !slot.end) {
+ throw new Error(`Invalid slot format for pickup.${day}`);
+ }
+ if (!timeRegex.test(slot.start)) throw new Error(`Invalid start time for pickup.${day}: ${slot.start}`);
+ if (!timeRegex.test(slot.end)) throw new Error(`Invalid end time for pickup.${day}: ${slot.end}`);
+ }
+}
+
+function syncPickupLegacyFromSchedule(settings) {
+ const pickup = settings?.schedule?.pickup;
+ if (!pickup) return;
+ const days = Object.keys(pickup).filter((d) => pickup[d] && pickup[d].start && pickup[d].end);
+ days.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
+ if (days.length) {
+ settings.pickup_days = days.join(",");
+ const first = pickup[days[0]];
+ settings.pickup_hours_start = first.start;
+ settings.pickup_hours_end = first.end;
+ } else {
+ settings.pickup_days = "";
+ }
+}
+
+function validateDeliveryZones(dz) {
+ if (!dz || typeof dz !== "object") return;
+ if (dz.zones && !Array.isArray(dz.zones)) {
+ throw new Error("delivery_zones.zones must be an array");
+ }
+ for (const z of dz.zones || []) {
+ if (!z?.id || !z?.name) throw new Error("Each zone needs id + name");
+ if (z.polygon && (z.polygon.type !== "Polygon" || !Array.isArray(z.polygon.coordinates))) {
+ throw new Error(`Invalid polygon GeoJSON for zone ${z.id}`);
+ }
+ if (Array.isArray(z.delivery_days)) {
+ for (const d of z.delivery_days) {
+ if (!VALID_DAYS.includes(d)) throw new Error(`Invalid delivery day in zone ${z.id}: ${d}`);
}
}
}
}
-/**
- * Sincroniza campos legacy desde schedule
- */
-function syncLegacyFromSchedule(settings) {
- const schedule = settings.schedule;
- if (!schedule) return;
-
- // Sincronizar delivery
- if (schedule.delivery) {
- const deliveryDays = Object.keys(schedule.delivery).filter(d => schedule.delivery[d] !== null);
- if (deliveryDays.length > 0) {
- // Ordenar días
- deliveryDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
- settings.delivery_days = deliveryDays.join(",");
-
- // Usar primer horario como legacy
- const firstSlot = schedule.delivery[deliveryDays[0]];
- if (firstSlot) {
- settings.delivery_hours_start = firstSlot.start;
- settings.delivery_hours_end = firstSlot.end;
- }
- } else {
- settings.delivery_days = "";
- }
- }
-
- // Sincronizar pickup
- if (schedule.pickup) {
- const pickupDays = Object.keys(schedule.pickup).filter(d => schedule.pickup[d] !== null);
- if (pickupDays.length > 0) {
- pickupDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
- settings.pickup_days = pickupDays.join(",");
-
- const firstSlot = schedule.pickup[pickupDays[0]];
- if (firstSlot) {
- settings.pickup_hours_start = firstSlot.start;
- settings.pickup_hours_end = firstSlot.end;
- }
- } else {
- settings.pickup_days = "";
- }
- }
-}
-
-/**
- * Guarda la configuración del tenant
- */
export async function handleSaveSettings({ tenantId, settings }) {
- // Validaciones básicas
- if (!settings.store_name?.trim()) {
- throw new Error("store_name is required");
- }
- if (!settings.bot_name?.trim()) {
- throw new Error("bot_name is required");
- }
+ if (!settings.store_name?.trim()) throw new Error("store_name is required");
+ if (!settings.bot_name?.trim()) throw new Error("bot_name is required");
- // Validar schedule si viene
if (settings.schedule) {
validateSchedule(settings.schedule);
- // Sincronizar campos legacy desde schedule
- syncLegacyFromSchedule(settings);
- } else {
- // Legacy: validar días individuales
- if (settings.delivery_days) {
- const days = settings.delivery_days.split(",").map(d => d.trim().toLowerCase());
- for (const day of days) {
- if (!VALID_DAYS.includes(day)) {
- throw new Error(`Invalid delivery day: ${day}`);
- }
- }
- settings.delivery_days = days.join(",");
- }
+ syncPickupLegacyFromSchedule(settings);
+ }
- if (settings.pickup_days) {
- const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase());
- for (const day of days) {
- if (!VALID_DAYS.includes(day)) {
- throw new Error(`Invalid pickup day: ${day}`);
- }
- }
- settings.pickup_days = days.join(",");
- }
+ if (settings.delivery_zones) {
+ validateDeliveryZones(settings.delivery_zones);
+ }
- // Validar horarios legacy
- const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
-
- if (settings.delivery_hours_start && !timeRegex.test(settings.delivery_hours_start)) {
- throw new Error("Invalid delivery_hours_start format (use HH:MM)");
- }
- if (settings.delivery_hours_end && !timeRegex.test(settings.delivery_hours_end)) {
- throw new Error("Invalid delivery_hours_end format (use HH:MM)");
- }
- if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) {
- throw new Error("Invalid pickup_hours_start format (use HH:MM)");
- }
- if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
- throw new Error("Invalid pickup_hours_end format (use HH:MM)");
+ if (settings.pickup_days) {
+ const days = settings.pickup_days.split(",").map((d) => d.trim().toLowerCase());
+ for (const d of days) {
+ if (!VALID_DAYS.includes(d)) throw new Error(`Invalid pickup day: ${d}`);
}
+ settings.pickup_days = days.join(",");
+ }
+
+ const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
+ if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) {
+ throw new Error("Invalid pickup_hours_start format (use HH:MM)");
+ }
+ if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
+ throw new Error("Invalid pickup_hours_end format (use HH:MM)");
}
const result = await upsertSettings({ tenantId, settings });
@@ -239,8 +142,6 @@ export async function handleSaveSettings({ tenantId, settings }) {
ok: true,
settings: {
...result,
- delivery_hours_start: result.delivery_hours_start?.slice(0, 5),
- delivery_hours_end: result.delivery_hours_end?.slice(0, 5),
pickup_hours_start: result.pickup_hours_start?.slice(0, 5),
pickup_hours_end: result.pickup_hours_end?.slice(0, 5),
delivery_zones: result.delivery_zones || {},
@@ -249,9 +150,6 @@ export async function handleSaveSettings({ tenantId, settings }) {
};
}
-/**
- * Obtiene el storeConfig formateado para prompts
- */
export async function handleGetStoreConfig({ tenantId }) {
return await getStoreConfig({ tenantId });
}