Estado inicial del mockup
This commit is contained in:
+1758
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
@@ -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 "&";
|
||||
if (c === "<") return "<";
|
||||
if (c === ">") return ">";
|
||||
if (c === '"') return """;
|
||||
return "'";
|
||||
});
|
||||
}
|
||||
|
||||
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 🍰" })]
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
};
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user