diff --git a/SPEC.md b/SPEC.md index 298d937..ab994a7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -51,6 +51,8 @@ con `dir:"in"`, anidados, NO se renderizan en el chat: los consume la interactiv Convenciones: `src`/`avatar`/`headerMedia`/`media` = dataURL o URL remota; `""` → placeholder SVG de `WAM.svgPlaceholder(texto)`. Botones con `url` no vacía se renderizan como `` (cta_url, template URL, card de carousel); sin `url` → flujo interactivo con `respuestas`. +Botón de plantilla o card con URL no permitida: `normalizar` deja `url: ""` y añade `urlBloqueada: true` +(sigue siendo un botón de enlace, inerte; nunca un quick reply con flujo; `resolverFlujo` lo ignora). `WAM.normalizar(o)` rellena campos faltantes por tipo y devuelve el estado válido (import/upgrade). Campo opcional `autoPulsar` (entero ≥ 0) en `template`/`buttons`/`list`/`carousel` (también en respuestas anidadas): índice del botón, de la fila aplanada de la lista o de la card que «Reproducir demo» pulsa sola @@ -73,7 +75,12 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`). (main.js lo pinta con `WAM.avisar`) hasta el siguiente guardado correcto. - `WAM.resetear()` → void (borra LS, estado = demo con IDs desde `m1`) - `WAM.normalizar(o)` → estado (acepta un array de mensajes como raíz; IDs duplicados o inválidos se - reasignan; vacía URLs con esquema no permitido; respuestas string → text; conserva campos extra primitivos) + reasignan; vacía URLs con esquema no permitido; respuestas string → text; conserva campos extra primitivos). + Lo recortado (más de 500 mensajes, 10 botones/secciones/filas/cards, 20 respuestas o 12 niveles) queda en + `WAM.ultimosRecortes` y en `estado.__recortes` (no enumerable): `{ mensajes, mensajesOriginales, botones, + secciones, filas, cards, respuestas, profundidad }`. +- `WAM.describirRecortes(r?)` → string en español para el toast de importar («Se importaron 500 de 600 + mensajes; se recortaron 3 respuestas…») o `""` si no se recortó nada (por defecto `WAM.ultimosRecortes`) - `WAM.generarId()` → string (nunca repite un ID ya entregado aunque aún no esté en el estado) - `WAM.crearAlocador(extra?)` → `function tomar()` que reparte IDs únicos frente al estado, a los ya entregados y a los de `extra` (para clonar mensajes con respuestas anidadas) @@ -168,9 +175,15 @@ cancela demo y flujos (interactivity.js envuelve `WAM.render`). " + descripcion`. - `WAM.mensajeDeElemento(el)` → Mensaje · `WAM.abrirLista(msg)` → abre la hoja (`abrirSheet(i | msg)`) - `WAM.marcarUsado(el)` → clase `.usado` (gris, sin más clics) · `WAM.reiniciarChat()` (= `render()`) -- `WAM.sesionAGuion()` → n: añade la sesión al guion (acción explícita) + guardar + re-render +- `WAM.sesionAGuion()` → n: añade la sesión al guion (acción explícita) + `puntoDeshacer` + guardar + + `marcarEditado` + re-render +- Hora de la sesión: las salientes de un flujo y sus respuestas usan la hora del último mensaje visible + + 1 min (línea temporal coherente con el guion); el composer libre usa `WAM.horaAhora()`. Los IDs de sesión + (`s1`, `s2`…) saltan los que ya existen en el guion. - `WAM.getVelocidad()` / `WAM.setVelocidad(0.5|1|2)` (persistida en `wam-mockup-v1-velocidad`) -- `WAM.demoActiva()` → bool · hook opcional `WAM.onDemoChange(activa)` +- `WAM.demoActiva()` → bool · hook opcional `WAM.onDemoChange(activa)` (también se llama en cada paso) +- `WAM.progresoDemo()` → `{ paso, total }` (mensajes del guion revelados / total; `{0, 0}` sin demo). + main.js lo pinta en `#demo-paso` («Paso 3/12», `aria-live`) dentro de `#barra-movil` - `WAM.reproducirDemo()` / `WAM.detenerDemo()` → void: cancela flujos en curso, vacía `#chat-cuerpo` y revela `estado.mensajes` uno a uno (pulsando solas las opciones `autoPulsar`); detener vuelve al guion (~600 ms; mensaje `in` precedido de typing ~700 ms; cancelable) diff --git a/js/interactivity.js b/js/interactivity.js index fa86d2e..4605955 100644 --- a/js/interactivity.js +++ b/js/interactivity.js @@ -27,7 +27,42 @@ window.WAM = window.WAM || {}; /* Mensajes de la sesión en curso (efímeros). */ WAM.sesion = []; var contSesion = 0; - function idSesion() { contSesion++; return "s" + contSesion; } + + /* 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 ————————————————— */ @@ -215,12 +250,12 @@ window.WAM = window.WAM || {}; return pintar(msg); } - function mensajeOut(texto, cita) { + function mensajeOut(texto, cita, hora) { var msg = { id: idSesion(), dir: "out", tipo: "text", - hora: WAM.horaAhora(), + hora: hora || WAM.horaAhora(), estado: "sent", texto: String(texto == null ? "" : texto) }; @@ -243,7 +278,7 @@ window.WAM = window.WAM || {}; if (TIPOS_VALIDOS.indexOf(copia.tipo) === -1) copia.tipo = "text"; copia.id = idSesion(); copia.dir = "in"; - copia.hora = WAM.horaAhora(); + copia.hora = horaTras(); copia.estado = "sent"; delete copia.cita; return copia; @@ -413,7 +448,7 @@ window.WAM = window.WAM || {}; WAM.flujo = function (texto, respuestas, cita) { var lista = Array.isArray(respuestas) ? respuestas : []; var ctx = contexto("flujo"); - var out = mensajeOut(texto, cita || null); + var out = mensajeOut(texto, cita || null, horaTras()); anadirSesion(out); var tics = animarTics(out, ctx); tics.catch(nada); @@ -460,7 +495,8 @@ window.WAM = window.WAM || {}; if (ds.btn !== undefined) { var n = parseInt(ds.btn, 10); var boton = (msg.botones || [])[n]; - if (!boton) return vacioR; + /* 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 || [], @@ -472,7 +508,7 @@ window.WAM = window.WAM || {}; if (ds.card !== undefined) { var c = parseInt(ds.card, 10); var card = (msg.cards || [])[c]; - if (!card) return vacioR; + if (!card || card.urlBloqueada) return vacioR; return { texto: vacio(card.textoBoton) ? "Ver más" : String(card.textoBoton), respuestas: card.respuestas || [], @@ -509,11 +545,13 @@ window.WAM = window.WAM || {}; 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; @@ -594,7 +632,7 @@ window.WAM = window.WAM || {}; async function flujoEnDemo(sel, ctx) { if (!sel || !sel.msg) return; - var out = mensajeOut(sel.texto, sel.cita); + var out = mensajeOut(sel.texto, sel.cita, horaTras()); anadirSesion(out); await animarTics(out, ctx); await responder(Array.isArray(sel.respuestas) ? sel.respuestas : [], ctx, true); @@ -609,6 +647,12 @@ window.WAM = window.WAM || {}; } } + /* 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 */ @@ -621,6 +665,8 @@ window.WAM = window.WAM || {}; 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++) { @@ -629,6 +675,8 @@ window.WAM = window.WAM || {}; 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); } diff --git a/js/main.js b/js/main.js index c277d6d..bdff379 100644 --- a/js/main.js +++ b/js/main.js @@ -74,15 +74,55 @@ window.WAM = window.WAM || {}; /* ————————————————— botón "Reproducir demo" ————————————————— */ + var SVG_STOP = ''; + + /* Contador discreto «Paso 3/12» junto al botón (se crea una vez). */ + function contadorDemo(btn) { + var c = porId("demo-paso"); + if (c) return c; + c = document.createElement("span"); + c.id = "demo-paso"; + c.className = "demo-paso"; + c.setAttribute("aria-live", "polite"); + c.setAttribute("role", "status"); + c.hidden = true; + /* estilo mínimo en línea (tabular) mientras no haya regla en iphone.css */ + c.style.fontSize = "12px"; + c.style.fontVariantNumeric = "tabular-nums"; + c.style.opacity = "0.75"; + c.style.whiteSpace = "nowrap"; + btn.parentNode.insertBefore(c, btn); + return c; + } + function pintarBotonDemo() { var btn = porId("btn-reproducir"); if (!btn) return; var activa = WAM.demoActiva(); btn.setAttribute("aria-pressed", activa ? "true" : "false"); + var svg = btn.querySelector("svg"); + if (svg) { + if (!svg.dataset.play) svg.dataset.play = svg.innerHTML; + var deseado = activa ? SVG_STOP : svg.dataset.play; + if (svg.innerHTML !== deseado) svg.innerHTML = deseado; + } var span = btn.querySelector("span"); - if (!span) return; - if (!span.dataset.etiqueta) span.dataset.etiqueta = span.textContent; - span.textContent = activa ? "■ Detener" : span.dataset.etiqueta; + if (span) { + if (!span.dataset.etiqueta) span.dataset.etiqueta = span.textContent; + span.textContent = activa ? "Detener" : span.dataset.etiqueta; + } + if (btn.parentNode) { + var c = contadorDemo(btn); + var p = typeof WAM.progresoDemo === "function" ? WAM.progresoDemo() : null; + if (activa && p && p.total > 0 && p.paso > 0) { + var txt = "Paso " + p.paso + "/" + p.total; + if (c.textContent !== txt) c.textContent = txt; + c.hidden = false; + } else { + c.hidden = true; + c.textContent = ""; + } + } } /* Evita que un doble clic arranque y detenga la demo en el mismo gesto. */ diff --git a/js/store.js b/js/store.js index 7cdecf0..be8c5a7 100644 --- a/js/store.js +++ b/js/store.js @@ -221,10 +221,54 @@ window.WAM = window.WAM || {}; return n; } - function lista(v, max) { - return (Array.isArray(v) ? v : []).slice(0, max || MAX_ITEMS); + /* Contadores de lo que recorta la normalización en curso (ver + WAM.ultimosRecortes). null fuera de WAM.normalizar. */ + var recortes = null; + + function lista(v, max, clave) { + var arr = Array.isArray(v) ? v : []; + var tope = max || MAX_ITEMS; + if (recortes && clave && arr.length > tope) recortes[clave] += arr.length - tope; + return arr.slice(0, tope); } + /* URL de un botón/card de enlace: si venía escrita pero es insegura se + vacía y se marca urlBloqueada, para que siga siendo un botón de enlace + (inerte) y no se convierta en una respuesta rápida con flujo. */ + function urlEnlace(destino, origen) { + var bruta = cad(origen.url).trim(); + destino.url = urlLimpia(bruta, "enlace"); + if (!destino.url && (bruta || origen.urlBloqueada === true || origen.urlBloqueada === "true")) { + destino.urlBloqueada = true; + } + } + + /* Texto en español con lo recortado, o "" si no se recortó nada. */ + WAM.describirRecortes = function (r) { + r = r || WAM.ultimosRecortes; + if (!r) return ""; + var partes = []; + function n(c, uno, varios) { if (r[c] > 0) partes.push(r[c] + " " + (r[c] === 1 ? uno : varios)); } + n("botones", "botón", "botones"); + n("secciones", "sección", "secciones"); + n("filas", "fila", "filas"); + n("cards", "tarjeta", "tarjetas"); + n("respuestas", "respuesta", "respuestas"); + n("profundidad", "respuesta anidada (más de " + MAX_PROFUNDIDAD + " niveles)", + "respuestas anidadas (más de " + MAX_PROFUNDIDAD + " niveles)"); + var txt = ""; + if (r.mensajesOriginales > r.mensajes) { + txt = "Se importaron " + r.mensajes + " de " + r.mensajesOriginales + " mensajes"; + } + if (partes.length) { + var total = 0; + ["botones", "secciones", "filas", "cards", "respuestas", "profundidad"].forEach(function (c) { total += r[c] || 0; }); + var verbo = total === 1 ? "se recortó " : "se recortaron "; + txt += (txt ? "; " + verbo : verbo.charAt(0).toUpperCase() + verbo.slice(1)) + partes.join(", "); + } + return txt; + }; + /* Rellena los campos que faltan por tipo y devuelve un estado válido. Usado por cargar(), resetear() y editor.importarJSON (import / upgrade). - Acepta también un array de mensajes suelto como raíz. @@ -239,6 +283,11 @@ window.WAM = window.WAM || {}; : ((o && typeof o === "object") ? o : {}); entregados = new Set(); + var mensajesSrc = Array.isArray(src.mensajes) ? src.mensajes.length : 0; + recortes = { + mensajes: Math.min(mensajesSrc, 500), mensajesOriginales: mensajesSrc, + botones: 0, secciones: 0, filas: 0, cards: 0, respuestas: 0, profundidad: 0 + }; var conjunto = new Set(); recolectarIds(src, conjunto); var tomar = crearAlocador(conjunto); @@ -270,16 +319,19 @@ window.WAM = window.WAM || {}; }; function respuestas(valor) { - if (profundidad >= MAX_PROFUNDIDAD) return []; - return lista(valor, 20).map(function (r) { return normalizarMensaje(r, true, profundidad + 1); }); + if (profundidad >= MAX_PROFUNDIDAD) { + if (Array.isArray(valor) && valor.length) recortes.profundidad += valor.length; + return []; + } + return lista(valor, 20, "respuestas").map(function (r) { return normalizarMensaje(r, true, profundidad + 1); }); } function botones(valor) { - return lista(valor).map(function (b) { + return lista(valor, MAX_ITEMS, "botones").map(function (b) { var boton = (b && typeof b === "object") ? b : { texto: typeof b === "string" ? 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 = urlLimpia(boton.url, "enlace"); + if (typeof boton.url === "string" || boton.urlBloqueada === true) urlEnlace(out, boton); return out; }); } @@ -338,11 +390,11 @@ window.WAM = window.WAM || {}; msg.body = cad(m.body); msg.footer = cad(m.footer); msg.boton = cad(m.boton); - msg.secciones = lista(m.secciones).map(function (s) { + msg.secciones = lista(m.secciones, MAX_ITEMS, "secciones").map(function (s) { var seccion = (s && typeof s === "object") ? s : {}; return { titulo: cad(seccion.titulo), - filas: lista(seccion.filas).map(function (f) { + filas: lista(seccion.filas, MAX_ITEMS, "filas").map(function (f) { var fila = (f && typeof f === "object") ? f : { titulo: typeof f === "string" ? f : "" }; return { titulo: cad(fila.titulo), @@ -363,15 +415,17 @@ window.WAM = window.WAM || {}; break; case "carousel": msg.body = cad(m.body); - msg.cards = lista(m.cards).map(function (c) { + msg.cards = lista(m.cards, MAX_ITEMS, "cards").map(function (c) { var card = (c && typeof c === "object") ? c : {}; - return { + var salida = { media: urlLimpia(card.media, "media"), texto: cad(card.texto), textoBoton: cad(card.textoBoton), - url: urlLimpia(card.url, "enlace"), + url: "", respuestas: respuestas(card.respuestas) }; + urlEnlace(salida, card); + return salida; }); autoPulsar(); break; @@ -389,7 +443,7 @@ window.WAM = window.WAM || {}; } var cuentaSrc = (src.cuenta && typeof src.cuenta === "object") ? src.cuenta : {}; - return { + var resultado = { version: 1, tema: src.tema === "oscuro" ? "oscuro" : "claro", cuenta: { @@ -404,6 +458,14 @@ window.WAM = window.WAM || {}; return normalizarMensaje(x, false, 0); }) }; + /* Lo recortado queda en WAM.ultimosRecortes y, no enumerable (no se + guarda ni se exporta), en resultado.__recortes. */ + WAM.ultimosRecortes = recortes; + try { + Object.defineProperty(resultado, "__recortes", { value: recortes, enumerable: false, configurable: true }); + } catch (e) { /* entorno sin defineProperty */ } + recortes = null; + return resultado; }; /* ————————————————— persistencia ————————————————— */