Estado inicial del mockup

This commit is contained in:
urieljareth
2026-09-27 19:17:33 -06:00
commit dc6e31a9d7
15 changed files with 6590 additions and 0 deletions
+1758
View File
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
/* WAM · interactivity.js — dueño D (núcleo).
Flujos de clic (quick replies, filas de lista, botones de plantilla y de card),
composer libre, indicador "escribiendo…", ticks automáticos y modo
"reproducir demo" paso a paso. No toca el DOM al cargar. */
window.WAM = window.WAM || {};
(function () {
"use strict";
var CANCELADO = "wam:cancelado";
var TIPOS_VALIDOS = [
"text", "image", "video", "audio", "document", "sticker", "location",
"contact", "template", "buttons", "list", "cta_url", "carousel"
];
/* Todo temporizador pasa por WAM.espera(); cancelarTodo() aborta los flujos
y la reproducción de la demo en curso. */
var cancelaciones = [];
var demoAndando = false;
function quitar(fn) {
var i = cancelaciones.indexOf(fn);
if (i !== -1) cancelaciones.splice(i, 1);
}
function cancelarTodo() {
var copia = cancelaciones.slice();
cancelaciones = [];
for (var i = 0; i < copia.length; i++) copia[i]();
}
function escribiendo(visible) {
if (typeof WAM.setEscribiendo === "function") WAM.setEscribiendo(visible);
}
/* Promise cancelable: se rechaza con CANCELADO si detenerDemo() la aborta. */
WAM.espera = function (ms) {
return new Promise(function (resolver, rechazar) {
var id = setTimeout(function () {
quitar(cancelar);
resolver();
}, ms);
function cancelar() {
clearTimeout(id);
quitar(cancelar);
rechazar(CANCELADO);
}
cancelaciones.push(cancelar);
});
};
WAM.demoActiva = function () { return demoAndando; };
function avisarDemo() {
if (typeof WAM.onDemoChange === "function") {
try { WAM.onDemoChange(demoAndando); } catch (e) { /* el hook es opcional */ }
}
}
/* ————————————————— alta de mensajes (mutación + guardar + DOM incremental) ————————————————— */
function anadir(msg) {
WAM.estado.mensajes.push(msg);
WAM.guardar();
if (typeof WAM.anadirMensajeDOM === "function") WAM.anadirMensajeDOM(msg);
}
function mensajeOut(texto) {
return {
id: WAM.generarId(),
dir: "out",
tipo: "text",
hora: WAM.horaAhora(),
estado: "sent",
texto: String(texto == null ? "" : texto)
};
}
/* Copia una respuesta anidada (Mensaje completo) para poder reutilizarla
cada vez que se vuelve a pulsar el mismo botón. */
function mensajeDesde(origen) {
var copia;
try {
copia = JSON.parse(JSON.stringify(
(origen && typeof origen === "object") ? origen : { tipo: "text", texto: String(origen == null ? "" : origen) }
));
} catch (e) {
copia = { tipo: "text", texto: "" };
}
if (!copia || typeof copia !== "object" || Array.isArray(copia)) copia = { tipo: "text", texto: "" };
if (TIPOS_VALIDOS.indexOf(copia.tipo) === -1) copia.tipo = "text";
copia.id = WAM.generarId();
copia.dir = "in";
copia.hora = WAM.horaAhora();
copia.estado = "sent";
return copia;
}
/* Ticks automáticos sent → delivered → read sobre la burbuja OUT indicada.
Se localiza por data-id del msg (no "la última fila") para que dos envíos
solapados avancen cada uno su propio tick. */
function repintarTickDe(msg, estado) {
var cuerpo = document.getElementById("chat-cuerpo");
if (!cuerpo || typeof WAM.tick !== "function") return;
if (!msg || typeof msg.id !== "string" || !msg.id) return;
var seguro = msg.id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
var fila = cuerpo.querySelector('.wa-row[data-id="' + seguro + '"]');
if (!fila || !fila.classList.contains("out")) return;
var tick = fila.querySelector(".tick");
if (tick && tick.parentNode) {
/* WAM.tick devuelve un <svg> completo: se REEMPLAZA el nodo para no
anidar un svg dentro de otro (se recortaría el segundo tick). */
var aux = document.createElement("span");
aux.innerHTML = WAM.tick(estado);
if (aux.firstChild) tick.parentNode.replaceChild(aux.firstChild, tick);
}
}
async function animarTics(msg) {
await WAM.espera(800);
msg.estado = "delivered";
WAM.guardar();
repintarTickDe(msg, "delivered");
await WAM.espera(800);
msg.estado = "read";
WAM.guardar();
repintarTickDe(msg, "read");
}
/* ————————————————— flujos ————————————————— */
/* Burbuja OUT con el texto (ticks auto), typing ~900 ms y, por cada respuesta,
typing ~900 ms + anadirMensajeDOM (con scroll). Cancelable por detenerDemo(). */
WAM.flujo = function (texto, respuestas) {
var lista = Array.isArray(respuestas) ? respuestas : [];
var secuencia = (async function () {
try {
var out = mensajeOut(texto);
anadir(out);
await animarTics(out);
for (var i = 0; i < lista.length; i++) {
escribiendo(true);
await WAM.espera(900);
escribiendo(false);
anadir(mensajeDesde(lista[i]));
}
escribiendo(false);
} catch (e) {
escribiendo(false);
if (e !== CANCELADO) { /* el mockup nunca ensucia la consola */ }
}
})();
return secuencia;
};
/* Envío libre del composer: burbuja OUT con hora actual y ticks automáticos. */
WAM.enviarLibre = function (texto) {
var limpio = String(texto == null ? "" : texto).trim();
if (!limpio) return;
var out = mensajeOut(limpio);
anadir(out);
var tics = animarTics(out);
if (typeof tics.catch === "function") tics.catch(function () { /* cancelado */ });
};
/* Resuelve { texto, respuestas } desde los data-* que estampa render.js:
data-flujo (índice del mensaje) + data-btn (template/buttons),
data-sec + data-fila (lista) o data-card (carousel). */
WAM.resolverFlujo = function (el) {
var vacio = { texto: "", respuestas: [] };
if (!el || typeof el.getAttribute !== "function") return vacio;
var indice = parseInt(el.getAttribute("data-flujo"), 10);
var mensajes = (WAM.estado && WAM.estado.mensajes) ? WAM.estado.mensajes : [];
var msg = isNaN(indice) ? null : mensajes[indice];
if (!msg) return vacio;
var ds = el.dataset || {};
if (ds.sec !== undefined || ds.fila !== undefined) {
var seccion = (msg.secciones || [])[parseInt(ds.sec, 10)];
var fila = ((seccion && seccion.filas) || [])[parseInt(ds.fila, 10)];
return fila ? { texto: fila.titulo || "", respuestas: fila.respuestas || [] } : vacio;
}
if (ds.btn !== undefined) {
var boton = (msg.botones || [])[parseInt(ds.btn, 10)];
return boton ? { texto: boton.texto || "", respuestas: boton.respuestas || [] } : vacio;
}
if (ds.card !== undefined) {
var card = (msg.cards || [])[parseInt(ds.card, 10)];
return card ? { texto: card.textoBoton || "", respuestas: card.respuestas || [] } : vacio;
}
/* Elemento plano de un mensaje list: es el botón que abre la sheet
(main.js lo detecta y llama WAM.abrirSheet); sin flujo directo. */
return vacio;
};
/* ————————————————— reproducción de la demo ————————————————— */
WAM.reproducirDemo = function () {
if (demoAndando) return;
demoAndando = true;
avisarDemo();
var cuerpo = document.getElementById("chat-cuerpo");
if (cuerpo) cuerpo.innerHTML = "";
var secuencia = ((WAM.estado && WAM.estado.mensajes) || []).slice();
(async function () {
try {
for (var i = 0; i < secuencia.length; i++) {
var msg = secuencia[i];
if (!msg) continue;
if (msg.dir === "in") {
escribiendo(true);
await WAM.espera(700);
escribiendo(false);
}
if (typeof WAM.anadirMensajeDOM === "function") WAM.anadirMensajeDOM(msg);
await WAM.espera(600);
}
demoAndando = false;
escribiendo(false);
avisarDemo();
} catch (e) {
demoAndando = false;
escribiendo(false);
avisarDemo();
}
})();
};
WAM.detenerDemo = function () {
var estaba = demoAndando;
demoAndando = false;
cancelarTodo(); /* aborta también flujos y typing en curso */
escribiendo(false);
if (estaba && typeof WAM.render === "function") WAM.render();
if (estaba) avisarDemo();
};
})();
+161
View File
@@ -0,0 +1,161 @@
/* WAM · main.js — dueño D (núcleo).
Bootstrap: carga el estado, aplica tema, renderiza chat y editor,
y enlaza la delegación de clics del chat, el composer, la reproducción
de la demo y la toolbar. Único script con ejecución al cargar. */
window.WAM = window.WAM || {};
(function () {
"use strict";
function porId(id) {
return document.getElementById(id);
}
function al(id, evento, manejador) {
var el = porId(id);
if (el) el.addEventListener(evento, manejador);
}
/* ————————————————— botón "Reproducir demo" ————————————————— */
function pintarBotonDemo() {
var btn = porId("btn-reproducir");
if (!btn) return;
var span = btn.querySelector("span");
if (!span) return;
if (!span.dataset.etiqueta) span.dataset.etiqueta = span.textContent;
span.textContent = WAM.demoActiva() ? "■ Detener" : span.dataset.etiqueta;
}
function alReproducir() {
if (WAM.demoActiva()) WAM.detenerDemo();
else WAM.reproducirDemo();
pintarBotonDemo();
}
/* ————————————————— toolbar ————————————————— */
function alTema() {
WAM.estado.tema = WAM.estado.tema === "oscuro" ? "claro" : "oscuro";
WAM.guardar();
WAM.setTema(WAM.estado.tema);
}
function alPresentacion() {
document.body.classList.toggle("presentacion");
var btn = porId("btn-presentacion");
if (btn) {
btn.setAttribute("aria-pressed",
document.body.classList.contains("presentacion") ? "true" : "false");
}
}
function reRenderTotal() {
WAM.setTema(WAM.estado.tema);
WAM.render();
WAM.renderEditor();
pintarBotonDemo();
}
function alExportar() {
if (typeof WAM.exportarJSON === "function") WAM.exportarJSON();
}
function alImportar() {
var archivo = porId("importar-archivo");
if (archivo) archivo.click();
}
function alArchivoImport(e) {
var input = e.currentTarget;
var archivo = input.files && input.files[0];
input.value = ""; /* permite reimportar el mismo archivo */
if (!archivo || typeof WAM.importarJSON !== "function") return;
WAM.detenerDemo();
var promesa = WAM.importarJSON(archivo);
if (promesa && typeof promesa.then === "function") {
promesa.then(reRenderTotal, reRenderTotal);
} else {
reRenderTotal();
}
}
function alReset() {
WAM.detenerDemo();
WAM.resetear();
reRenderTotal();
}
/* ————————————————— chat: delegación de clics (contrato B→D) ————————————————— */
function alClicChat(e) {
var objetivo = e.target;
var el = (objetivo && objetivo.closest) ? objetivo.closest("[data-flujo]") : null;
if (!el) return;
e.preventDefault(); /* protege frente a botones montados sobre <a href="#"> */
var ds = el.dataset || {};
if (ds.fila !== undefined) return; /* filas del sheet: se atienden en #pantalla */
var indice = parseInt(el.getAttribute("data-flujo"), 10);
var msg = (!isNaN(indice) && WAM.estado && WAM.estado.mensajes) ? WAM.estado.mensajes[indice] : null;
var plano = ds.btn === undefined && ds.sec === undefined &&
ds.fila === undefined && ds.card === undefined;
if (plano && msg && msg.tipo === "list") {
if (typeof WAM.abrirSheet === "function") WAM.abrirSheet(indice);
return;
}
var flujo = WAM.resolverFlujo(el);
if (flujo && flujo.texto) WAM.flujo(flujo.texto, flujo.respuestas);
}
/* Opciones del sheet de lista (vive en #pantalla, fuera de #chat-cuerpo). */
function alClicPantalla(e) {
var objetivo = e.target;
var fila = (objetivo && objetivo.closest) ? objetivo.closest("[data-fila]") : null;
if (!fila) return;
var hoja = porId("sheet");
if (!hoja || !hoja.contains(fila)) return;
e.preventDefault();
var flujo = WAM.resolverFlujo(fila);
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet();
if (flujo && flujo.texto) WAM.flujo(flujo.texto, flujo.respuestas);
}
/* ————————————————— composer ————————————————— */
function alComposer(e) {
e.preventDefault();
var input = porId("composer-input");
var texto = input ? input.value : "";
WAM.enviarLibre(texto);
if (input) {
input.value = "";
input.focus();
}
}
/* ————————————————— bootstrap ————————————————— */
WAM.init = function () {
WAM.estado = WAM.cargar();
WAM.setTema(WAM.estado.tema);
WAM.render();
WAM.renderEditor();
WAM.onDemoChange = pintarBotonDemo;
al("chat-cuerpo", "click", alClicChat);
al("pantalla", "click", alClicPantalla);
al("composer", "submit", alComposer);
al("btn-reproducir", "click", alReproducir);
al("btn-tema", "click", alTema);
al("btn-presentacion", "click", alPresentacion);
al("btn-exportar", "click", alExportar);
al("btn-importar", "click", alImportar);
al("importar-archivo", "change", alArchivoImport);
al("btn-reset", "click", alReset);
pintarBotonDemo();
};
WAM.init();
})();
+699
View File
@@ -0,0 +1,699 @@
/* ==========================================================================
js/render.js — dueño B: pinta el chat de WhatsApp dentro del iPhone.
API (SPEC §2): WAM.render / renderHeader / renderMensaje / anadirMensajeDOM /
setTema / setEscribiendo / scrollAbajo / abrirSheet / cerrarSheet / tick.
Campos por tipo: SPEC §1. Colores, geometría y tipografía: docs/research-ui.md.
Cero listeners de flujo (los clics los resuelve js/interactivity.js con los
data-* estampados aquí; main.js abre el sheet del list con el data-flujo
"plano" del botón). Listeners propios: cierre en #sheet-fondo (elemento
creado por B), scroll de puntos del carousel y despliegue del plegado
">3 botones". No toca DOM al cargar.
========================================================================== */
(function () {
"use strict";
window.WAM = window.WAM || {};
/* ------------------------------ helpers ------------------------------ */
function byId(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function vacio(s) { return s == null || String(s) === ""; }
function estado() { return WAM.estado || { cuenta: {}, mensajes: [] }; }
var ESCRIBIENDO = false;
/* Placeholder local; en la práctica usa WAM.svgPlaceholder (js/store.js). */
function placeholder(texto) {
if (typeof WAM.svgPlaceholder === "function") {
try {
var u = WAM.svgPlaceholder(String(texto == null ? "" : texto));
if (u) return u;
} catch (e) { /* fallback abajo */ }
}
var t = esc(String(texto == null ? "" : texto).slice(0, 42));
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240">' +
'<rect width="320" height="240" fill="#e7e3da"/>' +
'<rect x="122" y="70" width="76" height="60" rx="9" fill="none" stroke="#b9b2a4" stroke-width="4"/>' +
'<circle cx="143" cy="89" r="6" fill="#b9b2a4"/>' +
'<path d="M126 122 L148 104 L163 117 L176 106 L194 121" fill="none" stroke="#b9b2a4" stroke-width="4" stroke-linejoin="round"/>' +
'<text x="160" y="176" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" fill="#9a927f">' + t + "</text></svg>";
return "data:image/svg+xml," + encodeURIComponent(svg);
}
/* ------------------------------ iconos SVG inline ------------------------------ */
function svgChevronAtras() {
return '<svg width="12" height="20" viewBox="0 0 12 20" aria-hidden="true"><path d="M10.3 1.5 2 10l8.3 8.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgChevronDer(px) {
var s = px || 14;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 12 20" aria-hidden="true"><path d="M1.7 1.5 10 10l-8.3 8.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgVideoLlamada() {
return '<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><path d="M3.6 6.2h10.6a1.9 1.9 0 0 1 1.9 1.9v7.8a1.9 1.9 0 0 1-1.9 1.9H3.6a1.9 1.9 0 0 1-1.9-1.9V8.1a1.9 1.9 0 0 1 1.9-1.9Zm13.4 4.1 4.4-3a.55.55 0 0 1 .9.5v8.4a.55.55 0 0 1-.9.5l-4.4-3Z" fill="currentColor"/></svg>';
}
function svgLlamada(px) {
var s = px || 23;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3.4c.6-.6 1.5-.5 2 .1l2.1 2.6c.4.6.4 1.4-.1 1.9l-1.2 1.4c.9 1.8 2.5 3.4 4.3 4.3l1.4-1.2c.5-.5 1.3-.5 1.9-.1l2.6 2.1c.6.5.7 1.4.1 2l-1.6 1.7c-.6.6-1.5.9-2.4.6-5.2-1.7-9.3-5.8-11-11-.3-.9 0-1.8.6-2.4Z" fill="currentColor"/></svg>';
}
function svgVerificado() {
return '<svg width="17" height="17" viewBox="0 0 16 16" aria-hidden="true">' +
'<polygon points="16,8 14.1,10.53 13.66,13.66 10.53,14.1 8,16 5.47,14.1 2.34,13.66 1.9,10.53 0,8 1.9,5.47 2.34,2.34 5.47,1.9 8,0 10.53,1.9 13.66,2.34 14.1,5.47" fill="#53bdeb"/>' +
'<path d="M4.6 8.3 7 10.6 11.4 5.7" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgPlay(px) {
var s = px || 18;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M8.2 5.4v13.2L19.4 12Z" fill="currentColor"/></svg>';
}
function svgMicrofono(px) {
var s = px || 19;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 14.6a3.4 3.4 0 0 0 3.4-3.4V5.9a3.4 3.4 0 0 0-6.8 0v5.3a3.4 3.4 0 0 0 3.4 3.4Zm-5.8-3.4a5.8 5.8 0 0 0 11.6 0M12 17v3.2M8.8 20.2h6.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgMusica(px) {
var s = px || 19;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.2 18.3V5.6L18.4 3.5v12.7" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6.7" cy="18.4" r="2.5" fill="currentColor"/><circle cx="15.9" cy="16.3" r="2.5" fill="currentColor"/></svg>';
}
function svgDescarga(px) {
var s = px || 15;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4.2v10.6M7.6 10.6l4.4 4.4 4.4-4.4M5 19.6h14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgEnlace(px) {
var s = px || 18;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.9 14.1a4.1 4.1 0 0 1 0-5.8l3-3a4.1 4.1 0 0 1 5.8 5.8l-1.7 1.7M14.1 9.9a4.1 4.1 0 0 1 0 5.8l-3 3a4.1 4.1 0 0 1-5.8-5.8l1.7-1.7" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
}
function svgCorreo(px) {
var s = px || 15;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5.5" width="18" height="13" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M4 7l8 6 8-6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgEmpresa(px) {
var s = px || 15;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><rect x="3.2" y="7.6" width="17.6" height="12.4" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M9 7.6V6.2A2.2 2.2 0 0 1 11.2 4h1.6A2.2 2.2 0 0 1 15 6.2v1.4M3.2 12.4h17.6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
}
/* Icono de página coloreado por extensión (research-ui §6.6) */
var COLORES_DOC = {
pdf: "#e2574c", doc: "#4577e0", docx: "#4577e0",
xls: "#2f9e5b", xlsx: "#2f9e5b", csv: "#2f9e5b",
ppt: "#e5813d", pptx: "#e5813d",
txt: "#8592a0", zip: "#a5855e", rar: "#a5855e"
};
function extDe(nombre) {
var n = String(nombre == null ? "" : nombre);
var partes = n.split(".");
return partes.length > 1 ? partes.pop().toLowerCase() : "doc";
}
function docIcon(ext, px) {
var w = px || 30;
var h = Math.round(w * 36 / 30);
var color = COLORES_DOC[ext] || "#7c8894";
var etiqueta = ext.length <= 4 ? ext.toUpperCase() : "DOC";
return '<svg width="' + w + '" height="' + h + '" viewBox="0 0 30 36" aria-hidden="true">' +
'<path d="M3 4a3 3 0 0 1 3-3h14l7 7v24a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3Z" fill="' + color + '"/>' +
'<path d="M20 1l7 7h-6a1 1 0 0 1-1-1Z" fill="rgba(255,255,255,.4)"/>' +
'<text x="15" y="24.5" text-anchor="middle" font-family="Arial, sans-serif" font-size="7.5" font-weight="700" fill="#fff">' + esc(etiqueta) + "</text></svg>";
}
/* Mapa estático con pin rojo (research-ui §6.8, data-URI sin red) */
function svgMapa() {
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="190" viewBox="0 0 320 190">' +
'<rect width="320" height="190" fill="#e8e4da"/>' +
'<path d="M0 0h90L60 60H0Z" fill="#ded9cb"/>' +
'<path d="M230 190v-70l90-24v94Z" fill="#ded9cb"/>' +
'<path d="M115 0h75l-12 62h-70Z" fill="#e0dcd1"/>' +
'<path d="M0 150q80-26 160-6t160-14v60H0Z" fill="#cfe0e8"/>' +
'<path d="M118 0q10 60-8 120t-30 70" stroke="#fff" stroke-width="11" fill="none"/>' +
'<path d="M0 84q90 18 170 2t150-30" stroke="#fff" stroke-width="11" fill="none"/>' +
'<path d="M205 0q-6 50 12 95t40 95" stroke="#fff" stroke-width="7" fill="none"/>' +
'<path d="M40 40q60 24 120 12" stroke="#fff" stroke-width="5" fill="none"/>' +
'<path d="M252 40q40 10 68 6" stroke="#fff" stroke-width="5" fill="none"/>' +
'<ellipse cx="160" cy="103" rx="12" ry="4" fill="rgba(0,0,0,.18)"/>' +
'<path d="M160 34c-14.4 0-24 10-24 22.6C136 72 160 97 160 97s24-25 24-40.4C184 44 174.4 34 160 34Z" fill="#e14b4b"/>' +
'<circle cx="160" cy="56" r="8" fill="#fff"/>' +
"</svg>";
return "data:image/svg+xml," + encodeURIComponent(svg);
}
/* ------------------------------ texto enriquecido ------------------------------ */
/* Formato WhatsApp (research-api §2): *negrita* _cursiva_ ~tachado~ ```mono```
+ URLs hipervinculadas (color --wa-link, sin subrayado). */
function formatear(txt) {
var partes = String(txt == null ? "" : txt).split(/(https?:\/\/[^\s<>"]+)/g);
var html = "";
for (var i = 0; i < partes.length; i++) {
var p = partes[i];
if (p === "") continue;
if (/^https?:\/\//.test(p)) {
html += '<a class="wa-link" href="' + esc(p) + '" target="_blank" rel="noopener noreferrer">' + esc(p) + "</a>";
} else {
var t = esc(p);
t = t.replace(/```([\s\S]+?)```/g, "<code>$1</code>");
t = t.replace(/\*([^*\n]+)\*/g, "<strong>$1</strong>");
t = t.replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/g, "$1<em>$2</em>");
t = t.replace(/(^|[^\w])~([^~\n]+)~(?=[^\w]|$)/g, "$1<del>$2</del>");
html += t;
}
}
return html;
}
/* ------------------------------ ticks y meta ------------------------------ */
/* ✓ enviado (gris) / ✓✓ entregado (gris) / ✓✓ leído (celeste #53bdeb) */
var TRAZO_TICK = '<path d="M1 5.4 4.1 8.5 10.4 1.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>';
WAM.tick = function (estadoTick) {
var uno = '<svg class="tick" width="12" height="10" viewBox="0 0 11 10" aria-hidden="true">' + TRAZO_TICK + "</svg>";
var dos = '<svg class="tick" width="17" height="10" viewBox="0 0 16 10" aria-hidden="true">' + TRAZO_TICK +
'<path d="M6 5.4 9.1 8.5 15.4 1.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
if (estadoTick === "read") return dos.replace('class="tick"', 'class="tick tick-read"');
if (estadoTick === "delivered") return dos;
return uno;
};
/* Hora (+ ticks solo en OUT) dentro de la burbuja */
function metaHTML(m, extra) {
var mm = m || {};
var html = '<span class="meta' + (extra ? " " + extra : "") + '">' + esc(vacio(mm.hora) ? "" : mm.hora);
if (mm.dir === "out") html += WAM.tick(mm.estado || "sent");
return html + "</span>";
}
/* ------------------------------ piezas de burbujas ------------------------------ */
function insigniaPlay(px) {
return '<span class="wa-play-badge' + (px && px <= 38 ? " wa-play-sm" : "") + '">' + svgPlay(px || 26) + "</span>";
}
function burbujaTexto(m) {
return '<div class="bubble"><div class="wa-texto">' + formatear(m.texto) + metaHTML(m) + "</div></div>";
}
/* image / video: media full-bleed; caption debajo; hora encima o en caption.
El placeholder SVG (data:image/svg+xml) solo lo decodifican los <img>: un
<video> con ese src lanza MEDIA_ELEMENT_ERROR, así que el vídeo sin src
degrada al placeholder como imagen (SPEC §5.10). */
function burbujaMedia(m, tipo) {
var tieneCaption = !vacio(m.caption);
var esVideo = tipo === "video";
var sinSrc = vacio(m.src);
var src = sinSrc ? placeholder(esVideo ? "video" : (m.caption || "imagen")) : m.src;
var media;
if (esVideo) {
var nodo = sinSrc
? '<img class="wa-media" src="' + esc(src) + '" alt="" draggable="false">'
: '<video class="wa-media" src="' + esc(src) + '" muted playsinline preload="metadata"></video>';
media = '<div class="wa-media-wrap">' + nodo + insigniaPlay(52) + "</div>";
} else {
media = '<img class="wa-media" src="' + esc(src) + '" alt="" draggable="false">';
}
var html = '<div class="bubble wa-bubble-media' + (tieneCaption ? " wa-con-caption" : "") + '">' + media;
if (tieneCaption) {
html += '<div class="wa-caption">' + formatear(m.caption) + metaHTML(m) + "</div>";
} else {
html += '<div class="wa-gradiente"></div>' + metaHTML(m, "meta-en-media");
}
return html + "</div>";
}
/* Waveform determinista a partir del id del mensaje */
function waveform(m) {
var n = 26, paso = 4.6, alto = 18;
var semilla = 0, id = String((m && m.id) || "wam");
for (var c = 0; c < id.length; c++) semilla = (semilla + id.charCodeAt(c) * (c + 7)) % 9973;
var barras = "";
for (var i = 0; i < n; i++) {
var h = 4 + Math.abs(Math.sin(semilla * 0.37 + i * 1.7)) * (alto - 5);
var y = (alto - h) / 2;
barras += '<rect x="' + (i * paso).toFixed(1) + '" y="' + y.toFixed(1) + '" width="2" height="' + h.toFixed(1) + '" rx="1"/>';
}
var w = (n * paso - 2.6).toFixed(1);
return '<svg class="audio-wave" width="' + w + '" height="' + alto + '" viewBox="0 0 ' + w + " " + alto + '" fill="currentColor" aria-hidden="true">' + barras + "</svg>";
}
function burbujaAudio(m) {
var voz = !!m.voz;
var dur = vacio(m.duracion) ? "" : '<span class="audio-dur">' + esc(m.duracion) + "</span>";
return '<div class="bubble wa-audio"><div class="audio-line">' +
'<span class="audio-play">' + svgPlay(15) + "</span>" +
waveform(m) + dur +
'<span class="audio-icono' + (voz ? " wa-voz" : "") + '">' + (voz ? svgMicrofono() : svgMusica()) + "</span>" +
"</div>" + metaHTML(m) + "</div>";
}
function burbujaDocumento(m) {
var nombre = vacio(m.filename) ? "documento.pdf" : String(m.filename);
var ext = extDe(nombre);
var html = '<div class="bubble wa-doc"><div class="doc-line">' +
'<span class="doc-icono">' + docIcon(ext, 30) + "</span>" +
'<span class="doc-datos"><span class="doc-nombre">' + esc(nombre) + "</span>" +
'<span class="doc-sub">' + esc(ext.toUpperCase()) + " · Documento</span></span>" +
'<span class="doc-descarga">' + svgDescarga() + "</span>" +
"</div>";
if (!vacio(m.caption)) html += '<div class="wa-caption">' + formatear(m.caption) + metaHTML(m) + "</div>";
else html += metaHTML(m);
return html + "</div>";
}
function stickerHTML(m) {
var src = vacio(m.src) ? placeholder("sticker") : m.src;
return '<div class="sticker-box" data-hora="' + esc(vacio(m.hora) ? "" : m.hora) + '">' +
'<img class="sticker-img" src="' + esc(src) + '" alt="Sticker" draggable="false"></div>';
}
function burbujaUbicacion(m) {
var tieneDatos = !vacio(m.nombre) || !vacio(m.direccion);
var html = '<div class="bubble wa-loc"><img class="loc-mapa" src="' + svgMapa() + '" alt="Ubicación" draggable="false">';
if (tieneDatos) {
html += '<div class="loc-datos">' +
(vacio(m.nombre) ? "" : '<div class="loc-nombre">' + esc(m.nombre) + "</div>") +
(vacio(m.direccion) ? "" : '<div class="loc-dir">' + esc(m.direccion) + "</div>") +
metaHTML(m) + "</div>";
} else {
html += '<div class="wa-gradiente"></div>' + metaHTML(m, "meta-en-media");
}
return html + "</div>";
}
function inicialesDe(nombre) {
var partes = String(nombre == null ? "" : nombre).trim().split(/\s+/);
var s = "";
for (var i = 0; i < partes.length && s.length < 2; i++) {
if (partes[i]) s += partes[i].charAt(0).toUpperCase();
}
return s || "?";
}
function colorDe(nombre) {
var paleta = ["#7f8ea8", "#8a7fa8", "#a87f96", "#7fa88f", "#a89a7f", "#7f9aa8", "#a8847f", "#6f8ba4"];
var h = 0, s = String(nombre == null ? "" : nombre);
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 9973;
return paleta[h % paleta.length];
}
function burbujaContacto(m) {
var nombre = vacio(m.nombre) ? "Contacto" : String(m.nombre);
var filas = "";
if (!vacio(m.telefono)) filas += '<div class="contact-fila">' + svgLlamada(15) + "<span>" + esc(m.telefono) + "</span></div>";
if (!vacio(m.email)) filas += '<div class="contact-fila">' + svgCorreo() + "<span>" + esc(m.email) + "</span></div>";
if (!vacio(m.empresa)) filas += '<div class="contact-fila">' + svgEmpresa() + "<span>" + esc(m.empresa) + "</span></div>";
return '<div class="bubble wa-contact">' +
'<div class="contact-cab">' +
'<span class="contact-avatar" style="background:' + colorDe(nombre) + '">' + esc(inicialesDe(nombre)) + "</span>" +
'<span class="contact-nombre">' + esc(nombre) + "</span>" +
'<span class="contact-chev">' + svgChevronDer(14) + "</span>" +
"</div>" +
(filas ? '<div class="contact-filas">' + filas + "</div>" : "") +
metaHTML(m) +
"</div>";
}
/* Botones apilados (template / quick replies). Con url → <a>; sin url → flujo.
Con >3 botones (plantillas) el cliente real muestra 2 y un plegado
"See all options" (research-api §7.2 / research-ui §6.4): se pintan los
2 primeros + "Ver todas las opciones", que despliega el resto. */
function tarjetaBotones(botones, i, clase) {
var bs = Array.isArray(botones) ? botones : [];
var total = Math.min(bs.length, 10);
var plegar = total > 3;
var html = "", ocultos = "";
for (var j = 0; j < total; j++) {
var b = bs[j] || {};
var etiqueta = vacio(b.texto) ? "Botón " + (j + 1) : String(b.texto);
var nodo;
if (!vacio(b.url)) {
nodo = '<a class="qr-btn" href="' + esc(b.url) + '" target="_blank" rel="noopener noreferrer">' + esc(etiqueta) + "</a>";
} else {
nodo = '<span class="qr-btn" role="button" tabindex="0" data-flujo="' + i + '" data-btn="' + j + '">' + esc(etiqueta) + "</span>";
}
if (plegar && j < 2) html += nodo;
else if (plegar) ocultos += nodo.replace(/^<(a|span) /, "<$1 hidden ");
else html += nodo;
}
if (!html && !ocultos) return "";
if (ocultos) {
html += '<span class="qr-btn" role="button" tabindex="0" data-ver-todas="1">Ver todas las opciones (' + (total - 2) + ")</span>" + ocultos;
}
return '<div class="qr-btns' + (clase ? " " + clase : "") + '">' + html + "</div>";
}
/* Despliegue del plegado ">3 botones" (listener propio de B, solo presentación) */
function activarPlegado(li) {
var toggle = li.querySelector("[data-ver-todas]");
if (!toggle) return;
toggle.addEventListener("click", function () {
var ocultos = li.querySelectorAll(".qr-btn[hidden]");
for (var k = 0; k < ocultos.length; k++) ocultos[k].removeAttribute("hidden");
if (toggle.parentNode) toggle.parentNode.removeChild(toggle);
});
}
/* Header compartido por template y buttons (research-api §6.1/§7.2):
imagen / vídeo / documento / texto. Vídeo sin headerMedia → placeholder
como <img> (un <video> no decodifica el SVG; ver burbujaMedia). */
function headerHTML(m) {
var ht = m.headerTipo || "";
if (ht === "imagen") {
var src = vacio(m.headerMedia) ? placeholder(vacio(m.headerTexto) ? "imagen" : m.headerTexto) : m.headerMedia;
return '<img class="tpl-media" src="' + esc(src) + '" alt="" draggable="false">';
}
if (ht === "video") {
var sinVideo = vacio(m.headerMedia);
var srcv = sinVideo ? placeholder("video") : m.headerMedia;
var nodo = sinVideo
? '<img class="tpl-media" src="' + esc(srcv) + '" alt="" draggable="false">'
: '<video class="tpl-media" src="' + esc(srcv) + '" muted playsinline preload="metadata"></video>';
return '<div class="tpl-media-wrap">' + nodo + insigniaPlay(38) + "</div>";
}
if (ht === "documento") {
var nom = vacio(m.headerTexto) ? "documento.pdf" : String(m.headerTexto);
return '<div class="tpl-header-doc">' + docIcon(extDe(nom), 22) + "<span>" + esc(nom) + "</span></div>";
}
if (ht === "texto") {
return vacio(m.headerTexto) ? "" : '<div class="tpl-header-txt">' + esc(m.headerTexto) + "</div>";
}
return "";
}
/* template: header → body → footer → botones (research-ui §6.4) */
function tplHTML(m, i) {
var header = headerHTML(m);
var body = vacio(m.body) ? "" : '<div class="tpl-body">' + formatear(m.body) + "</div>";
var footer = vacio(m.footer) ? "" : '<span class="template-footer">' + formatear(m.footer) + "</span>";
return '<div class="tpl">' + header + body +
'<div class="wa-pie">' + footer + metaHTML(m) + "</div>" +
tarjetaBotones(m.botones, i, "tpl-btns") +
"</div>";
}
/* Pie común dentro de burbuja interactiva: footer gris + hora/ticks */
function pieBurbuja(m, footer) {
return '<div class="wa-pie">' + (vacio(footer) ? "" : '<span class="template-footer">' + formatear(footer) + "</span>") + metaHTML(m) + "</div>";
}
/* buttons: quick replies (máx 3) con header opcional (research-api §6.1) */
function buttonsHTML(m, i) {
return '<div class="wa-stack">' +
'<div class="bubble">' + headerHTML(m) + '<div class="wa-texto">' + formatear(m.body) + "</div>" + pieBurbuja(m, m.footer) + "</div>" +
tarjetaBotones(m.botones, i, "") +
"</div>";
}
/* list: header + body + footer + botón único. Sin data-btn/sec/fila/card:
es el marcador "plano" que main.js resuelve llamando WAM.abrirSheet(i). */
function listHTML(m, i) {
var cabecera = vacio(m.header) ? "" : '<div class="tpl-header-txt">' + esc(m.header) + "</div>";
var etiqueta = vacio(m.boton) ? "Ver opciones" : String(m.boton);
return '<div class="wa-stack">' +
'<div class="bubble">' + cabecera +
'<div class="wa-texto">' + formatear(m.body) + "</div>" +
pieBurbuja(m, m.footer) + "</div>" +
'<div class="qr-btns"><span class="qr-btn" role="button" tabindex="0" data-flujo="' + i + '">' + esc(etiqueta) + "</span></div>" +
"</div>";
}
function dominioDe(url) {
var u = String(url == null ? "" : url);
if (!u) return "";
try { return new URL(u).hostname || u; } catch (e) { return u.replace(/^[a-z]+:\/\//i, "").split("/")[0]; }
}
/* cta_url: headerMedia opcional + body + footer + botón con dominio.
Sin url el botón queda inerte (research-api §6.3: nada que abrir) y la
fila de dominio no se pinta (dominioDe("") = ""). */
function ctaHTML(m, i) {
var html = '<div class="wa-stack">';
if (!vacio(m.headerMedia)) {
html += '<div class="bubble wa-bubble-media"><img class="wa-media" src="' + esc(m.headerMedia) + '" alt="" draggable="false"></div>';
}
html += '<div class="bubble"><div class="wa-texto">' + formatear(m.body) + "</div>" + pieBurbuja(m, m.footer) + "</div>";
var etiqueta = vacio(m.textoBoton) ? "Abrir enlace" : String(m.textoBoton);
var dominio = dominioDe(m.url);
var boton;
if (vacio(m.url)) {
boton = '<span class="cta-btn cta-btn-inactiva" aria-disabled="true">' + svgEnlace() + '<span class="btn-label">' + esc(etiqueta) + "</span></span>";
} else {
boton = '<a class="cta-btn" href="' + esc(m.url) + '" target="_blank" rel="noopener noreferrer">' + svgEnlace() + '<span class="btn-label">' + esc(etiqueta) + "</span></a>";
}
html += '<div class="cta-card">' + boton +
(dominio ? '<div class="cta-domain">' + esc(dominio) + "</div>" : "") +
"</div></div>";
return html;
}
/* carousel: burbuja body + tarjetas con scroll-snap + puntos */
function carouselHTML(m, i) {
var cards = Array.isArray(m.cards) ? m.cards : [];
var html = '<div class="wa-stack wa-stack-full">';
if (!vacio(m.body)) {
html += '<div class="bubble"><div class="wa-texto">' + formatear(m.body) + "</div>" + pieBurbuja(m, "") + "</div>";
}
html += '<div class="carousel">';
for (var j = 0; j < cards.length; j++) {
var c = cards[j] || {};
var media = vacio(c.media) ? placeholder(vacio(c.texto) ? "card" : c.texto) : c.media;
var boton;
if (!vacio(c.url)) {
boton = '<a class="qr-btn" href="' + esc(c.url) + '" target="_blank" rel="noopener noreferrer">' + esc(vacio(c.textoBoton) ? "Ver más" : c.textoBoton) + "</a>";
} else {
boton = '<span class="qr-btn" role="button" tabindex="0" data-flujo="' + i + '" data-card="' + j + '">' + esc(vacio(c.textoBoton) ? "Ver más" : c.textoBoton) + "</span>";
}
html += '<div class="card">' +
'<img class="card-media" src="' + esc(media) + '" alt="" draggable="false">' +
(vacio(c.texto) ? "" : '<div class="card-body">' + formatear(c.texto) + "</div>") +
'<div class="card-btns">' + boton + "</div></div>";
}
html += "</div>";
if (cards.length > 1) {
html += '<div class="car-puntos">';
for (j = 0; j < cards.length; j++) html += '<span class="car-punto' + (j === 0 ? " activo" : "") + '"></span>';
html += "</div>";
}
return html + "</div>";
}
/* Puntos del carousel (listener de presentación, no de flujo) */
function activarPuntos(li) {
var car = li.querySelector(".carousel");
if (!car) return;
var puntos = li.querySelectorAll(".car-punto");
if (!puntos.length) return;
car.addEventListener("scroll", function () {
var hijo = car.firstElementChild;
var paso = hijo ? hijo.offsetWidth + 8 : 0;
if (!paso) return;
var idx = Math.round(car.scrollLeft / paso);
if (idx < 0) idx = 0;
if (idx > puntos.length - 1) idx = puntos.length - 1;
for (var k = 0; k < puntos.length; k++) {
puntos[k].className = "car-punto" + (k === idx ? " activo" : "");
}
}, { passive: true });
}
/* ------------------------------ API pública ------------------------------ */
function textoSubtitulo() {
if (ESCRIBIENDO) return "escribiendo…";
var c = estado().cuenta || {};
return vacio(c.subtitulo) ? "en línea" : String(c.subtitulo);
}
/* Avatar/nombre/subtítulo/verificado en #chat-header (chevron + iconos) */
WAM.renderHeader = function () {
var header = byId("chat-header");
if (!header) return;
var c = estado().cuenta || {};
var nombre = vacio(c.nombre) ? "Chat" : String(c.nombre);
var avatar = vacio(c.avatar) ? placeholder(nombre) : c.avatar;
header.innerHTML =
'<span class="wa-back">' + svgChevronAtras() + "</span>" +
'<img id="chat-avatar" class="wa-avatar" src="' + esc(avatar) + '" alt="" draggable="false">' +
'<div class="wa-header-tit">' +
'<div class="header-title"><span id="chat-nombre">' + esc(nombre) + "</span>" +
'<span id="chat-verificado" class="wa-verificado"' + (c.verificado ? "" : " hidden") + ">" + svgVerificado() + "</span></div>" +
'<div class="header-sub' + (ESCRIBIENDO ? " wa-sub-verde" : "") + '" id="chat-subtitulo">' + esc(textoSubtitulo()) + "</div>" +
"</div>" +
'<span class="wa-hicon">' + svgVideoLlamada() + "</span>" +
'<span class="wa-hicon">' + svgLlamada() + "</span>";
};
/* li.wa-row con todo el contenido del tipo + data-* para la interactividad */
WAM.renderMensaje = function (msg, i) {
var m = msg || {};
var idx = typeof i === "number" ? i : 0;
var dir = m.dir === "out" ? "out" : "in";
var mensajes = estado().mensajes || [];
var prev = idx > 0 ? mensajes[idx - 1] : null;
var grupo = !prev || prev.dir !== m.dir ? " grupo" : "";
var cuerpo;
switch (m.tipo) {
case "image": cuerpo = burbujaMedia(m, "imagen"); break;
case "video": cuerpo = burbujaMedia(m, "video"); break;
case "audio": cuerpo = burbujaAudio(m); break;
case "document": cuerpo = burbujaDocumento(m); break;
case "sticker": cuerpo = stickerHTML(m); break;
case "location": cuerpo = burbujaUbicacion(m); break;
case "contact": cuerpo = burbujaContacto(m); break;
case "template": cuerpo = tplHTML(m, idx); break;
case "buttons": cuerpo = buttonsHTML(m, idx); break;
case "list": cuerpo = listHTML(m, idx); break;
case "cta_url": cuerpo = ctaHTML(m, idx); break;
case "carousel": cuerpo = carouselHTML(m, idx); break;
default: cuerpo = burbujaTexto(m);
}
var li = document.createElement("li");
li.className = "wa-row " + dir + grupo;
if (!vacio(m.id)) li.setAttribute("data-id", String(m.id));
li.innerHTML = cuerpo;
if (m.tipo === "carousel") activarPuntos(li);
activarPlegado(li);
return li;
};
/* renderHeader() + repinta TODOS los mensajes en #chat-cuerpo */
WAM.render = function () {
WAM.renderHeader();
var cuerpo = byId("chat-cuerpo");
if (!cuerpo) return;
var ind = byId("chat-escribiendo");
var indEnCuerpo = !!(ind && ind.parentNode === cuerpo);
var mensajes = estado().mensajes || [];
var frag = document.createDocumentFragment();
for (var i = 0; i < mensajes.length; i++) frag.appendChild(WAM.renderMensaje(mensajes[i], i));
cuerpo.innerHTML = "";
if (indEnCuerpo && ind) frag.appendChild(ind); /* conserva el indicador */
cuerpo.appendChild(frag);
WAM.scrollAbajo();
};
/* append + scrollAbajo (lo usa la interactividad, sin re-render total) */
WAM.anadirMensajeDOM = function (msg) {
var cuerpo = byId("chat-cuerpo");
if (!cuerpo) return;
var mensajes = estado().mensajes || [];
var i = mensajes.indexOf ? mensajes.indexOf(msg) : -1;
if (i < 0) i = mensajes.length;
var nodo = WAM.renderMensaje(msg, i);
var ind = byId("chat-escribiendo");
if (ind && ind.parentNode === cuerpo && !ind.hidden) cuerpo.insertBefore(nodo, ind);
else cuerpo.appendChild(nodo);
WAM.scrollAbajo();
};
/* body[data-tema] + clase oscuro en #pantalla */
WAM.setTema = function (tema) {
var oscuro = tema === "oscuro";
if (document.body) document.body.setAttribute("data-tema", oscuro ? "oscuro" : "claro");
var pantalla = byId("pantalla");
if (pantalla) {
if (oscuro) pantalla.classList.add("oscuro");
else pantalla.classList.remove("oscuro");
}
};
/* Muestra/oculta #chat-escribiendo; subtítulo "escribiendo…" en verde */
WAM.setEscribiendo = function (visible) {
ESCRIBIENDO = !!visible;
var cuerpo = byId("chat-cuerpo");
var ind = byId("chat-escribiendo");
if (!ind && cuerpo) {
ind = document.createElement("li");
ind.id = "chat-escribiendo";
ind.className = "wa-row in";
ind.innerHTML = '<div class="bubble wa-bubble-escribiendo">' +
'<span class="wam-dot"></span><span class="wam-dot"></span><span class="wam-dot"></span></div>';
cuerpo.appendChild(ind);
}
if (ind) {
/* Respeta la colocación de A (index.html:147, fuera de #chat-cuerpo):
rellena contenido, alterna hidden y hereda la fila visual. */
if (String(ind.className).indexOf("wa-row") === -1) {
ind.className = String(ind.className ? ind.className + " " : "") + "wa-row in";
}
if (String(ind.innerHTML).indexOf("wa-bubble-escribiendo") === -1) {
ind.innerHTML = '<div class="bubble wa-bubble-escribiendo">' +
'<span class="wam-dot"></span><span class="wam-dot"></span><span class="wam-dot"></span></div>';
}
ind.hidden = !ESCRIBIENDO;
if (ESCRIBIENDO && ind.parentNode === cuerpo) cuerpo.appendChild(ind); /* al final */
}
var sub = byId("chat-subtitulo");
if (sub) {
sub.textContent = textoSubtitulo();
sub.className = "header-sub" + (ESCRIBIENDO ? " wa-sub-verde" : "");
}
if (ESCRIBIENDO) WAM.scrollAbajo();
};
WAM.scrollAbajo = function () {
var cuerpo = byId("chat-cuerpo");
if (cuerpo) cuerpo.scrollTop = cuerpo.scrollHeight;
};
/* Sheet de lista (B crea/destruye) en #pantalla */
WAM.abrirSheet = function (i) {
var pantalla = byId("pantalla");
if (!pantalla) return;
WAM.cerrarSheet();
var mensajes = estado().mensajes || [];
var m = (typeof i === "number" ? mensajes[i] : null) || {};
var secciones = Array.isArray(m.secciones) ? m.secciones : [];
var cuenta = estado().cuenta || {};
var titulo = vacio(m.header) ? (vacio(cuenta.nombre) ? "Opciones" : String(cuenta.nombre)) : String(m.header);
var html = "", total = 0, s, f;
for (s = 0; s < secciones.length && total < 10; s++) {
var sec = secciones[s] || {};
var filas = Array.isArray(sec.filas) ? sec.filas : [];
if (secciones.length > 1 && !vacio(sec.titulo)) {
html += '<div class="sheet-seccion">' + esc(sec.titulo) + "</div>";
}
html += '<div class="sheet-grupo">';
for (f = 0; f < filas.length && total < 10; f++) {
var fila = filas[f] || {};
total++;
html += '<span class="sheet-fila" role="button" tabindex="0" data-flujo="' + i + '" data-sec="' + s + '" data-fila="' + f + '">' +
'<span class="sheet-fila-txt"><span class="sheet-fila-titulo">' + esc(vacio(fila.titulo) ? "Opción" : fila.titulo) + "</span>" +
(vacio(fila.descripcion) ? "" : '<span class="sheet-fila-desc">' + esc(fila.descripcion) + "</span>") +
"</span>" + svgChevronDer(13) + "</span>";
}
html += "</div>";
}
var hoja = document.createElement("div");
hoja.id = "sheet";
hoja.className = "sheet";
hoja.innerHTML = '<div class="sheet-fondo" id="sheet-fondo"></div>' +
'<div class="sheet-panel"><div class="sheet-cabecera"><span class="sheet-titulo" id="sheet-titulo">' + esc(titulo) + "</span></div>" +
'<div class="sheet-opciones" id="sheet-opciones">' + html +
(total ? "" : '<div class="sheet-vacio">Sin opciones</div>') + "</div></div>";
pantalla.appendChild(hoja);
var fondo = hoja.querySelector("#sheet-fondo");
if (fondo) fondo.addEventListener("click", function () { WAM.cerrarSheet(); });
};
WAM.cerrarSheet = function () {
var hoja = byId("sheet");
if (hoja && hoja.parentNode) hoja.parentNode.removeChild(hoja);
};
})();
+544
View File
@@ -0,0 +1,544 @@
/* WAM · store.js — dueño D (núcleo).
Estado único, persistencia en localStorage, normalización, generación de IDs
y demo por defecto "Café Aurora" (los 13 tipos, respuestas encadenadas).
No toca el DOM al cargar: solo define WAM.*. */
window.WAM = window.WAM || {};
(function () {
"use strict";
WAM.CLAVE = "wam-mockup-v1";
WAM.estado = null;
var TIPOS = [
"text", "image", "video", "audio", "document", "sticker", "location",
"contact", "template", "buttons", "list", "cta_url", "carousel"
];
var HEADER_TIPOS = ["texto", "imagen", "video", "documento"];
/* ————————————————— utilidades ————————————————— */
function dos(n) { return (n < 10 ? "0" : "") + n; }
WAM.horaAhora = function () {
var d = new Date();
return dos(d.getHours()) + ":" + dos(d.getMinutes());
};
function cad(v, porDefecto) {
if (v === undefined || v === null) return porDefecto || "";
return String(v);
}
function escaparXml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
if (c === "&") return "&amp;";
if (c === "<") return "&lt;";
if (c === ">") return "&gt;";
if (c === '"') return "&quot;";
return "&apos;";
});
}
function aDataUri(svg) {
return "data:image/svg+xml," + encodeURIComponent(svg);
}
/* Placeholder genérico (taza) para src/avatar/headerMedia vacíos. */
WAM.svgPlaceholder = function (texto) {
var t = escaparXml(texto || "");
var svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320" viewBox="0 0 480 320">' +
'<rect width="480" height="320" fill="#efe6dc"/>' +
'<path d="M204 96 l8 -16 m16 16 l8 -16 m16 16 l8 -16" fill="none" stroke="#c8a27a" stroke-width="5" stroke-linecap="round"/>' +
'<rect x="184" y="118" width="70" height="52" rx="10" fill="none" stroke="#a9825f" stroke-width="7"/>' +
'<path d="M254 130 h13 a13 13 0 0 1 0 26 h-13" fill="none" stroke="#a9825f" stroke-width="7"/>' +
(t ? '<text x="240" y="242" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="24" fill="#7f5539">' + t + '</text>' : '') +
'</svg>';
return aDataUri(svg);
};
/* Imagen SVG en línea y pequeña para las medias de la demo (sin red). */
function svgDemo(texto, emoji, fondo, acento) {
var svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320" viewBox="0 0 480 320">' +
'<rect width="480" height="320" fill="' + fondo + '"/>' +
'<circle cx="240" cy="132" r="76" fill="' + acento + '"/>' +
'<text x="240" y="156" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="68">' + escaparXml(emoji || "☕") + '</text>' +
'<text x="240" y="272" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="25" fill="#5b4232">' + escaparXml(texto || "") + '</text>' +
'</svg>';
return aDataUri(svg);
}
function svgAvatar() {
return aDataUri(
'<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">' +
'<circle cx="48" cy="48" r="48" fill="#6f4e37"/>' +
'<text x="48" y="63" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="42">☕</text>' +
'</svg>'
);
}
function svgSticker() {
return aDataUri(
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">' +
'<circle cx="160" cy="160" r="150" fill="#fff8ef" stroke="#e3cfae" stroke-width="10"/>' +
'<text x="160" y="198" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="130">☕</text>' +
'</svg>'
);
}
/* ————————————————— IDs secuenciales ————————————————— */
/* Recorre el estado (mensajes, respuestas anidadas, botones, filas, cards)
y junta todos los id existentes para no colisionar nunca. */
function recolectarIds(o, conjunto) {
if (!o || typeof o !== "object") return;
if (Array.isArray(o)) {
for (var i = 0; i < o.length; i++) recolectarIds(o[i], conjunto);
return;
}
if (typeof o.id === "string" && o.id) conjunto.add(o.id);
var claves = ["mensajes", "respuestas", "botones", "secciones", "filas", "cards"];
for (var c = 0; c < claves.length; c++) {
var v = o[claves[c]];
if (v && typeof v === "object") recolectarIds(v, conjunto);
}
}
function crearAlocador(usados) {
var conjunto = usados || new Set();
return function tomar() {
var n = 1;
while (conjunto.has("m" + n)) n++;
var id = "m" + n;
conjunto.add(id);
return id;
};
}
WAM.generarId = function () {
var conjunto = new Set();
recolectarIds(WAM.estado, conjunto);
return crearAlocador(conjunto)();
};
/* ————————————————— mensajes ————————————————— */
var CAMPOS = {
text: function () { return { texto: "" }; },
image: function () { return { src: "", caption: "" }; },
video: function () { return { src: "", caption: "" }; },
audio: function () { return { src: "", voz: true, duracion: "0:00" }; },
document: function () { return { src: "", caption: "", filename: "documento.pdf" }; },
sticker: function () { return { src: "" }; },
location: function () { return { latitud: "", longitud: "", nombre: "", direccion: "" }; },
contact: function () { return { nombre: "", telefono: "", email: "", empresa: "" }; },
template: function () { return { headerTipo: "", headerTexto: "", headerMedia: "", body: "", footer: "", botones: [] }; },
buttons: function () { return { headerTipo: "", headerTexto: "", headerMedia: "", body: "", footer: "", botones: [] }; },
list: function () { return { header: "", body: "", footer: "", boton: "", secciones: [] }; },
cta_url: function () { return { headerMedia: "", body: "", footer: "", textoBoton: "", url: "" }; },
carousel: function () { return { body: "", cards: [] }; }
};
WAM.nuevoMensaje = function (tipo) {
var valido = Object.prototype.hasOwnProperty.call(CAMPOS, tipo);
var fabrica = valido ? CAMPOS[tipo] : CAMPOS.text;
var msg = {
id: WAM.generarId(),
dir: "in",
tipo: valido ? tipo : "text",
hora: WAM.horaAhora(),
estado: "sent"
};
var campos = fabrica();
for (var k in campos) {
if (Object.prototype.hasOwnProperty.call(campos, k)) msg[k] = campos[k];
}
return msg;
};
/* ————————————————— normalización ————————————————— */
/* Rellena los campos que faltan por tipo y devuelve un estado válido.
Usado por cargar() y por editor.importarJSON (import / upgrade). */
WAM.normalizar = function (o) {
var src = (o && typeof o === "object" && !Array.isArray(o)) ? o : {};
var conjunto = new Set();
recolectarIds(src, conjunto);
recolectarIds(WAM.estado, conjunto); /* evita colisiones con lo vivo */
var tomar = crearAlocador(conjunto);
function normalizarMensaje(msj, forzarEntrada) {
var m = (msj && typeof msj === "object") ? msj : {};
var tipo = TIPOS.indexOf(m.tipo) !== -1 ? m.tipo : "text";
var msg = {
id: (typeof m.id === "string" && m.id) ? m.id : tomar(),
dir: (!forzarEntrada && m.dir === "out") ? "out" : "in",
tipo: tipo,
hora: (typeof m.hora === "string" && m.hora) ? m.hora : WAM.horaAhora(),
estado: (m.estado === "delivered" || m.estado === "read") ? m.estado : "sent"
};
function respuestas(valor) {
var lista = Array.isArray(valor) ? valor : [];
return lista.map(function (r) { return normalizarMensaje(r, true); });
}
function botones(valor) {
var lista = Array.isArray(valor) ? valor : [];
return lista.map(function (b) {
var boton = (b && typeof b === "object") ? b : {};
var out = { texto: cad(boton.texto), respuestas: respuestas(boton.respuestas) };
/* Botón URL de plantilla (research-api §7.2): se conserva si viene */
if (typeof boton.url === "string") out.url = boton.url;
return out;
});
}
switch (tipo) {
case "text":
msg.texto = cad(m.texto);
break;
case "image":
case "video":
msg.src = cad(m.src);
msg.caption = cad(m.caption);
break;
case "audio":
msg.src = cad(m.src);
msg.voz = m.voz === true;
msg.duracion = cad(m.duracion, "0:00");
break;
case "document":
msg.src = cad(m.src);
msg.caption = cad(m.caption);
msg.filename = cad(m.filename, "documento.pdf");
break;
case "sticker":
msg.src = cad(m.src);
break;
case "location":
msg.latitud = cad(m.latitud);
msg.longitud = cad(m.longitud);
msg.nombre = cad(m.nombre);
msg.direccion = cad(m.direccion);
break;
case "contact":
msg.nombre = cad(m.nombre);
msg.telefono = cad(m.telefono);
msg.email = cad(m.email);
msg.empresa = cad(m.empresa);
break;
case "template":
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
msg.headerTexto = cad(m.headerTexto);
msg.headerMedia = cad(m.headerMedia);
msg.body = cad(m.body);
msg.footer = cad(m.footer);
msg.botones = botones(m.botones);
break;
case "buttons":
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
msg.headerTexto = cad(m.headerTexto);
msg.headerMedia = cad(m.headerMedia);
msg.body = cad(m.body);
msg.footer = cad(m.footer);
msg.botones = botones(m.botones);
break;
case "list":
msg.header = cad(m.header);
msg.body = cad(m.body);
msg.footer = cad(m.footer);
msg.boton = cad(m.boton);
msg.secciones = (Array.isArray(m.secciones) ? m.secciones : []).map(function (s) {
var seccion = (s && typeof s === "object") ? s : {};
return {
titulo: cad(seccion.titulo),
filas: (Array.isArray(seccion.filas) ? seccion.filas : []).map(function (f) {
var fila = (f && typeof f === "object") ? f : {};
return {
titulo: cad(fila.titulo),
descripcion: cad(fila.descripcion),
respuestas: respuestas(fila.respuestas)
};
})
};
});
break;
case "cta_url":
msg.headerMedia = cad(m.headerMedia);
msg.body = cad(m.body);
msg.footer = cad(m.footer);
msg.textoBoton = cad(m.textoBoton);
msg.url = cad(m.url);
break;
case "carousel":
msg.body = cad(m.body);
msg.cards = (Array.isArray(m.cards) ? m.cards : []).map(function (c) {
var card = (c && typeof c === "object") ? c : {};
return {
media: cad(card.media),
texto: cad(card.texto),
textoBoton: cad(card.textoBoton),
url: cad(card.url),
respuestas: respuestas(card.respuestas)
};
});
break;
}
return msg;
}
var cuentaSrc = (src.cuenta && typeof src.cuenta === "object") ? src.cuenta : {};
return {
version: 1,
tema: src.tema === "oscuro" ? "oscuro" : "claro",
cuenta: {
nombre: cad(cuentaSrc.nombre, "Café Aurora"),
avatar: cad(cuentaSrc.avatar),
subtitulo: cad(cuentaSrc.subtitulo, "en línea"),
verificado: cuentaSrc.verificado === true
},
mensajes: (Array.isArray(src.mensajes) ? src.mensajes : []).map(function (x) {
return normalizarMensaje(x, false);
})
};
};
/* ————————————————— persistencia ————————————————— */
WAM.cargar = function () {
var bruto = null;
try { bruto = window.localStorage.getItem(WAM.CLAVE); } catch (e) { bruto = null; }
var dato = null;
if (bruto) {
try { dato = JSON.parse(bruto); } catch (e) { dato = null; }
}
if (!dato) dato = WAM.datosDemo();
WAM.estado = WAM.normalizar(dato);
return WAM.estado;
};
WAM.guardar = function () {
try {
window.localStorage.setItem(WAM.CLAVE, JSON.stringify(WAM.estado));
} catch (e) { /* sin localStorage disponible (cuota/file://): se ignora */ }
};
WAM.resetear = function () {
try { window.localStorage.removeItem(WAM.CLAVE); } catch (e) { /* idem */ }
WAM.estado = WAM.normalizar(WAM.datosDemo());
};
/* ————————————————— demo por defecto: "Café Aurora" ————————————————— */
function men(dir, tipo, hora, campos) {
var msg = { id: "", dir: dir, tipo: tipo, hora: hora, estado: "sent" };
var k;
for (k in campos) {
if (Object.prototype.hasOwnProperty.call(campos, k)) msg[k] = campos[k];
}
return msg;
}
WAM.datosDemo = function () {
return {
version: 1,
tema: "claro",
cuenta: {
nombre: "Café Aurora",
avatar: svgAvatar(),
subtitulo: "en línea",
verificado: true
},
mensajes: [
/* 1 · text in (saludo) */
men("in", "text", "10:24", {
texto: "¡Hola! 👋 Bienvenido a Café Aurora. Soy Luna, tu barista de guardia. ¿Te cuento las novedades?"
}),
/* 2 · text out (cliente) */
men("out", "text", "10:24", {
estado: "read",
texto: "¡Hola, Luna! Sí, cuéntame ☕"
}),
/* 3 · image */
men("in", "image", "10:25", {
src: svgDemo("Nuevo horario", "⏰", "#f3e7d8", "#c8a27a"),
caption: "Nuevo horario desde el 1 de octubre ☕"
}),
/* 4 · video (sin src → placeholder SVG) */
men("in", "video", "10:25", {
src: "",
caption: "Así tostamos nuestros granos cada mañana"
}),
/* 5 · location */
men("in", "location", "10:26", {
latitud: "40.4168",
longitud: "-3.7038",
nombre: "Café Aurora",
direccion: "Calle Mayor 12, 28013 Madrid"
}),
/* 6 · contact */
men("in", "contact", "10:26", {
nombre: "Luna Mendoza",
telefono: "+34 611 222 333",
email: "[email protected]",
empresa: "Café Aurora"
}),
/* 7 · document */
men("in", "document", "10:27", {
src: "",
caption: "Nuestra carta con granos y precios",
filename: "carta-cafe-aurora.pdf"
}),
/* 8 · audio (nota de voz) */
men("in", "audio", "10:27", {
src: "",
voz: true,
duracion: "0:12"
}),
/* 9 · sticker */
men("in", "sticker", "10:28", {
src: svgSticker()
}),
/* 10 · template (header imagen + confirmación de reserva) */
men("in", "template", "10:29", {
headerTipo: "imagen",
headerTexto: "",
headerMedia: svgDemo("Tu mesa te espera", "☕", "#e9d8c3", "#a9825f"),
body: "Hola 👋 Tu mesa para 2 personas está reservada este viernes a las 19:30. ¿Confirmas la reserva?",
footer: "Café Aurora · Reservas",
botones: [
{
texto: "Confirmar",
respuestas: [
men("in", "text", "10:29", {
texto: "¡Genial! Reserva confirmada para el viernes a las 19:30 ✨ Te esperamos."
})
]
},
{
texto: "Cambiar fecha",
respuestas: [
men("in", "text", "10:29", {
texto: "Sin problema. ¿Para qué día y hora te la cambio?"
})
]
}
]
}),
/* 11 · buttons (quick replies, máx 3 · títulos ≤ 20) */
men("in", "buttons", "10:30", {
body: "¿Qué te muestro ahora?",
footer: "Elige una opción",
botones: [
{
texto: "Horarios",
respuestas: [
men("in", "text", "10:30", {
texto: "Abrimos de lunes a viernes de 7:30 a 20:00 y sábados de 9:00 a 14:00."
})
]
},
{
texto: "Promociones",
respuestas: [
men("in", "text", "10:30", { texto: "🔥 Martes de cold brew: 2×1 todo el día." }),
men("in", "text", "10:30", { texto: "🥐 Viernes: croissant gratis con tu café." })
]
},
{
texto: "Hablar con el equipo",
respuestas: [
men("in", "text", "10:30", {
texto: "Te paso con Marta del equipo humano, un momento…"
})
]
}
]
}),
/* 12 · list (filas ≤ 10 · títulos fila ≤ 24 · descripción ≤ 72) */
men("in", "list", "10:31", {
header: "Menú de la casa",
body: "¿Qué te apetece hoy?",
footer: "Catálogo 2026",
boton: "Ver opciones",
secciones: [
{
titulo: "Bebidas",
filas: [
{
titulo: "Espresso double",
descripcion: "Intenso, 100 % arábica",
respuestas: [men("in", "text", "10:31", { texto: "Un espresso double rumbo a tu mesa ☕" })]
},
{
titulo: "Latte de avena",
descripcion: "Suave, con leche de avena",
respuestas: [men("in", "text", "10:31", { texto: "¡Buen gusto! Latte de avena anotado 🌾" })]
},
{
titulo: "Cold brew 12 h",
descripcion: "Macerado en frío durante 12 horas",
respuestas: [men("in", "text", "10:31", { texto: "Cold brew bien frío, ¡perfecto para hoy! 🧊" })]
}
]
},
{
titulo: "Postres",
filas: [
{
titulo: "Tarta de queso",
descripcion: "La de siempre, casera",
respuestas: [men("in", "text", "10:31", { texto: "Una porción de tarta de queso, ¡gran elección! 🍰" })]
},
{
titulo: "Croissant almendra",
descripcion: "Horneado cada mañana",
respuestas: [men("in", "text", "10:31", { texto: "Croissant de almendra recién hecho 🥐" })]
}
]
}
]
}),
/* 13 · cta_url (botón con URL → <a target="_blank">, sin flujo) */
men("in", "cta_url", "10:32", {
headerMedia: svgDemo("Tienda online", "🛍️", "#efe0cd", "#b08968"),
body: "Ya puedes pedir nuestros granos tostados a domicilio.",
footer: "Envío en 24-48 h",
textoBoton: "Abrir tienda",
url: "https://cafeaurora.example/tienda"
}),
/* 14 · carousel (3 cards con respuestas encadenadas) */
men("in", "carousel", "10:33", {
body: "Los favoritos de la casa ☕",
cards: [
{
media: svgDemo("Espresso Aurora", "☕", "#f0e3d0", "#a9825f"),
texto: "Espresso Aurora · tueste medio con notas de cacao. Bolsa 250 g.",
textoBoton: "¡Lo quiero!",
url: "",
respuestas: [men("in", "text", "10:33", { texto: "¡Excelente! El Espresso Aurora sale hoy mismo hacia ti 📦" })]
},
{
media: svgDemo("Cold brew botella", "🧋", "#e4ecf0", "#7f9cab"),
texto: "Cold brew en botella de 1 L, listo para servir muy frío.",
textoBoton: "Pedir",
url: "",
respuestas: [men("in", "text", "10:33", { texto: "Cold brew de 1 L añadido a tu pedido 🧊" })]
},
{
media: svgDemo("Tarta de queso", "🍰", "#f5ebe2", "#c9a227"),
texto: "Tarta de queso casera, entera o por porciones.",
textoBoton: "Probar",
url: "",
respuestas: [men("in", "text", "10:33", { texto: "La tarta de queso sale del horno a las 12:00 🍰" })]
}
]
})
]
};
};
})();