700 lines
34 KiB
JavaScript
700 lines
34 KiB
JavaScript
/* ==========================================================================
|
|
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);
|
|
};
|
|
})();
|