diff --git a/.cursor/debug.log b/.cursor/debug.log deleted file mode 100644 index c226620..0000000 --- a/.cursor/debug.log +++ /dev/null @@ -1,7 +0,0 @@ -{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"add_to_cart","orderPendingCount":0,"orderCartCount":1,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399756809,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"add_to_cart","orderPendingCount":1,"orderCartCount":1,"pendingItemsFromPatch":1,"pendingQueries":["chota"]},"timestamp":1769399763739,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"cartHelpers.js:320","message":"takeover - order before/after pending removal","data":{"pendingItemId":"pending_1769399763727_6f1m2r","pendingItemQuery":"chota","originalPendingCount":1,"newPendingCount":0,"originalPendingIds":["pending_1769399763727_6f1m2r"],"newPendingIds":[]},"timestamp":1769399769529,"sessionId":"debug-session","hypothesisId":"C"} -{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"human_takeover","orderPendingCount":0,"orderCartCount":1,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399769547,"sessionId":"debug-session","hypothesisId":"A"} -{"location":"takeovers.handler.js:173","message":"respond - state read from DB","data":{"state":"CART","orderPendingCount":0,"orderCartCount":1,"pendingQueries":[]},"timestamp":1769399811578,"sessionId":"debug-session","hypothesisId":"B"} -{"location":"takeovers.handler.js:195","message":"respond - order being saved (with cart items)","data":{"orderPendingCount":0,"orderCartCount":2,"pendingQueries":[]},"timestamp":1769399811579,"sessionId":"debug-session","hypothesisId":"B-save"} -{"location":"pipeline.js:398","message":"upsertConversationState - context being saved","data":{"nextState":"CART","intent":"view_cart","orderPendingCount":0,"orderCartCount":2,"pendingItemsFromPatch":0,"pendingQueries":[]},"timestamp":1769399826724,"sessionId":"debug-session","hypothesisId":"A"} diff --git a/db/migrations/20260126100000_schedule_jsonb.sql b/db/migrations/20260126100000_schedule_jsonb.sql new file mode 100644 index 0000000..fb63e63 --- /dev/null +++ b/db/migrations/20260126100000_schedule_jsonb.sql @@ -0,0 +1,44 @@ +-- migrate:up + +-- Agregar columna schedule JSONB para horarios flexibles por día +-- Estructura: { "delivery": { "lun": { "start": "09:00", "end": "14:00" }, ... }, "pickup": { ... } } +ALTER TABLE tenant_settings +ADD COLUMN IF NOT EXISTS schedule JSONB DEFAULT '{}'; + +-- Migrar datos existentes al nuevo formato +UPDATE tenant_settings +SET schedule = jsonb_build_object( + 'delivery', CASE + WHEN delivery_enabled AND delivery_days IS NOT NULL THEN ( + SELECT jsonb_object_agg( + day, + jsonb_build_object( + 'start', COALESCE(delivery_hours_start::text, '09:00'), + 'end', COALESCE(delivery_hours_end::text, '18:00') + ) + ) + FROM unnest(string_to_array(delivery_days, ',')) AS day + WHERE day IS NOT NULL AND day != '' + ) + ELSE '{}'::jsonb + END, + 'pickup', CASE + WHEN pickup_enabled AND pickup_days IS NOT NULL THEN ( + SELECT jsonb_object_agg( + day, + jsonb_build_object( + 'start', COALESCE(pickup_hours_start::text, '08:00'), + 'end', COALESCE(pickup_hours_end::text, '20:00') + ) + ) + FROM unnest(string_to_array(pickup_days, ',')) AS day + WHERE day IS NOT NULL AND day != '' + ) + ELSE '{}'::jsonb + END +) +WHERE schedule = '{}' OR schedule IS NULL; + +-- migrate:down + +ALTER TABLE tenant_settings DROP COLUMN IF EXISTS schedule; diff --git a/public/components/orders-crud.js b/public/components/orders-crud.js index 99e1aad..18f34db 100644 --- a/public/components/orders-crud.js +++ b/public/components/orders-crud.js @@ -247,11 +247,50 @@ class OrdersCrud extends HTMLElement { } }); + // Escuchar nuevos pedidos para actualizar automáticamente + // Usa retry con backoff porque WooCommerce puede tardar en devolver el pedido recién creado + this._unsubOrderCreated = on("order:created", ({ order_id }) => { + console.log("[orders-crud] order:created received, order_id:", order_id); + this.refreshWithRetry(order_id); + }); + this.loadOrders(); } disconnectedCallback() { this._unsubRouter?.(); + this._unsubOrderCreated?.(); + } + + /** + * Refresca la lista de pedidos con retry si el pedido esperado no aparece + * WooCommerce puede tardar en devolver un pedido recién creado + */ + async refreshWithRetry(expectedOrderId, attempt = 1) { + const maxAttempts = 3; + const delays = [500, 2000, 4000]; // ms entre intentos + + // Esperar antes de refrescar + if (attempt > 1) { + await new Promise(r => setTimeout(r, delays[attempt - 1] || 2000)); + } else { + // Primer intento: pequeño delay para dar tiempo a WooCommerce + await new Promise(r => setTimeout(r, 500)); + } + + await this.loadOrders(); + + // Verificar si el pedido esperado apareció + if (expectedOrderId) { + const found = this.orders.find(o => o.id === expectedOrderId); + if (!found && attempt < maxAttempts) { + console.log(`[orders-crud] order ${expectedOrderId} not found, retry ${attempt + 1}/${maxAttempts}...`); + return this.refreshWithRetry(expectedOrderId, attempt + 1); + } + if (found) { + console.log(`[orders-crud] order ${expectedOrderId} found on attempt ${attempt}`); + } + } } async loadOrders() { diff --git a/public/components/settings-crud.js b/public/components/settings-crud.js index 00cd54f..f1a023e 100644 --- a/public/components/settings-crud.js +++ b/public/components/settings-crud.js @@ -1,13 +1,13 @@ import { api } from "../lib/api.js"; const DAYS = [ - { id: "lun", label: "Lun" }, - { id: "mar", label: "Mar" }, - { id: "mie", label: "Mié" }, - { id: "jue", label: "Jue" }, - { id: "vie", label: "Vie" }, - { id: "sab", label: "Sáb" }, - { id: "dom", label: "Dom" }, + { id: "lun", label: "Lunes", short: "Lun" }, + { id: "mar", label: "Martes", short: "Mar" }, + { id: "mie", label: "Miércoles", short: "Mié" }, + { id: "jue", label: "Jueves", short: "Jue" }, + { id: "vie", label: "Viernes", short: "Vie" }, + { id: "sab", label: "Sábado", short: "Sáb" }, + { id: "dom", label: "Domingo", short: "Dom" }, ]; class SettingsCrud extends HTMLElement { @@ -50,11 +50,11 @@ class SettingsCrud extends HTMLElement { button.secondary { background:#253245; } button.secondary:hover { background:#2d3e52; } - .toggle-row { display:flex; align-items:center; gap:12px; margin-bottom:12px; } + .toggle-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; } .toggle { position:relative; width:48px; height:26px; background:#253245; border-radius:13px; cursor:pointer; - transition:background .2s; + transition:background .2s; flex-shrink:0; } .toggle.active { background:#1f6feb; } .toggle::after { @@ -65,20 +65,38 @@ class SettingsCrud extends HTMLElement { .toggle.active::after { transform:translateX(22px); } .toggle-label { font-size:14px; color:#e7eef7; } - .days-selector { display:flex; gap:6px; flex-wrap:wrap; } - .day-btn { - padding:8px 12px; border-radius:6px; font-size:12px; font-weight:600; - background:#253245; color:#8aa0b5; border:none; cursor:pointer; - transition:all .15s; + /* Schedule grid */ + .schedule-grid { display:flex; flex-direction:column; gap:8px; } + .schedule-row { + display:grid; + grid-template-columns:90px 32px 1fr; + gap:12px; + align-items:center; + padding:8px 12px; + background:#0f1520; + border-radius:8px; + border:1px solid #1e2a3a; } - .day-btn:hover { background:#2d3e52; color:#e7eef7; } - .day-btn.selected { background:#1f6feb; color:#fff; } - - .hours-row { display:flex; align-items:center; gap:12px; margin-top:12px; } - .hours-row input { width:90px; text-align:center; font-family:monospace; font-size:15px; letter-spacing:1px; } - .hours-row input::placeholder { color:#6c7a89; } - .hours-row span { color:#8aa0b5; } - .hours-row .hour-hint { font-size:11px; color:#6c7a89; margin-left:8px; } + .schedule-row.disabled { opacity:0.4; } + .day-label { font-size:13px; color:#e7eef7; font-weight:500; } + .day-toggle { + width:32px; height:18px; background:#253245; border-radius:9px; + cursor:pointer; position:relative; transition:background .2s; + } + .day-toggle.active { background:#2ecc71; } + .day-toggle::after { + content:''; position:absolute; top:2px; left:2px; + width:14px; height:14px; background:#fff; border-radius:50%; + transition:transform .2s; + } + .day-toggle.active::after { transform:translateX(14px); } + .hours-inputs { display:flex; align-items:center; gap:8px; } + .hours-inputs input { + width:70px; text-align:center; font-family:monospace; + font-size:13px; padding:6px 8px; letter-spacing:1px; + } + .hours-inputs span { color:#6c7a89; font-size:12px; } + .hours-inputs.disabled input { opacity:0.4; pointer-events:none; } .actions { display:flex; gap:12px; margin-top:24px; } .loading { text-align:center; padding:60px; color:#8aa0b5; } @@ -93,6 +111,8 @@ class SettingsCrud extends HTMLElement { color:#e74c3c; padding:12px 16px; border-radius:8px; margin-bottom:16px; font-size:14px; } + + .min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid #1e2a3a; }
@@ -114,6 +134,10 @@ class SettingsCrud extends HTMLElement { try { this.settings = await api.getSettings(); + // Asegurar que schedule existe + if (!this.settings.schedule) { + this.settings.schedule = { delivery: {}, pickup: {} }; + } this.loading = false; this.render(); } catch (e) { @@ -123,6 +147,60 @@ class SettingsCrud extends HTMLElement { } } + getScheduleSlot(type, dayId) { + return this.settings?.schedule?.[type]?.[dayId] || null; + } + + setScheduleSlot(type, dayId, slot) { + if (!this.settings.schedule) { + this.settings.schedule = { delivery: {}, pickup: {} }; + } + if (!this.settings.schedule[type]) { + this.settings.schedule[type] = {}; + } + this.settings.schedule[type][dayId] = slot; + } + + renderScheduleGrid(type, enabled) { + const defaultStart = type === "delivery" ? "09:00" : "08:00"; + const defaultEnd = type === "delivery" ? "18:00" : "20:00"; + + return DAYS.map(day => { + const slot = this.getScheduleSlot(type, day.id); + const isActive = slot !== null && slot !== undefined; + const start = slot?.start || defaultStart; + const end = slot?.end || defaultEnd; + + return ` +
+ ${day.label} +
+
+ + a + +
+
+ `; + }).join(""); + } + render() { const content = this.shadowRoot.getElementById("content"); @@ -137,8 +215,6 @@ class SettingsCrud extends HTMLElement { } const s = this.settings; - const deliveryDays = (s.delivery_days || "").split(",").filter(d => d); - const pickupDays = (s.pickup_days || "").split(",").filter(d => d); content.innerHTML = ` @@ -185,26 +261,13 @@ class SettingsCrud extends HTMLElement { Delivery habilitado
-
+
+ ${this.renderScheduleGrid("delivery", s.delivery_enabled)} +
+ +
- -
- ${DAYS.map(d => ` - - `).join("")} -
-
- -
- Horario: - - a - - (formato 24hs) -
- -
- +
@@ -222,23 +285,8 @@ class SettingsCrud extends HTMLElement { Retiro en tienda habilitado
-
-
- -
- ${DAYS.map(d => ` - - `).join("")} -
-
- -
- Horario: - - a - - (formato 24hs) -
+
+ ${this.renderScheduleGrid("pickup", s.pickup_enabled)}
@@ -266,36 +314,43 @@ class SettingsCrud extends HTMLElement { this.render(); }); - // Delivery days - this.shadowRoot.querySelectorAll("#deliveryDays .day-btn").forEach(btn => { - btn.addEventListener("click", () => { - const day = btn.dataset.day; - let days = (this.settings.delivery_days || "").split(",").filter(d => d); - if (days.includes(day)) { - days = days.filter(d => d !== day); + // Day toggles + this.shadowRoot.querySelectorAll(".day-toggle").forEach(toggle => { + toggle.addEventListener("click", () => { + const type = toggle.dataset.type; + const day = toggle.dataset.day; + const currentSlot = this.getScheduleSlot(type, day); + + if (currentSlot) { + // Desactivar día + this.setScheduleSlot(type, day, null); } else { - days.push(day); + // Activar día con horarios default + const defaultStart = type === "delivery" ? "09:00" : "08:00"; + const defaultEnd = type === "delivery" ? "18:00" : "20:00"; + this.setScheduleSlot(type, day, { start: defaultStart, end: defaultEnd }); } - // Ordenar días - days.sort((a, b) => DAYS.findIndex(d => d.id === a) - DAYS.findIndex(d => d.id === b)); - this.settings.delivery_days = days.join(","); this.render(); }); }); - // Pickup days - this.shadowRoot.querySelectorAll("#pickupDays .day-btn").forEach(btn => { - btn.addEventListener("click", () => { - const day = btn.dataset.day; - let days = (this.settings.pickup_days || "").split(",").filter(d => d); - if (days.includes(day)) { - days = days.filter(d => d !== day); + // Hour inputs - update on blur + this.shadowRoot.querySelectorAll(".hour-start, .hour-end").forEach(input => { + input.addEventListener("blur", () => { + const type = input.dataset.type; + const day = input.dataset.day; + const isStart = input.classList.contains("hour-start"); + + const slot = this.getScheduleSlot(type, day); + if (!slot) return; + + const value = input.value.trim(); + if (isStart) { + slot.start = value || (type === "delivery" ? "09:00" : "08:00"); } else { - days.push(day); + slot.end = value || (type === "delivery" ? "18:00" : "20:00"); } - days.sort((a, b) => DAYS.findIndex(d => d.id === a) - DAYS.findIndex(d => d.id === b)); - this.settings.pickup_days = days.join(","); - this.render(); + this.setScheduleSlot(type, day, slot); }); }); @@ -306,25 +361,43 @@ class SettingsCrud extends HTMLElement { this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load()); } + 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"), + }; + } + }); + } + + return schedule; + } + async save() { - // Collect form data BEFORE re-rendering + // Collect schedule from inputs + const schedule = this.collectScheduleFromInputs(); + const data = { store_name: this.shadowRoot.getElementById("storeName")?.value || "", 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, - delivery_days: this.settings.delivery_days, - delivery_hours_start: this.shadowRoot.getElementById("deliveryStart")?.value || "09:00", - delivery_hours_end: this.shadowRoot.getElementById("deliveryEnd")?.value || "18:00", - delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0, pickup_enabled: this.settings.pickup_enabled, - pickup_days: this.settings.pickup_days, - pickup_hours_start: this.shadowRoot.getElementById("pickupStart")?.value || "08:00", - pickup_hours_end: this.shadowRoot.getElementById("pickupEnd")?.value || "20:00", + delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0, + schedule, }; - // Update settings with form values so they persist through render + // Update settings with form values this.settings = { ...this.settings, ...data }; this.saving = true; diff --git a/public/lib/sse.js b/public/lib/sse.js index bf18858..f1e5309 100644 --- a/public/lib/sse.js +++ b/public/lib/sse.js @@ -7,6 +7,7 @@ export function connectSSE() { es.addEventListener("conversation.upsert", (e) => emit("conversation:upsert", JSON.parse(e.data))); es.addEventListener("run.created", (e) => emit("run:created", JSON.parse(e.data))); es.addEventListener("takeover.created", (e) => emit("takeover:created", JSON.parse(e.data))); + es.addEventListener("order.created", (e) => emit("order:created", JSON.parse(e.data))); es.onerror = () => emit("sse:status", { ok: false }); diff --git a/src/modules/0-ui/db/settingsRepo.js b/src/modules/0-ui/db/settingsRepo.js index d6f6b6d..c75e938 100644 --- a/src/modules/0-ui/db/settingsRepo.js +++ b/src/modules/0-ui/db/settingsRepo.js @@ -19,6 +19,7 @@ export async function getSettings({ tenantId }) { pickup_enabled, pickup_days, pickup_hours_start::text as pickup_hours_start, pickup_hours_end::text as pickup_hours_end, + schedule, created_at, updated_at FROM tenant_settings WHERE tenant_id = $1 @@ -46,15 +47,17 @@ export async function upsertSettings({ tenantId, settings }) { pickup_days, pickup_hours_start, pickup_hours_end, + schedule, } = 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 + pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end, + schedule ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) 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), @@ -69,6 +72,7 @@ export async function upsertSettings({ tenantId, settings }) { pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days), pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start), pickup_hours_end = COALESCE(EXCLUDED.pickup_hours_end, tenant_settings.pickup_hours_end), + schedule = COALESCE(EXCLUDED.schedule, tenant_settings.schedule), updated_at = NOW() RETURNING id, tenant_id, @@ -80,6 +84,7 @@ export async function upsertSettings({ tenantId, settings }) { pickup_enabled, pickup_days, pickup_hours_start::text as pickup_hours_start, pickup_hours_end::text as pickup_hours_end, + schedule, created_at, updated_at `; @@ -98,6 +103,7 @@ export async function upsertSettings({ tenantId, settings }) { pickup_days || null, pickup_hours_start || null, pickup_hours_end || null, + schedule ? JSON.stringify(schedule) : null, ]; const { rows } = await pool.query(sql, params); @@ -105,6 +111,64 @@ export async function upsertSettings({ tenantId, settings }) { 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" + */ +function formatScheduleHours(scheduleType, enabled) { + if (!enabled || !scheduleType || typeof scheduleType !== "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" + }; + + // Agrupar días por horario + const groups = {}; + for (const day of dayOrder) { + const slot = scheduleType[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: [] }; + } + groups[key].days.push(day); + } + + if (Object.keys(groups).length === 0) return ""; + + // Formatear cada grupo + 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(", "); + } + } 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 parts.join(", "); +} + /** * Obtiene la configuración formateada para usar en prompts (storeConfig) */ @@ -121,39 +185,43 @@ export async function getStoreConfig({ tenantId }) { phone: "", deliveryHours: "", pickupHours: "", + schedule: null, }; } - // Formatear horarios para mostrar - const formatHours = (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}`; - }; + const schedule = settings.schedule || {}; - const deliveryHours = formatHours( - settings.delivery_enabled, - settings.delivery_days, - settings.delivery_hours_start, - settings.delivery_hours_end - ); + // 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 + ); + } - const pickupHours = formatHours( - settings.pickup_enabled, - settings.pickup_days, - settings.pickup_hours_start, - settings.pickup_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 + // Combinar horarios para store_hours (usa pickup como horario de tienda) let storeHours = ""; - if (settings.pickup_enabled && settings.pickup_days) { - storeHours = `${settings.pickup_days.split(",").join(", ")} ${settings.pickup_hours_start?.slice(0,5) || ""}-${settings.pickup_hours_end?.slice(0,5) || ""}`; + if (settings.pickup_enabled) { + storeHours = pickupHours; } return { @@ -166,5 +234,24 @@ export async function getStoreConfig({ tenantId }) { pickupHours, deliveryEnabled: settings.delivery_enabled, pickupEnabled: settings.pickup_enabled, + schedule, + // Campos legacy para compatibilidad + delivery_days: settings.delivery_days, + delivery_hours_start: settings.delivery_hours_start, + delivery_hours_end: settings.delivery_hours_end, }; } + +/** + * 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 c937392..c31e295 100644 --- a/src/modules/0-ui/handlers/settings.js +++ b/src/modules/0-ui/handlers/settings.js @@ -3,6 +3,22 @@ import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo. // 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 = {}; + 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 }; +} + /** * Obtiene la configuración actual del tenant */ @@ -25,10 +41,17 @@ export async function handleGetSettings({ tenantId }) { pickup_days: "lun,mar,mie,jue,vie,sab", pickup_hours_start: "08:00", pickup_hours_end: "20:00", + schedule: createDefaultSchedule(), is_default: true, }; } + // Si no tiene schedule, generar desde datos legacy + let schedule = settings.schedule; + if (!schedule || Object.keys(schedule).length === 0) { + schedule = buildScheduleFromLegacy(settings); + } + return { ...settings, // Formatear horarios TIME a HH:MM @@ -36,10 +59,121 @@ export async function handleGetSettings({ tenantId }) { 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, is_default: false, }; } +/** + * 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()); + 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 }; + } + } + } + + return schedule; +} + +/** + * Valida la estructura del schedule + */ +function validateSchedule(schedule) { + if (!schedule || typeof schedule !== "object") return; + + const timeRegex = /^([01]?[0-9]|2[0-3]):[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}`); + } + } + } +} + +/** + * 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 */ @@ -52,41 +186,48 @@ export async function handleSaveSettings({ tenantId, settings }) { throw new Error("bot_name is required"); } - // Validar días - 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}`); + // 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(","); } - settings.delivery_days = days.join(","); - } - 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}`); + 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(","); } - settings.pickup_days = days.join(","); - } - // Validar horarios - 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)"); + // 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)"); + } } const result = await upsertSettings({ tenantId, settings }); diff --git a/src/modules/2-identity/services/pipeline.js b/src/modules/2-identity/services/pipeline.js index f95f5d4..50cd5c3 100644 --- a/src/modules/2-identity/services/pipeline.js +++ b/src/modules/2-identity/services/pipeline.js @@ -258,16 +258,27 @@ const runStatus = llmMeta?.error ? "warn" : "ok"; ...baseAddress, phone: baseAddress.phone || phoneFromWa, }; + // Obtener shipping_method y payment_method del contexto (preferir decision que es el resultado del turn) + const shippingMethod = decision?.context_patch?.shipping_method || reducedContext?.shipping_method || null; + const paymentMethod = decision?.context_patch?.payment_method || reducedContext?.payment_method || null; const order = await createOrder({ tenantId, wooCustomerId: externalCustomerId, basket: basketToUse, address: addressWithPhone, + shippingMethod, + paymentMethod, run_id: null, }); actionPatch.woo_order_id = order?.id || null; actionPatch.order_total = calcOrderTotal(order); newTools.push({ type: "create_order", ok: true, order_id: order?.id || null }); + // Notificar via SSE para actualizar la pantalla de pedidos + sseSend("order.created", { + order_id: order?.id, + chat_id, + shipping_method: shippingMethod, + }); } else if (act.type === "update_order") { const baseAddrUpd = reducedContext?.delivery_address || reducedContext?.address || {}; const phoneFromWaUpd = from?.replace(/@.*$/, "")?.replace(/[^0-9]/g, "") || ""; diff --git a/src/modules/3-turn-engine/stateHandlers/cart.js b/src/modules/3-turn-engine/stateHandlers/cart.js index 79f099d..d35d643 100644 --- a/src/modules/3-turn-engine/stateHandlers/cart.js +++ b/src/modules/3-turn-engine/stateHandlers/cart.js @@ -333,13 +333,103 @@ async function handleRecommendIntent({ tenantId, text, nlu, currentOrder, audit return null; } +/** + * Detecta si el usuario pregunta por el total del carrito actual + */ +function isCartTotalQuery(nlu) { + const query = nlu?.entities?.product_query || ""; + const q = query.trim().toLowerCase(); + + // Patrones que indican consulta sobre el carrito actual + const cartKeywords = [ + "todo", "el total", "total", "mi pedido", "el pedido", "precio total", + "lo que tengo", "lo que llevo", "lo que estoy pidiendo", "lo que pedí", + "en el carrito", "del carrito", "mi carrito", "el carrito", + "lo que voy", "hasta ahora", "hasta el momento", + ]; + + // Si la query contiene alguna de estas frases, es consulta del carrito + if (cartKeywords.some(kw => q.includes(kw))) { + return true; + } + + // Patrones regex adicionales + const patterns = [ + /^cu[aá]nto (es|sale|cuesta|est[aá])/i, + /^precio/i, + ]; + return patterns.some(p => p.test(q)); +} + /** * Maneja price_query */ async function handlePriceQuery({ tenantId, nlu, currentOrder, audit }) { + // Si pregunta por el total del carrito + if (isCartTotalQuery(nlu) || !nlu?.entities?.product_query) { + const cartItems = currentOrder?.cart || []; + if (cartItems.length === 0) { + return { + plan: { + reply: "Tu carrito está vacío. ¿Qué te gustaría agregar?", + next_state: ConversationState.CART, + intent: "price_query", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + // Calcular y mostrar el total + let total = 0; + const lines = cartItems.map(item => { + const itemTotal = (item.price || 0) * (item.qty || 0); + total += itemTotal; + const unitStr = item.unit === "unit" ? "" : " kg"; + return `• ${item.name}: ${item.qty}${unitStr} x $${item.price} = $${itemTotal.toLocaleString("es-AR")}`; + }); + + const reply = `Tu pedido actual:\n\n${lines.join("\n")}\n\n💰 Total: $${total.toLocaleString("es-AR")}\n\n¿Algo más?`; + return { + plan: { + reply, + next_state: ConversationState.CART, + intent: "price_query", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + const productQueries = extractProductQueries(nlu); if (productQueries.length === 0) { + // Si no hay query pero hay carrito, mostrar el carrito + const cartItems = currentOrder?.cart || []; + if (cartItems.length > 0) { + let total = 0; + const lines = cartItems.map(item => { + const itemTotal = (item.price || 0) * (item.qty || 0); + total += itemTotal; + const unitStr = item.unit === "unit" ? "" : " kg"; + return `• ${item.name}: ${item.qty}${unitStr} x $${item.price} = $${itemTotal.toLocaleString("es-AR")}`; + }); + + const reply = `Tu pedido actual:\n\n${lines.join("\n")}\n\n💰 Total: $${total.toLocaleString("es-AR")}\n\n¿Querés saber el precio de algún producto específico?`; + return { + plan: { + reply, + next_state: ConversationState.CART, + intent: "price_query", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + return { plan: { reply: "¿De qué producto querés saber el precio?", diff --git a/src/modules/3-turn-engine/stateHandlers/cartHelpers.js b/src/modules/3-turn-engine/stateHandlers/cartHelpers.js index 73acc53..96e4aef 100644 --- a/src/modules/3-turn-engine/stateHandlers/cartHelpers.js +++ b/src/modules/3-turn-engine/stateHandlers/cartHelpers.js @@ -354,7 +354,7 @@ function processStrongMatch({ updatedOrder, pendingItem, best, audit }) { const displayUnit = normalizeUnit(best.sell_unit) || inferDefaultUnit({ name: best.name, categories: best.categories }); const hasQty = pendingItem.requested_qty != null && pendingItem.requested_qty > 0; const needsQuantity = displayUnit !== "unit" && !hasQty; - + const autoSelectedOrder = updatePendingItem(updatedOrder, pendingItem.id, { selected_woo_id: best.woo_product_id, selected_name: best.name, diff --git a/src/modules/3-turn-engine/stateHandlers/waiting.js b/src/modules/3-turn-engine/stateHandlers/waiting.js index 5768772..c7346a5 100644 --- a/src/modules/3-turn-engine/stateHandlers/waiting.js +++ b/src/modules/3-turn-engine/stateHandlers/waiting.js @@ -25,25 +25,17 @@ function isDeliveryInfoQuestion(text) { } /** - * Formatea los días de entrega para mostrar + * Detecta si el usuario pregunta por horarios de retiro */ -function formatDeliveryDays(daysStr) { - if (!daysStr) return null; - - const dayMap = { - "lun": "Lunes", "mar": "Martes", "mie": "Miércoles", "mié": "Miércoles", - "jue": "Jueves", "vie": "Viernes", "sab": "Sábado", "sáb": "Sábado", "dom": "Domingo", - }; - - const days = daysStr.split(",").map(d => d.trim().toLowerCase()); - const formatted = days.map(d => dayMap[d] || d).filter(Boolean); - - if (formatted.length === 0) return null; - if (formatted.length === 1) return formatted[0]; - - // "Lunes, Martes, Miércoles y Jueves" - const last = formatted.pop(); - return `${formatted.join(", ")} y ${last}`; +function isPickupInfoQuestion(text) { + const t = String(text || "").toLowerCase(); + const patterns = [ + /horario.*(retir|buscar|pasar)/i, + /cu[aá]ndo.*(retir|buscar|pasar)/i, + /a\s+qu[eé]\s+hora.*(retir|buscar)/i, + /d[ií]as?.*(retir|buscar)/i, + ]; + return patterns.some(p => p.test(t)); } /** @@ -71,19 +63,17 @@ export async function handleWaitingState({ tenantId, text, nlu, order, audit, st // Preguntas sobre horarios/días de entrega if (isDeliveryInfoQuestion(text)) { - const deliveryDays = formatDeliveryDays(storeConfig.delivery_days); - const startHour = storeConfig.delivery_hours_start?.slice(0, 5); - const endHour = storeConfig.delivery_hours_end?.slice(0, 5); + // Usar deliveryHours que ya viene formateado desde getStoreConfig + // (agrupa días con mismos horarios: "Lunes a Viernes de 9:00 a 14:00, Sábado de 9:00 a 13:00") + const deliveryHours = storeConfig.deliveryHours; let reply = ""; - if (deliveryDays) { - reply = `Hacemos entregas los días ${deliveryDays}`; - if (startHour && endHour) { - reply += ` de ${startHour} a ${endHour}`; - } - reply += ". "; + if (deliveryHours && deliveryHours !== "No disponible") { + reply = `Hacemos entregas: ${deliveryHours}. `; + } else if (storeConfig.deliveryEnabled === false) { + reply = "Por el momento no ofrecemos delivery. "; } else { - reply = "Todavía no tengo configurado los días de entrega. "; + reply = "Todavía no tengo configurados los horarios de entrega. "; } reply += "Tu pedido ya está en proceso, avisame cualquier cosa."; @@ -100,6 +90,33 @@ export async function handleWaitingState({ tenantId, text, nlu, order, audit, st }; } + // Preguntas sobre horarios de retiro + if (isPickupInfoQuestion(text)) { + const pickupHours = storeConfig.pickupHours; + + let reply = ""; + if (pickupHours && pickupHours !== "No disponible") { + reply = `Podés retirar: ${pickupHours}. `; + } else if (storeConfig.pickupEnabled === false) { + reply = "Por el momento no ofrecemos retiro en tienda. "; + } else { + reply = "Todavía no tengo configurados los horarios de retiro. "; + } + + reply += "Tu pedido ya está en proceso, avisame cualquier cosa."; + + return { + plan: { + reply, + next_state: ConversationState.WAITING_WEBHOOKS, + intent: "pickup_info", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + // Default const reply = currentOrder.payment_type === "link" ? "Tu pedido está en proceso. Avisame si necesitás algo más o esperá la confirmación de pago." diff --git a/src/modules/4-woo-orders/wooOrders.js b/src/modules/4-woo-orders/wooOrders.js index 598330e..fca78f3 100644 --- a/src/modules/4-woo-orders/wooOrders.js +++ b/src/modules/4-woo-orders/wooOrders.js @@ -188,7 +188,7 @@ function mapAddress(address) { }; } -export async function createOrder({ tenantId, wooCustomerId, basket, address, run_id }) { +export async function createOrder({ tenantId, wooCustomerId, basket, address, shippingMethod, paymentMethod, run_id }) { const lockKey = `${tenantId}:${wooCustomerId || "anon"}`; return withLock(lockKey, async () => { const client = await getWooClient({ tenantId }); @@ -200,9 +200,15 @@ export async function createOrder({ tenantId, wooCustomerId, basket, address, ru customer_id: wooCustomerId || undefined, line_items: lineItems, ...(addr ? { billing: addr, shipping: addr } : {}), + // Si es cash, usar payment_method "cod" (Cash On Delivery) de WooCommerce + ...(paymentMethod === "cash" ? { payment_method: "cod", payment_method_title: "Efectivo" } : {}), meta_data: [ { key: "source", value: "whatsapp" }, ...(run_id ? [{ key: "run_id", value: run_id }] : []), + // Guardar shipping_method como metadata para poder leerlo después + ...(shippingMethod ? [{ key: "shipping_method", value: shippingMethod }] : []), + // Guardar payment_method como metadata también + ...(paymentMethod ? [{ key: "payment_method_wa", value: paymentMethod }] : []), ], }; const url = `${client.base}/orders`; @@ -313,17 +319,34 @@ export async function listRecentOrders({ tenantId, limit = 20 }) { const source = sourceMeta?.value || "web"; // Método de envío (shipping) + // 1. Primero intentar leer de metadata (para pedidos de WhatsApp) + const metaShippingMethod = order.meta_data?.find(m => m.key === "shipping_method")?.value || null; + // 2. Fallback a shipping_lines de WooCommerce (para pedidos web) const shippingLines = order.shipping_lines || []; - const shippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null; - const isDelivery = shippingMethod ? - !shippingMethod.toLowerCase().includes("retiro") && - !shippingMethod.toLowerCase().includes("pickup") && - !shippingMethod.toLowerCase().includes("local") : false; + const wooShippingMethod = shippingLines[0]?.method_title || shippingLines[0]?.method_id || null; + // 3. Usar metadata si existe, sino WooCommerce + const shippingMethod = metaShippingMethod || wooShippingMethod; + // 4. Determinar isDelivery + let isDelivery = false; + if (metaShippingMethod) { + // Si viene de metadata, "delivery" = true, "pickup" = false + isDelivery = metaShippingMethod === "delivery"; + } else if (wooShippingMethod) { + // Si viene de WooCommerce, detectar por nombre + isDelivery = !wooShippingMethod.toLowerCase().includes("retiro") && + !wooShippingMethod.toLowerCase().includes("pickup") && + !wooShippingMethod.toLowerCase().includes("local"); + } // Método de pago + // 1. Primero intentar leer de metadata (para pedidos de WhatsApp) + const metaPaymentMethod = order.meta_data?.find(m => m.key === "payment_method_wa")?.value || null; + // 2. Luego de los campos estándar de WooCommerce const paymentMethod = order.payment_method || null; const paymentMethodTitle = order.payment_method_title || null; - const isCash = paymentMethod === "cod" || + // 3. Determinar si es cash + const isCash = metaPaymentMethod === "cash" || + paymentMethod === "cod" || paymentMethodTitle?.toLowerCase().includes("efectivo") || paymentMethodTitle?.toLowerCase().includes("cash"); diff --git a/src/modules/shared/wooSnapshot.js b/src/modules/shared/wooSnapshot.js index cc2f064..767bd37 100644 --- a/src/modules/shared/wooSnapshot.js +++ b/src/modules/shared/wooSnapshot.js @@ -72,6 +72,51 @@ function parsePrice(p) { return Number.isFinite(n) ? n : null; } +/** + * Decodifica HTML entities comunes (WooCommerce las usa en nombres de productos) + */ +function decodeHtmlEntities(str) { + if (!str || typeof str !== "string") return str; + // Solo procesar si hay entidades + if (!str.includes("&")) return str; + + const entities = { + "&": "&", "<": "<", ">": ">", """: '"', "'": "'", "'": "'", + " ": " ", "¡": "¡", "¢": "¢", "£": "£", "¤": "¤", + "¥": "¥", "¦": "¦", "§": "§", "¨": "¨", "©": "©", + "ª": "ª", "«": "«", "¬": "¬", "­": "\u00AD", "®": "®", + "¯": "¯", "°": "°", "±": "±", "²": "²", "³": "³", + "´": "´", "µ": "µ", "¶": "¶", "·": "·", "¸": "¸", + "¹": "¹", "º": "º", "»": "»", "¼": "¼", "½": "½", + "¾": "¾", "¿": "¿", "À": "À", "Á": "Á", "Â": "Â", + "Ã": "Ã", "Ä": "Ä", "Å": "Å", "Æ": "Æ", "Ç": "Ç", + "È": "È", "É": "É", "Ê": "Ê", "Ë": "Ë", "Ì": "Ì", + "Í": "Í", "Î": "Î", "Ï": "Ï", "Ð": "Ð", "Ñ": "Ñ", + "Ò": "Ò", "Ó": "Ó", "Ô": "Ô", "Õ": "Õ", "Ö": "Ö", + "×": "×", "Ø": "Ø", "Ù": "Ù", "Ú": "Ú", "Û": "Û", + "Ü": "Ü", "Ý": "Ý", "Þ": "Þ", "ß": "ß", "à": "à", + "á": "á", "â": "â", "ã": "ã", "ä": "ä", "å": "å", + "æ": "æ", "ç": "ç", "è": "è", "é": "é", "ê": "ê", + "ë": "ë", "ì": "ì", "í": "í", "î": "î", "ï": "ï", + "ð": "ð", "ñ": "ñ", "ò": "ò", "ó": "ó", "ô": "ô", + "õ": "õ", "ö": "ö", "÷": "÷", "ø": "ø", "ù": "ù", + "ú": "ú", "û": "û", "ü": "ü", "ý": "ý", "þ": "þ", + "ÿ": "ÿ", + }; + + let result = str; + // Reemplazar entities nombradas usando iteración (más robusto que regex) + for (const [entity, char] of Object.entries(entities)) { + if (result.includes(entity)) { + result = result.split(entity).join(char); + } + } + // Reemplazar entities numéricas ({ o {) + result = result.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10))); + result = result.replace(/&#x([0-9a-fA-F]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + return result; +} + function normalizeAttributes(attrs) { const out = {}; if (!Array.isArray(attrs)) return out; @@ -90,7 +135,7 @@ function normalizeWooProduct(p) { woo_id: p?.id, type: p?.type || "simple", parent_id: p?.parent_id || null, - name: p?.name || "", + name: decodeHtmlEntities(p?.name || ""), slug: p?.slug || null, status: p?.status || null, catalog_visibility: p?.catalog_visibility || null, @@ -114,7 +159,7 @@ function snapshotRowToItem(row) { const raw = row?.raw || {}; return { woo_product_id: row?.woo_id, - name: row?.name || "", + name: decodeHtmlEntities(row?.name || ""), sku: raw?.SKU || raw?.sku || row?.slug || null, slug: row?.slug || null, price: row?.price_current != null ? Number(row.price_current) : null,