- render.js: botones y cards con urlBloqueada se pintan inertes. - interactivity.js: sinFormato con la misma regla de marcadores que render.js; cita de documento con icono y nombre. - Zoom manual (Ajustar / 100 % / 125 %) en #barra-movil, persistido en localStorage. - Contador «Paso N/M» con estilo en iphone.css en lugar de estilos en línea. - store/editor: campos opcionales tamano y headerTamano; editar la URL retira urlBloqueada; urlValida acepta URLs con forma de dominio; importar avisa de lo recortado. - README y SPEC actualizados. Co-Authored-By: Claude Opus 5.5 <[email protected]>
753 lines
28 KiB
JavaScript
753 lines
28 KiB
JavaScript
/* 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 (con autoPulsar y velocidad). No toca el DOM
|
|
al cargar.
|
|
|
|
Guion frente a sesión: WAM.estado.mensajes es el GUION (persistente, lo que
|
|
edita el editor y reproduce la demo). Lo que ocurre al pulsar botones o
|
|
escribir en el composer es la SESIÓN (WAM.sesion, solo en memoria): se pinta
|
|
con anadirMensajeDOM sin tocar el estado ni localStorage. Cualquier
|
|
WAM.render() vuelve al guion: vacía la sesión, cierra la hoja de lista y
|
|
cancela flujos y demo en curso. */
|
|
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"
|
|
];
|
|
var CLAVE_VELOCIDAD = "wam-mockup-v1-velocidad";
|
|
var VELOCIDADES = [0.5, 1, 2];
|
|
|
|
function nada() { /* noop */ }
|
|
|
|
/* Mensajes de la sesión en curso (efímeros). */
|
|
WAM.sesion = [];
|
|
var contSesion = 0;
|
|
|
|
/* IDs del guion (mensajes, respuestas, botones, filas, cards) para que un
|
|
ID de sesión nunca coincida con uno importado (p. ej. un guion con "s1"). */
|
|
function idsDelGuion() {
|
|
var usados = new Set();
|
|
(function recorrer(o) {
|
|
if (!o || typeof o !== "object") return;
|
|
if (Array.isArray(o)) { for (var i = 0; i < o.length; i++) recorrer(o[i]); return; }
|
|
if (typeof o.id === "string" && o.id) usados.add(o.id);
|
|
["mensajes", "respuestas", "botones", "secciones", "filas", "cards"].forEach(function (k) {
|
|
if (o[k] && typeof o[k] === "object") recorrer(o[k]);
|
|
});
|
|
})(WAM.estado);
|
|
return usados;
|
|
}
|
|
|
|
function idSesion() {
|
|
var usados = WAM.estado ? idsDelGuion() : null;
|
|
var id;
|
|
do { contSesion++; id = "s" + contSesion; } while (usados && usados.has(id));
|
|
return id;
|
|
}
|
|
|
|
/* Hora para los mensajes de un flujo: la del último mensaje visible + 1 min,
|
|
para que la línea temporal de la demo siga siendo coherente (si no hay
|
|
ninguna hora legible, el reloj real). */
|
|
function horaTras() {
|
|
var metas = document.querySelectorAll("#chat-cuerpo .wa-row .meta");
|
|
var t = null;
|
|
for (var i = metas.length - 1; i >= 0 && !t; i--) {
|
|
t = String(metas[i].textContent || "").match(/(\d{1,2}):(\d{2})/);
|
|
}
|
|
if (!t) return WAM.horaAhora();
|
|
var min = (Number(t[1]) * 60 + Number(t[2]) + 1) % 1440;
|
|
return ("0" + Math.floor(min / 60)).slice(-2) + ":" + ("0" + (min % 60)).slice(-2);
|
|
}
|
|
|
|
/* ————————————————— temporizadores cancelables por grupo ————————————————— */
|
|
|
|
/* Dos grupos: "demo" (reproducción) y "flujo" (clics, composer, ticks).
|
|
Cancelar un grupo sube su generación: los pasos pendientes de esa
|
|
generación se rechazan con CANCELADO y ya no pueden programar más. */
|
|
var grupos = {
|
|
demo: { gen: 0, cancelar: [] },
|
|
flujo: { gen: 0, cancelar: [] }
|
|
};
|
|
|
|
function grupoDe(nombre) { return grupos[nombre] ? nombre : "flujo"; }
|
|
|
|
var velocidad = 1;
|
|
try {
|
|
var guardada = parseFloat(window.localStorage.getItem(CLAVE_VELOCIDAD));
|
|
if (VELOCIDADES.indexOf(guardada) !== -1) velocidad = guardada;
|
|
} catch (e) { /* sin localStorage */ }
|
|
|
|
WAM.getVelocidad = function () { return velocidad; };
|
|
WAM.setVelocidad = function (v) {
|
|
var n = parseFloat(v);
|
|
if (VELOCIDADES.indexOf(n) === -1) n = 1;
|
|
velocidad = n;
|
|
try { window.localStorage.setItem(CLAVE_VELOCIDAD, String(n)); } catch (e) { /* idem */ }
|
|
return velocidad;
|
|
};
|
|
|
|
function esperaEn(nombre, ms, gen) {
|
|
var g = grupos[grupoDe(nombre)];
|
|
return new Promise(function (resolver, rechazar) {
|
|
if (gen !== undefined && gen !== g.gen) { rechazar(CANCELADO); return; }
|
|
var dur = Math.max(0, (Number(ms) || 0) / velocidad);
|
|
var id = setTimeout(function () {
|
|
quitar();
|
|
if (gen !== undefined && gen !== g.gen) rechazar(CANCELADO);
|
|
else resolver();
|
|
}, dur);
|
|
function cancelar() {
|
|
clearTimeout(id);
|
|
quitar();
|
|
rechazar(CANCELADO);
|
|
}
|
|
function quitar() {
|
|
var i = g.cancelar.indexOf(cancelar);
|
|
if (i !== -1) g.cancelar.splice(i, 1);
|
|
}
|
|
g.cancelar.push(cancelar);
|
|
});
|
|
}
|
|
|
|
/* Promise cancelable (SPEC): todo temporizador pasa por aquí.
|
|
grupo opcional: "flujo" (por defecto) o "demo". La duración se divide
|
|
por la velocidad elegida (0,5x / 1x / 2x). */
|
|
WAM.espera = function (ms, grupo) { return esperaEn(grupo, ms); };
|
|
|
|
/* Contexto de una secuencia: fija grupo y generación al empezar. */
|
|
function contexto(nombre) {
|
|
var g = grupoDe(nombre);
|
|
var gen = grupos[g].gen;
|
|
return {
|
|
grupo: g,
|
|
vivo: function () { return grupos[g].gen === gen; },
|
|
esperar: function (ms) { return esperaEn(g, ms, gen); }
|
|
};
|
|
}
|
|
|
|
var colas = { demo: Promise.resolve(), flujo: Promise.resolve() };
|
|
|
|
function cancelarGrupo(nombre) {
|
|
var g = grupos[grupoDe(nombre)];
|
|
g.gen++;
|
|
var copia = g.cancelar.slice();
|
|
g.cancelar = [];
|
|
for (var i = 0; i < copia.length; i++) copia[i]();
|
|
colas[grupoDe(nombre)] = Promise.resolve();
|
|
}
|
|
|
|
function cancelarTodo() {
|
|
cancelarGrupo("demo");
|
|
cancelarGrupo("flujo");
|
|
escritores.clear();
|
|
pintarEscribiendo();
|
|
}
|
|
|
|
/* ————————————————— indicador "escribiendo…" (contador de referencias) ————————————————— */
|
|
|
|
var escritores = new Set();
|
|
|
|
function pintarEscribiendo() {
|
|
if (typeof WAM.setEscribiendo === "function") WAM.setEscribiendo(escritores.size > 0);
|
|
}
|
|
|
|
function empezarEscribir() {
|
|
var ficha = {};
|
|
escritores.add(ficha);
|
|
pintarEscribiendo();
|
|
return ficha;
|
|
}
|
|
|
|
function dejarEscribir(ficha) {
|
|
if (escritores.delete(ficha)) pintarEscribiendo();
|
|
}
|
|
|
|
async function escribirDurante(ctx, ms) {
|
|
var ficha = empezarEscribir();
|
|
try { await ctx.esperar(ms); } finally { dejarEscribir(ficha); }
|
|
}
|
|
|
|
/* ————————————————— estado de la demo ————————————————— */
|
|
|
|
var demoAndando = false;
|
|
|
|
WAM.demoActiva = function () { return demoAndando; };
|
|
|
|
function avisarDemo() {
|
|
if (typeof WAM.onDemoChange === "function") {
|
|
try { WAM.onDemoChange(demoAndando); } catch (e) { /* el hook es opcional */ }
|
|
}
|
|
}
|
|
|
|
/* ————————————————— búsqueda de mensajes y filas ————————————————— */
|
|
|
|
function cuerpoChat() { return document.getElementById("chat-cuerpo"); }
|
|
|
|
function selectorId(id) {
|
|
return '.wa-row[data-id="' + String(id).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"]';
|
|
}
|
|
|
|
function filaDe(msg) {
|
|
var cuerpo = cuerpoChat();
|
|
if (!cuerpo || !msg || typeof msg.id !== "string" || !msg.id) return null;
|
|
var filas = cuerpo.querySelectorAll(selectorId(msg.id));
|
|
return filas.length ? filas[filas.length - 1] : null;
|
|
}
|
|
|
|
function buscarPorId(id) {
|
|
if (!id) return null;
|
|
var guion = (WAM.estado && WAM.estado.mensajes) || [];
|
|
for (var i = 0; i < guion.length; i++) if (guion[i] && guion[i].id === id) return guion[i];
|
|
for (var j = WAM.sesion.length - 1; j >= 0; j--) if (WAM.sesion[j] && WAM.sesion[j].id === id) return WAM.sesion[j];
|
|
return null;
|
|
}
|
|
|
|
/* Mensaje de la hoja de lista abierta (guion o sesión). */
|
|
var hojaMsg = null;
|
|
|
|
/* Mensaje al que pertenece un elemento clicable del chat o de la hoja.
|
|
Se resuelve por el data-id de su fila (inmune a índices obsoletos y
|
|
válido para mensajes de sesión); el índice data-flujo queda de respaldo. */
|
|
WAM.mensajeDeElemento = function (el) {
|
|
if (!el || !el.closest) return null;
|
|
var hoja = el.closest("#sheet");
|
|
if (hoja) return hojaMsg;
|
|
var fila = el.closest(".wa-row[data-id]");
|
|
var msg = fila ? buscarPorId(fila.getAttribute("data-id")) : null;
|
|
if (msg) return msg;
|
|
var indice = parseInt(el.getAttribute("data-flujo"), 10);
|
|
var guion = (WAM.estado && WAM.estado.mensajes) || [];
|
|
return isNaN(indice) ? null : (guion[indice] || null);
|
|
};
|
|
|
|
/* Abre la hoja de lista de un mensaje (del guion o de la sesión). */
|
|
WAM.abrirLista = function (msg) {
|
|
if (!msg || typeof WAM.abrirSheet !== "function") return;
|
|
hojaMsg = msg;
|
|
var guion = (WAM.estado && WAM.estado.mensajes) || [];
|
|
var i = guion.indexOf(msg);
|
|
WAM.abrirSheet(i >= 0 ? i : msg);
|
|
};
|
|
|
|
/* ————————————————— pintado incremental ————————————————— */
|
|
|
|
/* grupo / cola (.grupo / .ultimo) los recalcula anadirMensajeDOM según el DOM. */
|
|
function pintar(msg) {
|
|
if (typeof WAM.anadirMensajeDOM !== "function") return null;
|
|
WAM.anadirMensajeDOM(msg);
|
|
var fila = filaDe(msg);
|
|
if (fila && msg.cita) pintarCita(fila, msg.cita);
|
|
return fila;
|
|
}
|
|
|
|
function anadirSesion(msg) {
|
|
WAM.sesion.push(msg);
|
|
return pintar(msg);
|
|
}
|
|
|
|
function mensajeOut(texto, cita, hora) {
|
|
var msg = {
|
|
id: idSesion(),
|
|
dir: "out",
|
|
tipo: "text",
|
|
hora: hora || WAM.horaAhora(),
|
|
estado: "sent",
|
|
texto: String(texto == null ? "" : texto)
|
|
};
|
|
if (cita) msg.cita = cita;
|
|
return msg;
|
|
}
|
|
|
|
/* 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 = idSesion();
|
|
copia.dir = "in";
|
|
copia.hora = horaTras();
|
|
copia.estado = "sent";
|
|
delete copia.cita;
|
|
return copia;
|
|
}
|
|
|
|
/* ————————————————— cita de la respuesta (quoted reply) ————————————————— */
|
|
|
|
function vacio(v) { return v === undefined || v === null || String(v).trim() === ""; }
|
|
|
|
function sinFormato(s) {
|
|
return String(s == null ? "" : s)
|
|
.replace(/```([\s\S]*?)```/g, "$1")
|
|
.replace(/`([^`\n]+)`/g, "$1")
|
|
.replace(reMarca("\*"), "$1$2")
|
|
.replace(reMarca("_"), "$1$2")
|
|
.replace(reMarca("~"), "$1$2")
|
|
.trim();
|
|
}
|
|
|
|
/* Misma regla que render.js: marca pegada a un carácter no blanco por dentro
|
|
y sin letra, número ni _ por fuera («2 * 3 * 4» queda literal). */
|
|
function reMarca(e) {
|
|
try {
|
|
return new RegExp("(^|[^\p{L}\p{N}_" + e + "])" + e + "(?=\S)([^" + e + "\n]*?\S)" + e +
|
|
"(?=[^\p{L}\p{N}_" + e + "]|$)", "gu");
|
|
} catch (err) {
|
|
return new RegExp("(^|[^\w" + e + "])" + e + "(?=\S)([^" + e + "\n]*?\S)" + e + "(?=[^\w" + e + "]|$)", "g");
|
|
}
|
|
}
|
|
|
|
function miniaturaSegura(u) {
|
|
if (vacio(u)) return "";
|
|
var s = String(u);
|
|
if (/^data:video\//i.test(s)) return "";
|
|
if (typeof WAM.urlSegura === "function" && !WAM.urlSegura(s, "media")) return "";
|
|
return s;
|
|
}
|
|
|
|
/* { nombre, titulo, extracto, miniatura, foto } del mensaje original. */
|
|
function citaDe(msg, opciones) {
|
|
if (!msg) return null;
|
|
var cuenta = (WAM.estado && WAM.estado.cuenta) || {};
|
|
var cita = {
|
|
nombre: vacio(cuenta.nombre) ? "Chat" : String(cuenta.nombre),
|
|
titulo: "",
|
|
extracto: "",
|
|
miniatura: "",
|
|
foto: false
|
|
};
|
|
var op = opciones || {};
|
|
var partes = [];
|
|
if (msg.tipo === "list") {
|
|
cita.titulo = sinFormato(msg.header);
|
|
partes = [msg.body, msg.footer];
|
|
} else if (msg.tipo === "carousel") {
|
|
var card = (msg.cards || [])[op.card] || {};
|
|
partes = [vacio(card.texto) ? msg.body : card.texto];
|
|
cita.miniatura = miniaturaSegura(card.media);
|
|
if (!cita.miniatura && vacio(card.media) && typeof WAM.svgPlaceholder === "function") {
|
|
cita.miniatura = WAM.svgPlaceholder("");
|
|
}
|
|
cita.foto = true;
|
|
} else {
|
|
if (msg.headerTipo === "texto") cita.titulo = sinFormato(msg.headerTexto);
|
|
partes = [msg.body];
|
|
if (msg.headerTipo === "imagen") {
|
|
cita.miniatura = vacio(msg.headerMedia)
|
|
? (typeof WAM.svgPlaceholder === "function" ? WAM.svgPlaceholder("") : "")
|
|
: miniaturaSegura(msg.headerMedia);
|
|
cita.foto = true;
|
|
} else if (msg.headerTipo === "video") {
|
|
cita.foto = true;
|
|
} else if (msg.headerTipo === "documento") {
|
|
cita.titulo = vacio(msg.headerTexto) ? "documento.pdf" : String(msg.headerTexto);
|
|
cita.doc = true;
|
|
}
|
|
}
|
|
cita.extracto = partes.filter(function (p) { return !vacio(p); }).map(sinFormato).join("\n");
|
|
if (!cita.extracto && !cita.titulo) cita.extracto = cita.foto ? "Foto" : "Mensaje";
|
|
return cita;
|
|
}
|
|
|
|
var SVG_CAMARA = '<svg class="wa-cita-cam" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">' +
|
|
'<path fill="currentColor" d="M5.6 2.5h4.8l1 1.5H13a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 13 12.5H3A1.5 1.5 0 0 1 1.5 11V5.5A1.5 1.5 0 0 1 3 4h1.6l1-1.5Zm2.4 3a2.75 2.75 0 1 0 0 5.5 2.75 2.75 0 0 0 0-5.5Z"/></svg>';
|
|
|
|
var SVG_DOC = '<svg class="wa-cita-doc" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">' +
|
|
'<path fill="currentColor" d="M4 1.5h5.3L13 5.2V13a1.5 1.5 0 0 1-1.5 1.5h-7A1.5 1.5 0 0 1 3 13V3a1.5 1.5 0 0 1 1-1.5Zm5 .9V5.5h3.1L9 2.4ZM5 8h6v1H5V8Zm0 2.2h6v1H5v-1Z"/></svg>';
|
|
|
|
/* Bloque de cita dentro de la burbuja OUT; todo por textContent (sin XSS). */
|
|
function pintarCita(fila, cita) {
|
|
var burbuja = fila.querySelector(".bubble");
|
|
if (!burbuja || burbuja.querySelector(".wa-cita")) return;
|
|
var caja = document.createElement("div");
|
|
caja.className = "wa-cita";
|
|
var txt = document.createElement("div");
|
|
txt.className = "wa-cita-txt";
|
|
var nombre = document.createElement("div");
|
|
nombre.className = "wa-cita-nombre";
|
|
nombre.textContent = cita.nombre;
|
|
txt.appendChild(nombre);
|
|
if (cita.titulo) {
|
|
var tit = document.createElement("div");
|
|
tit.className = "wa-cita-titulo";
|
|
if (cita.doc) {
|
|
var doc = document.createElement("span");
|
|
doc.innerHTML = SVG_DOC; /* constante propia, sin datos del usuario */
|
|
if (doc.firstChild) tit.appendChild(doc.firstChild);
|
|
}
|
|
tit.appendChild(document.createTextNode(cita.titulo));
|
|
txt.appendChild(tit);
|
|
}
|
|
if (cita.extracto) {
|
|
var ext = document.createElement("div");
|
|
ext.className = "wa-cita-extracto";
|
|
if (cita.foto) {
|
|
var cam = document.createElement("span");
|
|
cam.innerHTML = SVG_CAMARA; /* constante propia, sin datos del usuario */
|
|
if (cam.firstChild) ext.appendChild(cam.firstChild);
|
|
}
|
|
ext.appendChild(document.createTextNode(cita.extracto));
|
|
txt.appendChild(ext);
|
|
}
|
|
caja.appendChild(txt);
|
|
if (cita.miniatura) {
|
|
var img = document.createElement("img");
|
|
img.className = "wa-cita-mini";
|
|
img.alt = "";
|
|
img.draggable = false;
|
|
img.src = cita.miniatura;
|
|
caja.appendChild(img);
|
|
}
|
|
burbuja.insertBefore(caja, burbuja.firstChild);
|
|
burbuja.classList.add("wa-con-cita");
|
|
}
|
|
|
|
/* ————————————————— ticks automáticos ————————————————— */
|
|
|
|
/* Ticks sent → delivered → read sobre la burbuja OUT indicada (por data-id,
|
|
para que dos envíos solapados avancen cada uno su propio tick). */
|
|
function repintarTickDe(msg, estado) {
|
|
if (typeof WAM.tick !== "function") return;
|
|
var fila = filaDe(msg);
|
|
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. */
|
|
var aux = document.createElement("span");
|
|
aux.innerHTML = WAM.tick(estado);
|
|
if (aux.firstChild) tick.parentNode.replaceChild(aux.firstChild, tick);
|
|
}
|
|
}
|
|
|
|
/* Los mensajes de sesión no se guardan; si se cancelan los ticks, la
|
|
burbuja salta a "read" para no quedar congelada en ✓ / ✓✓ gris. */
|
|
async function animarTics(msg, ctx) {
|
|
try {
|
|
await ctx.esperar(800);
|
|
msg.estado = "delivered";
|
|
repintarTickDe(msg, "delivered");
|
|
await ctx.esperar(800);
|
|
msg.estado = "read";
|
|
repintarTickDe(msg, "read");
|
|
} catch (e) {
|
|
if (msg.estado !== "read") {
|
|
msg.estado = "read";
|
|
repintarTickDe(msg, "read");
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/* ————————————————— flujos ————————————————— */
|
|
|
|
/* Respuestas encadenadas: typing ~900 ms + burbuja IN por cada una.
|
|
En la demo (enDemo) cada respuesta con autoPulsar se pulsa sola. */
|
|
async function responder(lista, ctx, enDemo) {
|
|
for (var i = 0; i < lista.length; i++) {
|
|
await escribirDurante(ctx, 900);
|
|
if (!ctx.vivo()) throw CANCELADO;
|
|
var r = mensajeDesde(lista[i]);
|
|
anadirSesion(r);
|
|
if (enDemo) await autoPulsar(r, ctx);
|
|
}
|
|
}
|
|
|
|
/* Burbuja OUT con el texto (y cita opcional {nombre, extracto, miniatura}),
|
|
ticks auto y respuestas. La burbuja sale al instante; las respuestas de
|
|
flujos simultáneos se encolan para no intercalarse. Cancelable. */
|
|
WAM.flujo = function (texto, respuestas, cita) {
|
|
var lista = Array.isArray(respuestas) ? respuestas : [];
|
|
var ctx = contexto("flujo");
|
|
var out = mensajeOut(texto, cita || null, horaTras());
|
|
anadirSesion(out);
|
|
var tics = animarTics(out, ctx);
|
|
tics.catch(nada);
|
|
var turno = colas.flujo.then(function () {
|
|
if (!ctx.vivo()) throw CANCELADO;
|
|
return tics.then(function () { return responder(lista, ctx, false); });
|
|
});
|
|
colas.flujo = turno.catch(nada);
|
|
return turno.catch(nada);
|
|
};
|
|
|
|
/* 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);
|
|
anadirSesion(out);
|
|
animarTics(out, contexto("flujo")).catch(nada);
|
|
};
|
|
|
|
/* Resuelve { texto, respuestas, cita, msg, tipo } desde los data-* que
|
|
estampa render.js: data-btn (template/buttons), data-sec + data-fila
|
|
(lista) o data-card (carousel). Etiquetas vacías = las del render. */
|
|
WAM.resolverFlujo = function (el) {
|
|
var vacioR = { texto: "", respuestas: [], cita: null, msg: null, tipo: "" };
|
|
if (!el || typeof el.getAttribute !== "function") return vacioR;
|
|
var msg = WAM.mensajeDeElemento(el);
|
|
if (!msg) return vacioR;
|
|
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)];
|
|
if (!fila) return vacioR;
|
|
var titulo = vacio(fila.titulo) ? "Opción" : String(fila.titulo);
|
|
return {
|
|
texto: titulo + (vacio(fila.descripcion) ? "" : "\n" + fila.descripcion),
|
|
respuestas: fila.respuestas || [],
|
|
cita: citaDe(msg),
|
|
msg: msg,
|
|
tipo: "fila"
|
|
};
|
|
}
|
|
if (ds.btn !== undefined) {
|
|
var n = parseInt(ds.btn, 10);
|
|
var boton = (msg.botones || [])[n];
|
|
/* botón de enlace con URL bloqueada: inerte, nunca un quick reply */
|
|
if (!boton || boton.urlBloqueada) return vacioR;
|
|
return {
|
|
texto: vacio(boton.texto) ? "Botón " + (n + 1) : String(boton.texto),
|
|
respuestas: boton.respuestas || [],
|
|
cita: citaDe(msg),
|
|
msg: msg,
|
|
tipo: "boton"
|
|
};
|
|
}
|
|
if (ds.card !== undefined) {
|
|
var c = parseInt(ds.card, 10);
|
|
var card = (msg.cards || [])[c];
|
|
if (!card || card.urlBloqueada) return vacioR;
|
|
return {
|
|
texto: vacio(card.textoBoton) ? "Ver más" : String(card.textoBoton),
|
|
respuestas: card.respuestas || [],
|
|
cita: citaDe(msg, { card: c }),
|
|
msg: msg,
|
|
tipo: "card"
|
|
};
|
|
}
|
|
/* Elemento plano de un mensaje list: es el botón que abre la hoja. */
|
|
return vacioR;
|
|
};
|
|
|
|
/* Deja el botón pulsado en gris y sin respuesta a más clics (como iOS). */
|
|
WAM.marcarUsado = function (el) {
|
|
if (!el || !el.classList) return;
|
|
el.classList.add("usado");
|
|
el.setAttribute("aria-disabled", "true");
|
|
el.setAttribute("tabindex", "-1");
|
|
};
|
|
|
|
/* Guarda la sesión actual como parte del guion (acción explícita). */
|
|
WAM.sesionAGuion = function () {
|
|
if (!WAM.sesion.length || !WAM.estado) return 0;
|
|
var tomar = typeof WAM.crearAlocador === "function" ? WAM.crearAlocador() : WAM.generarId;
|
|
var nuevos = WAM.sesion.map(function (m) {
|
|
var copia = JSON.parse(JSON.stringify(m));
|
|
delete copia.cita;
|
|
(function reasignar(o) {
|
|
if (!o || typeof o !== "object") return;
|
|
if (Array.isArray(o)) { o.forEach(reasignar); return; }
|
|
if (typeof o.id === "string") o.id = tomar();
|
|
["respuestas", "botones", "secciones", "filas", "cards"].forEach(function (k) { reasignar(o[k]); });
|
|
})(copia);
|
|
return copia;
|
|
});
|
|
var n = nuevos.length;
|
|
if (typeof WAM.puntoDeshacer === "function") { try { WAM.puntoDeshacer(); } catch (e) { /* opcional */ } }
|
|
WAM.estado = WAM.normalizar ? WAM.normalizar({
|
|
version: WAM.estado.version, tema: WAM.estado.tema, cuenta: WAM.estado.cuenta,
|
|
mensajes: WAM.estado.mensajes.concat(nuevos)
|
|
}) : WAM.estado;
|
|
if (typeof WAM.guardar === "function") WAM.guardar();
|
|
if (typeof WAM.marcarEditado === "function") { try { WAM.marcarEditado(); } catch (e) { /* opcional */ } }
|
|
if (typeof WAM.render === "function") WAM.render();
|
|
if (typeof WAM.renderEditor === "function") WAM.renderEditor();
|
|
return n;
|
|
};
|
|
|
|
/* ————————————————— reproducción de la demo ————————————————— */
|
|
|
|
function pulsacion(el, ctx) {
|
|
el.classList.add("wam-pulsando");
|
|
return ctx.esperar(260).then(function () { el.classList.remove("wam-pulsando"); },
|
|
function (e) { el.classList.remove("wam-pulsando"); throw e; });
|
|
}
|
|
|
|
function centrarCard(el) {
|
|
var car = el.closest(".carousel");
|
|
var card = el.closest(".card");
|
|
if (!car || !card) return;
|
|
var delta = card.getBoundingClientRect().left - car.getBoundingClientRect().left;
|
|
try { car.scrollTo({ left: car.scrollLeft + delta - 8, behavior: "smooth" }); }
|
|
catch (e) { car.scrollLeft += delta - 8; }
|
|
}
|
|
|
|
/* Pulsa sola la opción msg.autoPulsar (índice de botón, card o fila
|
|
aplanada de la lista) 1 s después de que aparezca el mensaje. */
|
|
async function autoPulsar(msg, ctx) {
|
|
if (!msg || ["template", "buttons", "list", "carousel"].indexOf(msg.tipo) === -1) return;
|
|
var idx = msg.autoPulsar;
|
|
if (idx === null || idx === undefined || idx === "") return;
|
|
idx = Number(idx);
|
|
if (!isFinite(idx) || idx < 0) return;
|
|
var fila = filaDe(msg);
|
|
if (!fila) return;
|
|
await ctx.esperar(1000);
|
|
var el = null;
|
|
|
|
if (msg.tipo === "list") {
|
|
var s, f, cuenta = 0, destino = null;
|
|
var secs = msg.secciones || [];
|
|
for (s = 0; s < secs.length && !destino; s++) {
|
|
var filas = (secs[s] && secs[s].filas) || [];
|
|
for (f = 0; f < filas.length; f++) {
|
|
if (cuenta === idx) { destino = [s, f]; break; }
|
|
cuenta++;
|
|
}
|
|
}
|
|
if (!destino) return;
|
|
var abridor = fila.querySelector("[data-flujo]:not([data-btn]):not([data-card])");
|
|
if (abridor) await pulsacion(abridor, ctx);
|
|
WAM.abrirLista(msg);
|
|
await ctx.esperar(900);
|
|
var hoja = document.getElementById("sheet");
|
|
el = hoja ? hoja.querySelector('[data-sec="' + destino[0] + '"][data-fila="' + destino[1] + '"]') : null;
|
|
if (!el) { if (WAM.cerrarSheet) WAM.cerrarSheet(); return; }
|
|
await pulsacion(el, ctx);
|
|
var selL = WAM.resolverFlujo(el);
|
|
if (WAM.cerrarSheet) WAM.cerrarSheet();
|
|
await flujoEnDemo(selL, ctx);
|
|
return;
|
|
}
|
|
|
|
el = fila.querySelector(msg.tipo === "carousel" ? '[data-card="' + idx + '"]' : '[data-btn="' + idx + '"]');
|
|
if (!el) return;
|
|
if (el.hidden) {
|
|
var plegado = fila.querySelector("[data-ver-todas]");
|
|
if (plegado) { plegado.click(); await ctx.esperar(400); }
|
|
/* B abre la hoja "Todas las opciones" (render.js): se pulsa su fila */
|
|
var enHoja = document.querySelector('#sheet [data-opcion="' + idx + '"]');
|
|
if (!enHoja) { if (WAM.cerrarSheet) WAM.cerrarSheet(); return; }
|
|
await pulsacion(enHoja, ctx);
|
|
if (WAM.cerrarSheet) WAM.cerrarSheet();
|
|
}
|
|
if (msg.tipo === "carousel") { centrarCard(el); await ctx.esperar(500); }
|
|
await pulsacion(el, ctx);
|
|
var sel = WAM.resolverFlujo(el);
|
|
WAM.marcarUsado(el);
|
|
await flujoEnDemo(sel, ctx);
|
|
}
|
|
|
|
async function flujoEnDemo(sel, ctx) {
|
|
if (!sel || !sel.msg) return;
|
|
var out = mensajeOut(sel.texto, sel.cita, horaTras());
|
|
anadirSesion(out);
|
|
await animarTics(out, ctx);
|
|
await responder(Array.isArray(sel.respuestas) ? sel.respuestas : [], ctx, true);
|
|
}
|
|
|
|
function vaciarChat() {
|
|
var cuerpo = cuerpoChat();
|
|
if (!cuerpo) return;
|
|
var filas = cuerpo.querySelectorAll(".wa-row");
|
|
for (var i = 0; i < filas.length; i++) {
|
|
if (filas[i].id !== "chat-escribiendo") cuerpo.removeChild(filas[i]);
|
|
}
|
|
}
|
|
|
|
/* Progreso de la demo: mensajes del guion revelados / total. */
|
|
var progreso = { paso: 0, total: 0 };
|
|
WAM.progresoDemo = function () {
|
|
return demoAndando ? { paso: progreso.paso, total: progreso.total } : { paso: 0, total: 0 };
|
|
};
|
|
|
|
WAM.reproducirDemo = function () {
|
|
if (demoAndando) return;
|
|
/* aborta flujos, ticks y typing en curso: no se mezclan con la demo */
|
|
cancelarTodo();
|
|
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet();
|
|
hojaMsg = null;
|
|
WAM.sesion.length = 0;
|
|
demoAndando = true;
|
|
avisarDemo();
|
|
vaciarChat();
|
|
var ctx = contexto("demo");
|
|
var secuencia = ((WAM.estado && WAM.estado.mensajes) || []).slice();
|
|
progreso.paso = 0;
|
|
progreso.total = secuencia.length;
|
|
(async function () {
|
|
try {
|
|
for (var i = 0; i < secuencia.length; i++) {
|
|
var msg = secuencia[i];
|
|
if (!msg) continue;
|
|
if (msg.dir === "in") await escribirDurante(ctx, 700);
|
|
if (!ctx.vivo()) throw CANCELADO;
|
|
pintar(msg);
|
|
progreso.paso = i + 1;
|
|
avisarDemo();
|
|
await ctx.esperar(600);
|
|
await autoPulsar(msg, ctx);
|
|
}
|
|
} catch (e) {
|
|
/* cancelado: detenerDemo() / render() ya dejaron el chat coherente */
|
|
}
|
|
if (ctx.vivo()) {
|
|
demoAndando = false;
|
|
avisarDemo();
|
|
}
|
|
})();
|
|
};
|
|
|
|
WAM.detenerDemo = function () {
|
|
var estaba = demoAndando;
|
|
demoAndando = false;
|
|
cancelarGrupo("demo"); /* solo el grupo de la demo */
|
|
if (estaba) {
|
|
/* vuelve al guion completo (y descarta la sesión: ver reiniciarInteraccion) */
|
|
if (typeof WAM.render === "function") WAM.render();
|
|
avisarDemo();
|
|
}
|
|
};
|
|
|
|
/* Vuelve al guion: cancela todo, vacía la sesión y cierra la hoja. */
|
|
function reiniciarInteraccion() {
|
|
var estaba = demoAndando;
|
|
demoAndando = false;
|
|
cancelarTodo();
|
|
WAM.sesion.length = 0;
|
|
hojaMsg = null;
|
|
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet();
|
|
if (estaba) avisarDemo();
|
|
}
|
|
|
|
WAM.reiniciarChat = function () {
|
|
if (typeof WAM.render === "function") WAM.render();
|
|
};
|
|
|
|
/* WAM.render() repinta el guion entero: antes se descarta la sesión y se
|
|
cancelan demo y flujos para que nada añada filas sobre el repintado
|
|
(evita data-id duplicados y la hoja con índices obsoletos). */
|
|
if (typeof WAM.render === "function" && !WAM.render.__wamEnvuelto) {
|
|
var renderBase = WAM.render;
|
|
WAM.render = function () {
|
|
reiniciarInteraccion();
|
|
return renderBase.apply(this, arguments);
|
|
};
|
|
WAM.render.__wamEnvuelto = true;
|
|
}
|
|
})();
|