diff --git a/public/components/aliases-crud.js b/public/components/aliases-crud.js index 8b1c054..288c423 100644 --- a/public/components/aliases-crud.js +++ b/public/components/aliases-crud.js @@ -1,4 +1,5 @@ import { api } from "../lib/api.js"; +import { modal } from "../lib/modal.js"; class AliasesCrud extends HTMLElement { constructor() { @@ -380,7 +381,7 @@ class AliasesCrud extends HTMLElement { addBtn.onclick = () => { if (!selectedProductId) { - alert("Selecciona un producto primero"); + modal.warn("Selecciona un producto primero"); return; } @@ -407,12 +408,12 @@ class AliasesCrud extends HTMLElement { const categoryInput = this.shadowRoot.getElementById("categoryInput").value.trim(); if (!aliasInput) { - alert("El alias es requerido"); + modal.warn("El alias es requerido"); return; } if (!this.productMappings.length) { - alert("Agrega al menos un producto"); + modal.warn("Agrega al menos un producto"); return; } @@ -441,13 +442,14 @@ class AliasesCrud extends HTMLElement { this.renderForm(); } catch (e) { console.error("Error saving alias:", e); - alert("Error guardando: " + (e.message || e)); + modal.error("Error guardando: " + (e.message || e)); } } async delete() { if (!this.selected?.alias) return; - if (!confirm(`¿Eliminar el alias "${this.selected.alias}"?`)) return; + const confirmed = await modal.confirm(`¿Eliminar el alias "${this.selected.alias}"?`); + if (!confirmed) return; try { await api.deleteAlias(this.selected.alias); @@ -458,7 +460,7 @@ class AliasesCrud extends HTMLElement { this.renderForm(); } catch (e) { console.error("Error deleting alias:", e); - alert("Error eliminando: " + (e.message || e)); + modal.error("Error eliminando: " + (e.message || e)); } } diff --git a/public/components/chat-simulator.js b/public/components/chat-simulator.js index 03e6163..73aec1f 100644 --- a/public/components/chat-simulator.js +++ b/public/components/chat-simulator.js @@ -1,5 +1,6 @@ import { api } from "../lib/api.js"; import { emit, on } from "../lib/bus.js"; +import { modal } from "../lib/modal.js"; class ChatSimulator extends HTMLElement { constructor() { @@ -129,7 +130,7 @@ class ChatSimulator extends HTMLElement { const pushName = evoPushEl.value.trim(); if (!from || !text) { - alert("Falta from o text"); + modal.warn("Falta from o text"); return; } diff --git a/public/components/conversation-list.js b/public/components/conversation-list.js index 34b73aa..1411815 100644 --- a/public/components/conversation-list.js +++ b/public/components/conversation-list.js @@ -1,5 +1,6 @@ import { api } from "../lib/api.js"; import { emit, on } from "../lib/bus.js"; +import { modal } from "../lib/modal.js"; class ConversationList extends HTMLElement { constructor() { @@ -90,7 +91,8 @@ class ConversationList extends HTMLElement { this.shadowRoot.getElementById("uaDeleteConv").onclick = async () => { if (!this.selectedUser) return; const chat_id = this.selectedUser.chat_id; - if (!confirm(`¿Borrar conversación completa de ${chat_id}?`)) return; + const confirmed = await modal.confirm(`¿Borrar conversación completa de ${chat_id}?`); + if (!confirmed) return; await api.deleteConversation(chat_id); this.selectedUser = null; await this.refreshUsers(); @@ -98,7 +100,8 @@ class ConversationList extends HTMLElement { this.shadowRoot.getElementById("uaDeleteUser").onclick = async () => { if (!this.selectedUser) return; const chat_id = this.selectedUser.chat_id; - if (!confirm(`¿Borrar usuario ${chat_id}, sus conversaciones y el customer en Woo?`)) return; + const confirmed = await modal.confirm(`¿Borrar usuario ${chat_id}, sus conversaciones y el customer en Woo?`); + if (!confirmed) return; await api.deleteUser(chat_id, { deleteWoo: true }); this.selectedUser = null; await this.refreshUsers(); @@ -229,7 +232,8 @@ class ConversationList extends HTMLElement { el.onclick = async (e) => { if (e?.target?.dataset?.del) { e.stopPropagation(); - if (!confirm(`¿Borrar conversación completa de ${c.chat_id}?`)) return; + const confirmed = await modal.confirm(`¿Borrar conversación completa de ${c.chat_id}?`); + if (!confirmed) return; await api.deleteConversation(c.chat_id); if (this.selected === c.chat_id) this.selected = null; await this.refresh(); diff --git a/public/components/conversations-crud.js b/public/components/conversations-crud.js index 77d05e5..0919d51 100644 --- a/public/components/conversations-crud.js +++ b/public/components/conversations-crud.js @@ -1,5 +1,6 @@ import { api } from "../lib/api.js"; import { emit, on } from "../lib/bus.js"; +import { modal } from "../lib/modal.js"; class ConversationsCrud extends HTMLElement { constructor() { @@ -244,21 +245,22 @@ class ConversationsCrud extends HTMLElement { this.shadowRoot.getElementById("retryLast").onclick = async () => { try { await api.retryLast(c.chat_id); - alert("Retry ejecutado"); + modal.success("Retry ejecutado"); } catch (e) { - alert("Error: " + (e.message || e)); + modal.error("Error: " + (e.message || e)); } }; this.shadowRoot.getElementById("deleteConv").onclick = async () => { - if (!confirm(`¿Eliminar la conversacion de "${c.chat_id}"?`)) return; + const confirmed = await modal.confirm(`¿Eliminar la conversación de "${c.chat_id}"?`); + if (!confirmed) return; try { await api.deleteConversation(c.chat_id); this.selected = null; await this.load(); this.renderDetail(); } catch (e) { - alert("Error: " + (e.message || e)); + modal.error("Error: " + (e.message || e)); } }; } diff --git a/public/components/products-crud.js b/public/components/products-crud.js index 1a1e2fe..002c205 100644 --- a/public/components/products-crud.js +++ b/public/components/products-crud.js @@ -1,6 +1,7 @@ import { api } from "../lib/api.js"; import { on } from "../lib/bus.js"; import { navigateToItem } from "../lib/router.js"; +import { modal } from "../lib/modal.js"; class ProductsCrud extends HTMLElement { constructor() { @@ -166,13 +167,10 @@ class ProductsCrud extends HTMLElement { async syncFromWoo() { // Mostrar confirmación antes de sincronizar - const confirmed = confirm( + const confirmed = await modal.confirm( "⚠️ Resincronización de emergencia\n\n" + "Esto reimportará TODOS los productos desde WooCommerce y sobrescribirá los datos locales.\n\n" + - "Usar solo si:\n" + - "• La plataforma estuvo caída mientras se hacían cambios en Woo\n" + - "• Los webhooks no funcionaron correctamente\n" + - "• Necesitás una sincronización completa\n\n" + + "Usar solo si la plataforma estuvo caída, los webhooks no funcionaron, o necesitás una sincronización completa.\n\n" + "¿Continuar?" ); @@ -185,14 +183,14 @@ class ProductsCrud extends HTMLElement { try { const result = await api.syncFromWoo(); if (result.ok) { - alert(`Sincronización completada: ${result.synced} productos importados`); + modal.success(`Sincronización completada: ${result.synced} productos importados`); } else { - alert("Error: " + (result.error || "Error desconocido")); + modal.error("Error: " + (result.error || "Error desconocido")); } await this.load(); } catch (e) { console.error("Error syncing products:", e); - alert("Error sincronizando: " + (e.message || e)); + modal.error("Error sincronizando: " + (e.message || e)); } finally { btn.disabled = false; btn.textContent = "Resincronizar"; @@ -636,7 +634,7 @@ class ProductsCrud extends HTMLElement { setTimeout(() => { btn.textContent = "Guardar cambios"; btn.disabled = false; }, 1500); } catch (e) { console.error("Error saving product:", e); - alert("Error guardando: " + (e.message || e)); + modal.error("Error guardando: " + (e.message || e)); btn.textContent = "Guardar cambios"; btn.disabled = false; } @@ -710,7 +708,7 @@ class ProductsCrud extends HTMLElement { const categoryName = select.value; if (!categoryName) { - alert("Seleccioná una categoría"); + modal.warn("Seleccioná una categoría"); return; } @@ -753,7 +751,7 @@ class ProductsCrud extends HTMLElement { setTimeout(() => { btn.textContent = "Agregar"; btn.disabled = false; }, 1500); } catch (e) { console.error("Error adding category:", e); - alert("Error agregando categoría: " + (e.message || e)); + modal.error("Error agregando categoría: " + (e.message || e)); btn.textContent = "Agregar"; btn.disabled = false; } @@ -804,7 +802,7 @@ class ProductsCrud extends HTMLElement { }, 1500); } catch (e) { console.error("Error saving product unit:", e); - alert("Error guardando: " + (e.message || e)); + modal.error("Error guardando: " + (e.message || e)); btn.textContent = count > 1 ? `Guardar para ${count}` : "Guardar"; btn.disabled = false; } diff --git a/public/components/prompts-crud.js b/public/components/prompts-crud.js index 314c7ea..e1d5dbd 100644 --- a/public/components/prompts-crud.js +++ b/public/components/prompts-crud.js @@ -1,5 +1,6 @@ import { api } from "../lib/api.js"; import { on } from "../lib/bus.js"; +import { modal } from "../lib/modal.js"; const PROMPT_LABELS = { router: "Router (clasificador de dominio)", @@ -376,61 +377,59 @@ class PromptsCrud extends HTMLElement { const model = this.shadowRoot.getElementById("modelSelect").value; if (!content.trim()) { - alert("El contenido no puede estar vacio"); + modal.warn("El contenido no puede estar vacío"); return; } try { await api.savePrompt(this.selected.prompt_key, { content, model }); - alert("Prompt guardado correctamente"); + modal.success("Prompt guardado correctamente"); await this.load(); // Re-seleccionar el prompt actual const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key); if (updated) this.selectPrompt(updated); } catch (e) { console.error("Error saving prompt:", e); - alert("Error guardando: " + (e.message || e)); + modal.error("Error guardando: " + (e.message || e)); } } async reset() { - if (!confirm("Esto desactivara todas las versiones custom y volvera al prompt por defecto. Continuar?")) { - return; - } + const confirmed = await modal.confirm("Esto desactivará todas las versiones custom y volverá al prompt por defecto. ¿Continuar?"); + if (!confirmed) return; try { await api.resetPrompt(this.selected.prompt_key); - alert("Prompt reseteado a default"); + modal.success("Prompt reseteado a default"); await this.load(); const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key); if (updated) this.selectPrompt(updated); } catch (e) { console.error("Error resetting prompt:", e); - alert("Error: " + (e.message || e)); + modal.error("Error: " + (e.message || e)); } } async rollback(version) { - if (!confirm(`Restaurar version ${version}? Se creara una nueva version con ese contenido.`)) { - return; - } + const confirmed = await modal.confirm(`¿Restaurar versión ${version}? Se creará una nueva versión con ese contenido.`); + if (!confirmed) return; try { await api.rollbackPrompt(this.selected.prompt_key, version); - alert("Version restaurada"); + modal.success("Versión restaurada"); await this.load(); const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key); if (updated) this.selectPrompt(updated); } catch (e) { console.error("Error rolling back:", e); - alert("Error: " + (e.message || e)); + modal.error("Error: " + (e.message || e)); } } async runTest() { const testMessage = this.shadowRoot.getElementById("testMessage").value; if (!testMessage.trim()) { - alert("Ingresa un mensaje de prueba"); + modal.warn("Ingresa un mensaje de prueba"); return; } diff --git a/public/components/recommendations-crud.js b/public/components/recommendations-crud.js index 632df46..157a18a 100644 --- a/public/components/recommendations-crud.js +++ b/public/components/recommendations-crud.js @@ -1,6 +1,7 @@ import { api } from "../lib/api.js"; import { on } from "../lib/bus.js"; import { navigateToItem } from "../lib/router.js"; +import { modal } from "../lib/modal.js"; class RecommendationsCrud extends HTMLElement { static get observedAttributes() { @@ -661,7 +662,7 @@ class RecommendationsCrud extends HTMLElement { addBtn.onclick = () => { if (!selectedProductId) { - alert("Selecciona un producto primero"); + modal.warn("Selecciona un producto primero"); return; } @@ -792,7 +793,7 @@ class RecommendationsCrud extends HTMLElement { const active = this.shadowRoot.getElementById("activeInput").checked; if (!ruleKey) { - alert("El nombre de la regla es requerido"); + modal.warn("El nombre de la regla es requerido"); return; } @@ -812,11 +813,11 @@ class RecommendationsCrud extends HTMLElement { if (this.currentRuleType === "crosssell") { if (!this.selectedTriggerProducts.length) { - alert("Selecciona al menos un producto trigger"); + modal.warn("Selecciona al menos un producto trigger"); return; } if (!this.selectedRecommendedProducts.length) { - alert("Selecciona al menos un producto para recomendar"); + modal.warn("Selecciona al menos un producto para recomendar"); return; } data.trigger_product_ids = this.selectedTriggerProducts; @@ -827,7 +828,7 @@ class RecommendationsCrud extends HTMLElement { data.trigger_event = triggerEvent; if (!this.ruleItems.length) { - alert("Agrega al menos un producto con cantidad"); + modal.warn("Agrega al menos un producto con cantidad"); return; } @@ -853,13 +854,14 @@ class RecommendationsCrud extends HTMLElement { this.renderForm(); } catch (e) { console.error("Error saving recommendation:", e); - alert("Error guardando: " + (e.message || e)); + modal.error("Error guardando: " + (e.message || e)); } } async delete() { if (!this.selected?.id) return; - if (!confirm(`¿Eliminar la regla "${this.selected.rule_key}"?`)) return; + const confirmed = await modal.confirm(`¿Eliminar la regla "${this.selected.rule_key}"?`); + if (!confirmed) return; try { await api.deleteRecommendation(this.selected.id); @@ -869,7 +871,7 @@ class RecommendationsCrud extends HTMLElement { this.renderForm(); } catch (e) { console.error("Error deleting recommendation:", e); - alert("Error eliminando: " + (e.message || e)); + modal.error("Error eliminando: " + (e.message || e)); } } diff --git a/public/components/takeovers-crud.js b/public/components/takeovers-crud.js index 4490a2c..ba6a02a 100644 --- a/public/components/takeovers-crud.js +++ b/public/components/takeovers-crud.js @@ -1,5 +1,6 @@ import { api } from "../lib/api.js"; import { on } from "../lib/bus.js"; +import { modal } from "../lib/modal.js"; class TakeoversCrud extends HTMLElement { constructor() { @@ -10,6 +11,7 @@ class TakeoversCrud extends HTMLElement { this.loading = false; this.products = []; this.pendingCount = 0; + this.cartItemsToAdd = []; // Items que el humano quiere agregar al carrito this.shadowRoot.innerHTML = ` @@ -259,9 +277,30 @@ class TakeoversCrud extends HTMLElement { ` : ""} +
+

+ + Agregar al carrito del cliente +

+
+
+
+ +
+
+ + + +
+
+
- +
@@ -297,7 +336,148 @@ class TakeoversCrud extends HTMLElement { aliasSection.style.display = addAliasCheck.checked ? "block" : "none"; }; + // Limpiar items al cambiar de takeover + this.cartItemsToAdd = []; + this.renderCartItemsList(); + this.setupProductSelector(); + this.setupCartProductSelector(); + } + + renderCartItemsList() { + const container = this.shadowRoot.getElementById("cartItemsList"); + if (!container) return; + + if (this.cartItemsToAdd.length === 0) { + container.innerHTML = `
Sin items agregados
`; + return; + } + + container.innerHTML = this.cartItemsToAdd.map((item, idx) => ` +
+ ${item.name} + + + +
+ `).join(""); + + // Event listeners para editar/eliminar + container.querySelectorAll(".qty").forEach(input => { + input.onchange = (e) => { + const idx = parseInt(e.target.dataset.idx, 10); + this.cartItemsToAdd[idx].qty = parseFloat(e.target.value) || 1; + }; + }); + + container.querySelectorAll(".unit-select").forEach(select => { + select.onchange = (e) => { + const idx = parseInt(e.target.dataset.idx, 10); + this.cartItemsToAdd[idx].unit = e.target.value; + }; + }); + + container.querySelectorAll(".remove-btn").forEach(btn => { + btn.onclick = (e) => { + const idx = parseInt(e.target.dataset.idx, 10); + this.cartItemsToAdd.splice(idx, 1); + this.renderCartItemsList(); + }; + }); + } + + setupCartProductSelector() { + const searchInput = this.shadowRoot.getElementById("cartProductSearch"); + const dropdown = this.shadowRoot.getElementById("cartProductDropdown"); + const addBtn = this.shadowRoot.getElementById("addCartItemBtn"); + const qtyInput = this.shadowRoot.getElementById("cartQtyInput"); + const unitSelect = this.shadowRoot.getElementById("cartUnitSelect"); + + if (!searchInput || !dropdown || !addBtn) return; + + let selectedProduct = null; + + const renderDropdown = (query) => { + const q = (query || "").toLowerCase().trim(); + let filtered = this.products; + if (q) { + filtered = filtered.filter(p => p.name.toLowerCase().includes(q)); + } + filtered = filtered.slice(0, 30); + + if (!filtered.length) { + dropdown.classList.remove("open"); + return; + } + + dropdown.innerHTML = filtered.map(p => ` +
+ ${p.name} + $${p.price || 0} +
+ `).join(""); + + dropdown.querySelectorAll(".product-option").forEach(opt => { + opt.onclick = () => { + selectedProduct = { + woo_id: parseInt(opt.dataset.id, 10), + name: opt.dataset.name, + }; + searchInput.value = selectedProduct.name; + // Auto-seleccionar unidad según producto + const productUnit = opt.dataset.unit || "kg"; + if (unitSelect) { + unitSelect.value = productUnit === "unit" || productUnit === "Unidad" ? "unit" : "kg"; + } + dropdown.classList.remove("open"); + }; + }); + + dropdown.classList.add("open"); + }; + + searchInput.oninput = () => { + selectedProduct = null; + clearTimeout(this._cartSearchTimer); + this._cartSearchTimer = setTimeout(() => renderDropdown(searchInput.value), 150); + }; + + searchInput.onfocus = () => renderDropdown(searchInput.value); + + addBtn.onclick = () => { + if (!selectedProduct) { + modal.warn("Selecciona un producto primero"); + return; + } + + const qty = parseFloat(qtyInput.value) || 1; + const unit = unitSelect.value || "kg"; + + this.cartItemsToAdd.push({ + woo_id: selectedProduct.woo_id, + name: selectedProduct.name, + qty, + unit, + }); + + // Limpiar inputs + searchInput.value = ""; + qtyInput.value = "1"; + selectedProduct = null; + + this.renderCartItemsList(); + }; + + // Close dropdown on outside click + document.addEventListener("click", (e) => { + if (!this.shadowRoot.getElementById("cartProductSelector")?.contains(e.target)) { + dropdown.classList.remove("open"); + } + }); } escapeHtml(str) { @@ -362,7 +542,7 @@ class TakeoversCrud extends HTMLElement { async respond() { const response = this.shadowRoot.getElementById("responseInput").value.trim(); if (!response) { - alert("Escribe una respuesta"); + modal.warn("Escribe una respuesta"); return; } @@ -376,32 +556,42 @@ class TakeoversCrud extends HTMLElement { }; } + // Items a agregar al carrito del cliente + const cartItems = this.cartItemsToAdd.length > 0 ? this.cartItemsToAdd : null; + try { - await api.respondTakeover(this.selected.id, { response, add_alias: addAlias }); - alert("Respuesta enviada"); + await api.respondTakeover(this.selected.id, { + response, + add_alias: addAlias, + cart_items: cartItems, + }); + + const itemsMsg = cartItems ? ` (${cartItems.length} item(s) agregados al carrito)` : ""; + modal.success("Respuesta enviada" + itemsMsg); + this.selected = null; + this.cartItemsToAdd = []; await this.load(); this.renderForm(); } catch (e) { console.error("Error responding:", e); - alert("Error: " + (e.message || e)); + modal.error("Error: " + (e.message || e)); } } async cancel() { - if (!confirm("Cancelar este takeover? El cliente no recibira respuesta automatica.")) { - return; - } + const confirmed = await modal.confirm("¿Cancelar este takeover? El cliente no recibirá respuesta automática."); + if (!confirmed) return; try { await api.cancelTakeover(this.selected.id); - alert("Takeover cancelado"); + modal.success("Takeover cancelado"); this.selected = null; await this.load(); this.renderForm(); } catch (e) { console.error("Error cancelling:", e); - alert("Error: " + (e.message || e)); + modal.error("Error: " + (e.message || e)); } } } diff --git a/public/components/test-panel.js b/public/components/test-panel.js index baa77ac..93de0fd 100644 --- a/public/components/test-panel.js +++ b/public/components/test-panel.js @@ -1,4 +1,5 @@ import { api } from "../lib/api.js"; +import { modal } from "../lib/modal.js"; // Datos aleatorios para generar usuarios de prueba const NOMBRES = ["Juan", "María", "Carlos", "Ana", "Pedro", "Laura", "Diego", "Sofía"]; @@ -300,7 +301,7 @@ class TestPanel extends HTMLElement { } if (this.products.length === 0) { - alert("No hay productos con stock disponible"); + modal.warn("No hay productos con stock disponible"); return; } @@ -420,11 +421,11 @@ class TestPanel extends HTMLElement { this.shadowRoot.getElementById("orderResult").style.display = "block"; this.shadowRoot.getElementById("inputAmount").value = result.total || ""; } else { - alert("Error: " + (result.error || "Error desconocido")); + modal.error("Error: " + (result.error || "Error desconocido")); } } catch (e) { console.error("[test-panel] createOrder error:", e); - alert("Error creando orden: " + e.message); + modal.error("Error creando orden: " + e.message); } finally { this.loading = false; btn.textContent = "Crear Orden en WooCommerce"; @@ -435,13 +436,13 @@ class TestPanel extends HTMLElement { async createPaymentLink() { if (this.loading) return; if (!this.lastOrder?.woo_order_id) { - alert("Primero creá una orden"); + modal.warn("Primero creá una orden"); return; } const amount = parseFloat(this.shadowRoot.getElementById("inputAmount").value); if (!amount || amount <= 0) { - alert("Ingresá un monto válido"); + modal.warn("Ingresá un monto válido"); return; } @@ -464,11 +465,11 @@ class TestPanel extends HTMLElement { this.shadowRoot.getElementById("preferenceIdValue").textContent = result.preference_id || "—"; this.shadowRoot.getElementById("paymentResult").style.display = "block"; } else { - alert("Error: " + (result.error || "Error desconocido")); + modal.error("Error: " + (result.error || "Error desconocido")); } } catch (e) { console.error("[test-panel] createPaymentLink error:", e); - alert("Error generando link: " + e.message); + modal.error("Error generando link: " + e.message); } finally { this.loading = false; btn.textContent = "Generar Link de Pago"; @@ -479,7 +480,7 @@ class TestPanel extends HTMLElement { async simulateWebhook() { if (this.loading) return; if (!this.lastOrder?.woo_order_id) { - alert("Primero creá una orden"); + modal.warn("Primero creá una orden"); return; } @@ -501,11 +502,11 @@ class TestPanel extends HTMLElement { this.shadowRoot.getElementById("webhookOrderStatusValue").textContent = result.order_status || "processing"; this.shadowRoot.getElementById("webhookResult").style.display = "block"; } else { - alert("Error: " + (result.error || "Error desconocido")); + modal.error("Error: " + (result.error || "Error desconocido")); } } catch (e) { console.error("[test-panel] simulateWebhook error:", e); - alert("Error simulando webhook: " + e.message); + modal.error("Error simulando webhook: " + e.message); } finally { this.loading = false; btn.textContent = "Simular Pago Exitoso"; diff --git a/public/components/users-crud.js b/public/components/users-crud.js index d80c2e6..16d7910 100644 --- a/public/components/users-crud.js +++ b/public/components/users-crud.js @@ -1,6 +1,7 @@ import { api } from "../lib/api.js"; import { emit, on } from "../lib/bus.js"; import { navigateToItem } from "../lib/router.js"; +import { modal } from "../lib/modal.js"; class UsersCrud extends HTMLElement { constructor() { @@ -291,24 +292,26 @@ class UsersCrud extends HTMLElement { }; this.shadowRoot.getElementById("deleteConv").onclick = async () => { - if (!confirm(`¿Eliminar la conversacion de "${u.chat_id}"?`)) return; + const confirmed = await modal.confirm(`¿Eliminar la conversación de "${u.chat_id}"?`); + if (!confirmed) return; try { await api.deleteConversation(u.chat_id); - alert("Conversacion eliminada"); + modal.success("Conversación eliminada"); } catch (e) { - alert("Error: " + (e.message || e)); + modal.error("Error: " + (e.message || e)); } }; this.shadowRoot.getElementById("deleteUser").onclick = async () => { - if (!confirm(`¿Eliminar usuario "${u.chat_id}", su conversacion y el customer en Woo?`)) return; + const confirmed = await modal.confirm(`¿Eliminar usuario "${u.chat_id}", su conversación y el customer en Woo?`); + if (!confirmed) return; try { await api.deleteUser(u.chat_id, { deleteWoo: true }); this.selected = null; await this.load(); this.renderDetail(); } catch (e) { - alert("Error: " + (e.message || e)); + modal.error("Error: " + (e.message || e)); } }; } diff --git a/public/lib/modal.js b/public/lib/modal.js new file mode 100644 index 0000000..3c126a2 --- /dev/null +++ b/public/lib/modal.js @@ -0,0 +1,232 @@ +/** + * Sistema de modales centralizado para reemplazar alert() nativos + * Uso: + * import { modal } from './lib/modal.js'; + * modal.success("Guardado correctamente"); + * modal.error("Error: " + e.message); + * modal.info("Información importante"); + * modal.warn("Advertencia"); + * const ok = await modal.confirm("¿Estás seguro?"); + */ + +const STYLES = ` + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.15s ease-out; + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes slideIn { + from { transform: translateY(-20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + + .modal-box { + background: #1e1e1e; + border-radius: 8px; + padding: 24px; + min-width: 320px; + max-width: 480px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + animation: slideIn 0.2s ease-out; + border: 1px solid #333; + } + + .modal-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + } + + .modal-icon { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + } + + .modal-icon.success { background: #22c55e20; color: #22c55e; } + .modal-icon.error { background: #ef444420; color: #ef4444; } + .modal-icon.warn { background: #f59e0b20; color: #f59e0b; } + .modal-icon.info { background: #3b82f620; color: #3b82f6; } + .modal-icon.confirm { background: #8b5cf620; color: #8b5cf6; } + + .modal-title { + font-size: 16px; + font-weight: 600; + color: #fff; + margin: 0; + } + + .modal-message { + color: #ccc; + font-size: 14px; + line-height: 1.5; + margin-bottom: 20px; + word-break: break-word; + } + + .modal-buttons { + display: flex; + gap: 12px; + justify-content: flex-end; + } + + .modal-btn { + padding: 8px 16px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + transition: all 0.15s; + } + + .modal-btn:hover { + filter: brightness(1.1); + } + + .modal-btn.primary { + background: #3b82f6; + color: white; + } + + .modal-btn.secondary { + background: #333; + color: #ccc; + border: 1px solid #444; + } + + .modal-btn.danger { + background: #ef4444; + color: white; + } +`; + +// Inyectar estilos una sola vez +let stylesInjected = false; +function injectStyles() { + if (stylesInjected) return; + const style = document.createElement("style"); + style.textContent = STYLES; + document.head.appendChild(style); + stylesInjected = true; +} + +const ICONS = { + success: "✓", + error: "✕", + warn: "!", + info: "i", + confirm: "?", +}; + +const TITLES = { + success: "Éxito", + error: "Error", + warn: "Advertencia", + info: "Información", + confirm: "Confirmar", +}; + +function createModal({ type, message, showCancel = false, confirmText = "Aceptar", cancelText = "Cancelar" }) { + injectStyles(); + + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.className = "modal-overlay"; + + const box = document.createElement("div"); + box.className = "modal-box"; + + box.innerHTML = ` + + + + `; + + overlay.appendChild(box); + document.body.appendChild(overlay); + + // Focus en el botón principal + const confirmBtn = box.querySelector('[data-action="confirm"]'); + confirmBtn?.focus(); + + const close = (result) => { + overlay.style.animation = "fadeIn 0.15s ease-out reverse"; + setTimeout(() => { + overlay.remove(); + resolve(result); + }, 140); + }; + + // Click en botones + box.addEventListener("click", (e) => { + const action = e.target.dataset?.action; + if (action === "confirm") close(true); + if (action === "cancel") close(false); + }); + + // Click fuera cierra (solo para mensajes, no confirms) + overlay.addEventListener("click", (e) => { + if (e.target === overlay && !showCancel) { + close(true); + } + }); + + // Escape cierra + const handleKeydown = (e) => { + if (e.key === "Escape") { + close(showCancel ? false : true); + document.removeEventListener("keydown", handleKeydown); + } + if (e.key === "Enter") { + close(true); + document.removeEventListener("keydown", handleKeydown); + } + }; + document.addEventListener("keydown", handleKeydown); + }); +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +export const modal = { + success: (message) => createModal({ type: "success", message }), + error: (message) => createModal({ type: "error", message }), + warn: (message) => createModal({ type: "warn", message }), + info: (message) => createModal({ type: "info", message }), + confirm: (message, { confirmText = "Confirmar", cancelText = "Cancelar" } = {}) => + createModal({ type: "confirm", message, showCancel: true, confirmText, cancelText }), +}; + +// También exportar como default para conveniencia +export default modal; diff --git a/src/modules/0-ui/controllers/takeovers.js b/src/modules/0-ui/controllers/takeovers.js index b22dd9d..a39f8eb 100644 --- a/src/modules/0-ui/controllers/takeovers.js +++ b/src/modules/0-ui/controllers/takeovers.js @@ -63,7 +63,7 @@ export const makeRespondToTakeover = (tenantIdOrFn) => async (req, res) => { try { const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn; const id = parseInt(req.params.id, 10); - const { response, responded_by, add_alias } = req.body || {}; + const { response, responded_by, add_alias, cart_items } = req.body || {}; if (!response) { return res.status(400).json({ ok: false, error: "response_required" }); @@ -75,6 +75,7 @@ export const makeRespondToTakeover = (tenantIdOrFn) => async (req, res) => { response, respondedBy: responded_by || null, addAlias: add_alias || null, + cartItems: cart_items || null, }); res.json(result); } catch (err) { diff --git a/src/modules/0-ui/handlers/takeovers.js b/src/modules/0-ui/handlers/takeovers.js index ada9ed8..19439c6 100644 --- a/src/modules/0-ui/handlers/takeovers.js +++ b/src/modules/0-ui/handlers/takeovers.js @@ -10,7 +10,7 @@ import { getTakeoverStats, } from "../db/takeoverRepo.js"; import { insertAlias, upsertAliasMapping } from "../db/repo.js"; -import { getRecentMessagesForLLM } from "../../2-identity/db/repo.js"; +import { getRecentMessagesForLLM, getConversationState, upsertConversationState } from "../../2-identity/db/repo.js"; /** * Lista takeovers pendientes de respuesta @@ -99,19 +99,72 @@ export async function handleRespondToTakeover({ response, respondedBy = null, addAlias = null, // { query: string, woo_product_id: number } + cartItems = null, // [{ woo_id, name, qty, unit }] - items a agregar al carrito }) { - // Responder al takeover + // Responder al takeover con los items del carrito const result = await respondToTakeover({ tenantId, id, humanResponse: response, respondedBy, + cartItems, // Pasar los items para que se guarden }); if (!result) { throw new Error("Takeover not found or already responded"); } + // Si hay items para agregar al carrito, actualizar el estado de la conversación + if (cartItems && cartItems.length > 0 && result.chat_id) { + try { + // Obtener estado actual + const currentState = await getConversationState(tenantId, result.chat_id); + const context = currentState?.context || {}; + const order = context.order || { cart: [], pending: [] }; + + // Agregar los items al carrito + const newCartItems = cartItems.map(item => ({ + woo_id: item.woo_id, + qty: parseFloat(item.qty) || 1, + unit: item.unit || "kg", + name: item.name || null, + price: item.price || null, + })); + + order.cart = [...(order.cart || []), ...newCartItems]; + + // Actualizar estado: cambiar a CART para que el bot retome + await upsertConversationState({ + tenant_id: tenantId, + wa_chat_id: result.chat_id, + state: "CART", // Retomar flujo normal + last_intent: "human_response", + context: { ...context, order }, + }); + + console.log(`[takeovers] Added ${newCartItems.length} items to cart for ${result.chat_id}`); + } catch (e) { + console.error("[takeovers] Error updating cart:", e); + // No fallar si hay error al actualizar carrito + } + } else if (result.chat_id) { + // Si no hay items pero respondió, igual cambiar estado a CART preservando el context + try { + const currentState = await getConversationState(tenantId, result.chat_id); + const context = currentState?.context || {}; + + await upsertConversationState({ + tenant_id: tenantId, + wa_chat_id: result.chat_id, + state: "CART", + last_intent: "human_response", + context, + }); + } catch (e) { + console.error("[takeovers] Error updating state:", e); + } + } + // Si se pidió agregar alias, hacerlo if (addAlias && addAlias.query && addAlias.woo_product_id) { try { @@ -138,6 +191,7 @@ export async function handleRespondToTakeover({ return { ok: true, takeover: result, + cart_items_added: cartItems?.length || 0, message: "Response sent successfully", }; } diff --git a/src/modules/3-turn-engine/orderModel.js b/src/modules/3-turn-engine/orderModel.js index 3ca04bd..db96d3d 100644 --- a/src/modules/3-turn-engine/orderModel.js +++ b/src/modules/3-turn-engine/orderModel.js @@ -53,7 +53,9 @@ export function createPendingItem({ selected_unit = null, qty = null, unit = null, - status = PendingStatus.NEEDS_TYPE + status = PendingStatus.NEEDS_TYPE, + requested_qty = null, + requested_unit = null, }) { return { id: id || `pending_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, @@ -66,6 +68,8 @@ export function createPendingItem({ qty, // Cantidad (null si NEEDS_QUANTITY) unit, // Unidad elegida por usuario status, + requested_qty, // Cantidad pedida originalmente por el usuario (para usar después de selección) + requested_unit, // Unidad pedida originalmente por el usuario }; } diff --git a/src/modules/3-turn-engine/stateHandlers.js b/src/modules/3-turn-engine/stateHandlers.js index 9513e34..52e54a8 100644 --- a/src/modules/3-turn-engine/stateHandlers.js +++ b/src/modules/3-turn-engine/stateHandlers.js @@ -20,6 +20,7 @@ import { } from "./orderModel.js"; import { handleRecommend } from "./recommendations.js"; import { getProductQtyRules } from "../0-ui/db/repo.js"; +import { createHumanTakeoverResponse } from "./nlu/humanFallback.js"; // ───────────────────────────────────────────────────────────── // Utilidades @@ -134,9 +135,55 @@ export async function handleCartState({ tenantId, text, nlu, order, audit, fromI let currentOrder = order || createEmptyOrder(); const actions = []; - // 1) Si hay pending items sin resolver, procesar clarificación + // Intents que tienen prioridad sobre pending items (permiten "escapar" del loop) + const priorityIntents = ["view_cart", "confirm_order", "greeting"]; + const isPriorityIntent = priorityIntents.includes(intent); + + // Detectar si el usuario quiere cancelar/saltar el pending item actual const pendingItem = getNextPendingItem(currentOrder); - if (pendingItem) { + const cancelPhrases = /\b(dejalo|dejá|deja|olvidalo|olvida|nada|no importa|saltea|saltar|otro|siguiente|cancelar|skip)\b/i; + const wantsToSkipPending = pendingItem && cancelPhrases.test(text || ""); + + // Si quiere saltar el pending, eliminarlo + if (wantsToSkipPending && pendingItem) { + currentOrder = { + ...currentOrder, + pending: (currentOrder.pending || []).filter(p => p.id !== pendingItem.id), + }; + audit.skipped_pending = pendingItem.query; + + // Si hay más pending items, continuar con el siguiente + const nextPending = getNextPendingItem(currentOrder); + if (nextPending) { + const { question } = formatOptionsForDisplay(nextPending); + return { + plan: { + reply: `Ok, salteo "${pendingItem.query}". ${question}`, + next_state: ConversationState.CART, + intent: "add_to_cart", + missing_fields: ["product_selection"], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + // No hay más pending, mostrar confirmación + const cartDisplay = formatCartForDisplay(currentOrder); + return { + plan: { + reply: `Ok, salteo "${pendingItem.query}".\n\n${cartDisplay}\n\n¿Algo más?`, + next_state: ConversationState.CART, + intent: "other", + missing_fields: [], + order_action: "none", + }, + decision: { actions: [], order: currentOrder, audit }, + }; + } + + // 1) Si hay pending items sin resolver Y NO es un intent prioritario, procesar clarificación + if (pendingItem && !isPriorityIntent) { const result = await processPendingClarification({ tenantId, text, nlu, order: currentOrder, pendingItem, audit }); if (result) return result; } @@ -768,6 +815,8 @@ function createPendingItemFromSearch({ query, quantity, unit, candidates }) { } // Multiple candidates, needs selection + // Guardar la cantidad y unidad que pidió el usuario para usarla después de seleccionar + const hasQty = quantity != null && Number.isFinite(Number(quantity)) && Number(quantity) > 0; return createPendingItem({ query, candidates: cands.slice(0, 20).map(c => ({ @@ -778,6 +827,9 @@ function createPendingItemFromSearch({ query, quantity, unit, candidates }) { display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }), })), status: PendingStatus.NEEDS_TYPE, + // Guardar cantidad/unidad pedida para usarla al seleccionar + requested_qty: hasQty ? Number(quantity) : null, + requested_unit: normalizeUnit(unit) || null, }); } @@ -806,7 +858,19 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending if (idx && pendingItem.candidates && idx <= pendingItem.candidates.length) { const selected = pendingItem.candidates[idx - 1]; const displayUnit = selected.display_unit || inferDefaultUnit({ name: selected.name, categories: [] }); - const needsQuantity = displayUnit !== "unit"; + + // Usar la cantidad que pidió originalmente el usuario, o la unidad pedida para determinar si necesita cantidad + const requestedQty = pendingItem.requested_qty; + const requestedUnit = pendingItem.requested_unit || displayUnit; + const hasRequestedQty = requestedQty != null && Number.isFinite(requestedQty) && requestedQty > 0; + + // Si vende por peso y no tenemos cantidad, preguntar. Si vende por unidad y tiene cantidad, usar esa. + const sellsByWeight = displayUnit !== "unit"; + const needsQuantity = sellsByWeight && !hasRequestedQty; + + // Determinar la cantidad final + const finalQty = hasRequestedQty ? requestedQty : 1; + const finalUnit = requestedUnit || displayUnit; const updatedOrder = updatePendingItem(order, pendingItem.id, { selected_woo_id: selected.woo_id, @@ -815,8 +879,8 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending selected_unit: displayUnit, candidates: [], status: needsQuantity ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY, - qty: needsQuantity ? null : 1, - unit: displayUnit, + qty: needsQuantity ? null : finalQty, + unit: finalUnit, }); // Si necesita cantidad, preguntar @@ -836,9 +900,13 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending // Listo, mover al cart const finalOrder = moveReadyToCart(updatedOrder); + // Formatear la cantidad según la unidad + const qtyDisplay = displayUnit === "unit" + ? `${finalQty} ${finalQty === 1 ? 'unidad de' : 'unidades de'}` + : `${finalQty}${displayUnit}`; return { plan: { - reply: `Perfecto, anoto 1 ${selected.name}. ¿Algo más?`, + reply: `Perfecto, anoto ${qtyDisplay} ${selected.name}. ¿Algo más?`, next_state: ConversationState.CART, intent: "add_to_cart", missing_fields: [], @@ -848,6 +916,69 @@ async function processPendingClarification({ tenantId, text, nlu, order, pending }; } + // Si no hay candidatos (producto no encontrado) y el usuario da texto libre, + // intentar re-buscar con el texto como aclaración + if ((!pendingItem.candidates || pendingItem.candidates.length === 0) && text && text.length > 2) { + const newQuery = text.trim(); + const searchResult = await retrieveCandidates({ tenantId, query: newQuery, limit: 20 }); + const newCandidates = searchResult?.candidates || []; + audit.retry_search = { query: newQuery, count: newCandidates.length }; + + if (newCandidates.length > 0) { + // Encontramos opciones con la nueva búsqueda + const updatedPending = { + ...pendingItem, + query: newQuery, + candidates: newCandidates.slice(0, 20).map(c => ({ + woo_id: c.woo_product_id, + name: c.name, + price: c.price, + sell_unit: c.sell_unit || null, + display_unit: normalizeUnit(c.sell_unit) || inferDefaultUnit({ name: c.name, categories: c.categories }), + })), + }; + + const updatedOrder = { + ...order, + pending: order.pending.map(p => p.id === pendingItem.id ? updatedPending : p), + }; + + const { question } = formatOptionsForDisplay(updatedPending); + return { + plan: { + reply: question, + next_state: ConversationState.CART, + intent: "add_to_cart", + missing_fields: ["product_selection"], + order_action: "none", + }, + decision: { actions: [], order: updatedOrder, audit }, + }; + } + + // Sigue sin encontrar después de la aclaración -> escalar a humano + // Primero eliminar el pending item que no se puede resolver + const orderWithoutPending = { + ...order, + pending: (order.pending || []).filter(p => p.id !== pendingItem.id), + }; + + audit.escalated_to_human = true; + audit.original_query = pendingItem.query; + audit.retry_query = newQuery; + + // Crear takeover con contexto de la búsqueda fallida + return createHumanTakeoverResponse({ + pendingQuery: `${pendingItem.query} (aclaración: "${newQuery}")`, + order: orderWithoutPending, + context: { + original_query: pendingItem.query, + user_clarification: newQuery, + search_attempts: 2, + }, + }); + } + // No entendió, volver a preguntar const { question } = formatOptionsForDisplay(pendingItem); return {