This commit is contained in:
Lucas Tettamanti
2026-02-04 16:06:51 -03:00
parent 2f8e267268
commit 5e79f17d00
21 changed files with 291 additions and 599 deletions

View File

@@ -472,7 +472,7 @@ class OrdersCrud extends HTMLElement {
container.querySelectorAll("tr[data-order-id]").forEach(row => {
row.onclick = () => {
const orderId = parseInt(row.dataset.orderId);
const order = this.orders.find(o => o.id === orderId);
const order = this.orders.find(o => o.id == orderId);
if (order) {
this.selectOrder(order);
}

View File

@@ -1,13 +1,27 @@
import { api } from "../lib/api.js";
const DAYS = [
{ 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" },
{ id: "lun", label: "Lunes", short: "L" },
{ id: "mar", label: "Martes", short: "M" },
{ id: "mie", label: "Miércoles", short: "X" },
{ id: "jue", label: "Jueves", short: "J" },
{ id: "vie", label: "Viernes", short: "V" },
{ id: "sab", label: "Sábado", short: "S" },
{ id: "dom", label: "Domingo", short: "D" },
];
// Lista oficial de 48 barrios de CABA
const CABA_BARRIOS = [
"Agronomía", "Almagro", "Balvanera", "Barracas", "Belgrano", "Boedo",
"Caballito", "Chacarita", "Coghlan", "Colegiales", "Constitución",
"Flores", "Floresta", "La Boca", "La Paternal", "Liniers",
"Mataderos", "Monte Castro", "Montserrat", "Nueva Pompeya", "Núñez",
"Palermo", "Parque Avellaneda", "Parque Chacabuco", "Parque Chas",
"Parque Patricios", "Puerto Madero", "Recoleta", "Retiro", "Saavedra",
"San Cristóbal", "San Nicolás", "San Telmo", "Vélez Sársfield", "Versalles",
"Villa Crespo", "Villa del Parque", "Villa Devoto", "Villa General Mitre",
"Villa Lugano", "Villa Luro", "Villa Ortúzar", "Villa Pueyrredón",
"Villa Real", "Villa Riachuelo", "Villa Santa Rita", "Villa Soldati", "Villa Urquiza"
];
class SettingsCrud extends HTMLElement {
@@ -113,6 +127,62 @@ class SettingsCrud extends HTMLElement {
}
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid #1e2a3a; }
/* Zonas de entrega */
.zones-search { margin-bottom:12px; }
.zones-search input {
width:100%; padding:10px 14px;
background:#0f1520 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%236c7a89'%3E%3Cpath d='M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3C/svg%3E") no-repeat 12px center;
background-size:18px; padding-left:38px;
}
.zones-list { max-height:400px; overflow-y:auto; display:flex; flex-direction:column; gap:4px; }
.zone-row {
display:grid;
grid-template-columns:32px 1fr;
gap:12px;
align-items:start;
padding:10px 12px;
background:#0f1520;
border-radius:8px;
border:1px solid #1e2a3a;
transition:border-color .2s;
}
.zone-row.active { border-color:#1f6feb; background:#0f1825; }
.zone-row.hidden { display:none; }
.zone-toggle {
width:32px; height:18px; background:#253245; border-radius:9px;
cursor:pointer; position:relative; transition:background .2s; margin-top:2px;
}
.zone-toggle.active { background:#2ecc71; }
.zone-toggle::after {
content:''; position:absolute; top:2px; left:2px;
width:14px; height:14px; background:#fff; border-radius:50%;
transition:transform .2s;
}
.zone-toggle.active::after { transform:translateX(14px); }
.zone-content { display:flex; flex-direction:column; gap:8px; }
.zone-name { font-size:14px; color:#e7eef7; font-weight:500; }
.zone-config { display:none; gap:16px; flex-wrap:wrap; align-items:center; }
.zone-row.active .zone-config { display:flex; }
.zone-days { display:flex; gap:4px; }
.zone-day {
width:28px; height:28px; border-radius:6px;
background:#253245; color:#8aa0b5;
display:flex; align-items:center; justify-content:center;
font-size:11px; font-weight:600; cursor:pointer;
transition:all .15s;
}
.zone-day.active { background:#1f6feb; color:#fff; }
.zone-day:hover { background:#2d3e52; }
.zone-day.active:hover { background:#1a5fd0; }
.zone-cost { display:flex; align-items:center; gap:6px; }
.zone-cost label { font-size:12px; color:#8aa0b5; }
.zone-cost input { width:90px; padding:6px 10px; font-size:13px; text-align:right; }
.zones-summary {
margin-top:12px; padding:12px; background:#0f1520;
border-radius:8px; font-size:13px; color:#8aa0b5;
}
.zones-summary strong { color:#e7eef7; }
</style>
<div class="container">
@@ -138,6 +208,10 @@ class SettingsCrud extends HTMLElement {
if (!this.settings.schedule) {
this.settings.schedule = { delivery: {}, pickup: {} };
}
// Asegurar que delivery_zones existe
if (!this.settings.delivery_zones) {
this.settings.delivery_zones = {};
}
this.loading = false;
this.render();
} catch (e) {
@@ -201,6 +275,72 @@ class SettingsCrud extends HTMLElement {
}).join("");
}
// Convierte nombre de barrio a key (ej: "Villa Crespo" -> "villa_crespo")
barrioToKey(name) {
return name.toLowerCase()
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // quitar acentos
.replace(/\s+/g, "_");
}
getZoneConfig(barrioKey) {
return this.settings?.delivery_zones?.[barrioKey] || null;
}
setZoneConfig(barrioKey, config) {
if (!this.settings.delivery_zones) {
this.settings.delivery_zones = {};
}
if (config === null) {
delete this.settings.delivery_zones[barrioKey];
} else {
this.settings.delivery_zones[barrioKey] = config;
}
}
renderZonesList() {
return CABA_BARRIOS.map(barrio => {
const key = this.barrioToKey(barrio);
const config = this.getZoneConfig(key);
const isActive = config?.enabled === true;
const days = config?.days || [];
const cost = config?.delivery_cost || 0;
return `
<div class="zone-row ${isActive ? 'active' : ''}" data-barrio="${key}">
<div class="zone-toggle ${isActive ? 'active' : ''}" data-barrio="${key}"></div>
<div class="zone-content">
<span class="zone-name">${barrio}</span>
<div class="zone-config">
<div class="zone-days">
${DAYS.map(d => `
<div class="zone-day ${days.includes(d.id) ? 'active' : ''}"
data-barrio="${key}" data-day="${d.id}"
title="${d.label}">${d.short}</div>
`).join("")}
</div>
<div class="zone-cost">
<label>Costo:</label>
<input type="number" class="zone-cost-input" data-barrio="${key}"
value="${cost}" min="0" step="100" placeholder="0" />
</div>
</div>
</div>
</div>
`;
}).join("");
}
renderZonesSummary() {
const zones = this.settings?.delivery_zones || {};
const activeZones = Object.entries(zones).filter(([k, v]) => v?.enabled);
if (activeZones.length === 0) {
return `<div class="zones-summary">No hay zonas de entrega configuradas. Activá los barrios donde hacés delivery.</div>`;
}
return `<div class="zones-summary"><strong>${activeZones.length}</strong> zona${activeZones.length > 1 ? 's' : ''} de entrega activa${activeZones.length > 1 ? 's' : ''}</div>`;
}
render() {
const content = this.shadowRoot.getElementById("content");
@@ -290,6 +430,24 @@ class SettingsCrud extends HTMLElement {
</div>
</div>
<!-- Zonas de Entrega -->
<div class="panel">
<div class="panel-title">
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
Zonas de Entrega (Barrios CABA)
</div>
<div class="zones-search">
<input type="text" id="zoneSearch" placeholder="Buscar barrio..." />
</div>
<div class="zones-list" id="zonesList">
${this.renderZonesList()}
</div>
${this.renderZonesSummary()}
</div>
<div class="actions">
<button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button>
<button id="resetBtn" class="secondary">Restaurar</button>
@@ -359,6 +517,73 @@ class SettingsCrud extends HTMLElement {
// Reset button
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
// Zone search
this.shadowRoot.getElementById("zoneSearch")?.addEventListener("input", (e) => {
const query = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
this.shadowRoot.querySelectorAll(".zone-row").forEach(row => {
const barrio = row.dataset.barrio;
const barrioName = CABA_BARRIOS.find(b => this.barrioToKey(b) === barrio) || "";
const normalized = barrioName.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
row.classList.toggle("hidden", query && !normalized.includes(query));
});
});
// Zone toggles
this.shadowRoot.querySelectorAll(".zone-toggle").forEach(toggle => {
toggle.addEventListener("click", () => {
const barrio = toggle.dataset.barrio;
const config = this.getZoneConfig(barrio);
if (config?.enabled) {
// Desactivar zona
this.setZoneConfig(barrio, null);
} else {
// Activar zona con días default (lun-sab)
this.setZoneConfig(barrio, {
enabled: true,
days: ["lun", "mar", "mie", "jue", "vie", "sab"],
delivery_cost: 0
});
}
this.render();
});
});
// Zone day toggles
this.shadowRoot.querySelectorAll(".zone-day").forEach(dayBtn => {
dayBtn.addEventListener("click", () => {
const barrio = dayBtn.dataset.barrio;
const day = dayBtn.dataset.day;
const config = this.getZoneConfig(barrio);
if (!config) return;
const days = config.days || [];
const idx = days.indexOf(day);
if (idx >= 0) {
days.splice(idx, 1);
} else {
days.push(day);
}
config.days = days;
this.setZoneConfig(barrio, config);
// Update UI without full re-render
dayBtn.classList.toggle("active", days.includes(day));
});
});
// Zone cost inputs
this.shadowRoot.querySelectorAll(".zone-cost-input").forEach(input => {
input.addEventListener("change", () => {
const barrio = input.dataset.barrio;
const config = this.getZoneConfig(barrio);
if (!config) return;
config.delivery_cost = parseFloat(input.value) || 0;
this.setZoneConfig(barrio, config);
});
});
}
collectScheduleFromInputs() {
@@ -386,6 +611,9 @@ class SettingsCrud extends HTMLElement {
// Collect schedule from inputs
const schedule = this.collectScheduleFromInputs();
// Collect delivery zones (already in settings from event handlers)
const delivery_zones = this.settings.delivery_zones || {};
const data = {
store_name: this.shadowRoot.getElementById("storeName")?.value || "",
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
@@ -395,6 +623,7 @@ class SettingsCrud extends HTMLElement {
pickup_enabled: this.settings.pickup_enabled,
delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
schedule,
delivery_zones,
};
// Update settings with form values

View File

@@ -46,7 +46,6 @@ class TestPanel extends HTMLElement {
this.selectedProducts = [];
this.testUser = null;
this.lastOrder = null;
this.lastPaymentLink = null;
this.loading = false;
this.shadowRoot.innerHTML = `
@@ -66,11 +65,12 @@ class TestPanel extends HTMLElement {
height: 100%;
background: var(--bg);
color: var(--text);
display: grid;
grid-template-columns: 1fr 1fr;
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
overflow: auto;
max-width: 600px;
}
.panel {
background: var(--panel);
@@ -227,50 +227,6 @@ class TestPanel extends HTMLElement {
</div>
</div>
</div>
<div class="panel">
<div class="panel-title">2. Link de Pago (MercadoPago)</div>
<div class="section">
<div class="section-title">Monto</div>
<div class="row">
<input type="number" id="inputAmount" placeholder="Monto en ARS" class="flex-1" />
<button id="btnPaymentLink" disabled>Generar Link de Pago</button>
</div>
</div>
<div class="section" id="paymentResult" style="display:none;">
<div class="result success">
<div class="result-label">Link de pago</div>
<a class="result-link" id="paymentLinkValue" href="#" target="_blank">—</a>
<div style="margin-top:8px;">
<span class="result-label">Preference ID:</span>
<span id="preferenceIdValue">—</span>
</div>
</div>
</div>
<div class="panel-title" style="margin-top:24px;">3. Simular Pago Exitoso</div>
<div class="section">
<p style="font-size:12px;color:var(--muted);margin:0;">
Simula el webhook de MercadoPago con status "approved".
Esto actualiza la orden en WooCommerce a "processing".
</p>
<button id="btnSimulateWebhook" disabled>Simular Pago Exitoso</button>
</div>
<div class="section" id="webhookResult" style="display:none;">
<div class="result success">
<div class="result-label">Pago simulado</div>
<div class="result-value" id="webhookStatusValue">—</div>
<div style="margin-top:8px;">
<span class="result-label">Orden status:</span>
<span id="webhookOrderStatusValue">—</span>
</div>
</div>
</div>
</div>
</div>
`;
}
@@ -279,8 +235,6 @@ class TestPanel extends HTMLElement {
this.shadowRoot.getElementById("btnGenerate").onclick = () => this.generateRandomOrder();
this.shadowRoot.getElementById("btnClear").onclick = () => this.clearAll();
this.shadowRoot.getElementById("btnCreateOrder").onclick = () => this.createOrder();
this.shadowRoot.getElementById("btnPaymentLink").onclick = () => this.createPaymentLink();
this.shadowRoot.getElementById("btnSimulateWebhook").onclick = () => this.simulateWebhook();
this.loadProducts();
}
@@ -379,16 +333,7 @@ class TestPanel extends HTMLElement {
updateButtonStates() {
const hasProducts = this.selectedProducts.length > 0;
const hasOrder = this.lastOrder?.woo_order_id;
const hasPaymentLink = this.lastPaymentLink?.init_point;
this.shadowRoot.getElementById("btnCreateOrder").disabled = !hasProducts;
this.shadowRoot.getElementById("btnPaymentLink").disabled = !hasOrder;
this.shadowRoot.getElementById("btnSimulateWebhook").disabled = !hasOrder;
if (hasOrder) {
this.shadowRoot.getElementById("inputAmount").value = this.lastOrder.total || "";
}
}
async createOrder() {
@@ -433,101 +378,16 @@ class TestPanel extends HTMLElement {
}
}
async createPaymentLink() {
if (this.loading) return;
if (!this.lastOrder?.woo_order_id) {
modal.warn("Primero creá una orden");
return;
}
const amount = parseFloat(this.shadowRoot.getElementById("inputAmount").value);
if (!amount || amount <= 0) {
modal.warn("Ingresá un monto válido");
return;
}
this.loading = true;
const btn = this.shadowRoot.getElementById("btnPaymentLink");
btn.disabled = true;
btn.textContent = "Generando...";
try {
const result = await api.createPaymentLink({
woo_order_id: this.lastOrder.woo_order_id,
amount,
});
if (result.ok) {
this.lastPaymentLink = result;
const linkEl = this.shadowRoot.getElementById("paymentLinkValue");
linkEl.href = result.init_point || result.sandbox_init_point || "#";
linkEl.textContent = result.init_point || result.sandbox_init_point || "—";
this.shadowRoot.getElementById("preferenceIdValue").textContent = result.preference_id || "—";
this.shadowRoot.getElementById("paymentResult").style.display = "block";
} else {
modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] createPaymentLink error:", e);
modal.error("Error generando link: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Generar Link de Pago";
this.updateButtonStates();
}
}
async simulateWebhook() {
if (this.loading) return;
if (!this.lastOrder?.woo_order_id) {
modal.warn("Primero creá una orden");
return;
}
this.loading = true;
const btn = this.shadowRoot.getElementById("btnSimulateWebhook");
btn.disabled = true;
btn.textContent = "Simulando...";
try {
const amount = parseFloat(this.shadowRoot.getElementById("inputAmount").value) || this.lastOrder.total || 0;
const result = await api.simulateMpWebhook({
woo_order_id: this.lastOrder.woo_order_id,
amount,
});
if (result.ok) {
this.shadowRoot.getElementById("webhookStatusValue").textContent = `Payment ${result.payment_id} - ${result.status}`;
this.shadowRoot.getElementById("webhookOrderStatusValue").textContent = result.order_status || "processing";
this.shadowRoot.getElementById("webhookResult").style.display = "block";
} else {
modal.error("Error: " + (result.error || "Error desconocido"));
}
} catch (e) {
console.error("[test-panel] simulateWebhook error:", e);
modal.error("Error simulando webhook: " + e.message);
} finally {
this.loading = false;
btn.textContent = "Simular Pago Exitoso";
this.updateButtonStates();
}
}
clearAll() {
this.selectedProducts = [];
this.testUser = null;
this.lastOrder = null;
this.lastPaymentLink = null;
this.renderProductList();
this.renderUserInfo();
this.updateButtonStates();
this.shadowRoot.getElementById("orderResult").style.display = "none";
this.shadowRoot.getElementById("paymentResult").style.display = "none";
this.shadowRoot.getElementById("webhookResult").style.display = "none";
this.shadowRoot.getElementById("inputAmount").value = "";
}
}

View File

@@ -214,22 +214,6 @@ export const api = {
}).then(r => r.json());
},
async createPaymentLink({ woo_order_id, amount }) {
return fetch("/test/payment-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ woo_order_id, amount }),
}).then(r => r.json());
},
async simulateMpWebhook({ woo_order_id, amount }) {
return fetch("/test/simulate-webhook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ woo_order_id, amount }),
}).then(r => r.json());
},
// --- Prompts CRUD ---
async prompts() {
return fetch("/prompts").then(r => r.json());

View File

@@ -70,7 +70,6 @@ function upsertConversation(chat_id, patch) {
* - call LLM (structured output)
* - product search (LIMITED) + resolve ids
* - create/update Woo order
* - create MercadoPago link
* - save state
*/
async function processMessage({ chat_id, from, text }) {