diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e0af025 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +# Allowlist: se excluye todo y solo se re-incluye lo que se publica. +* +!index.html +!css/ +!js/ +!docs/capturas/ +!nginx.conf + +# Exclusiones explícitas (por si alguien relaja la allowlist) +.git +.gitignore +.zcode +node_modules +tests +test-results +playwright-report +docs/referencias +**/*.log +**/.DS_Store +**/Thumbs.db +.env* diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d0d6371 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +*.png binary diff --git a/.gitignore b/.gitignore index 6b0d5b0..eb41bff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ test-results/ playwright-report/ .zcode/ *.log +docs/referencias/*.png +docs/referencias/*.jpg +docs/referencias/*.webp +.tmp-audit/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dbe46f7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 +# WhatsApp API Mockup - sitio estático servido por nginx (puerto 80). +# Coolify: build pack "dockerfile", ports_exposes=80. + +# Etapa 1: reunir solo lo publicable. El contexto ya viene filtrado por +# .dockerignore (allowlist); aquí se copia docs/capturas únicamente si existe, +# para que el build no falle cuando la carpeta no está. +FROM nginx:stable-alpine AS site +WORKDIR /src +COPY . . +RUN set -eux; \ + mkdir -p /out; \ + cp index.html /out/; \ + cp -R css js /out/; \ + if [ -d docs/capturas ]; then mkdir -p /out/docs && cp -R docs/capturas /out/docs/; fi; \ + find /out -type d -exec chmod 0755 {} +; \ + find /out -type f -exec chmod 0644 {} + + +# Etapa 2: imagen final. +FROM nginx:stable-alpine +RUN rm -f /etc/nginx/conf.d/*.conf /usr/share/nginx/html/* +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=site /out/ /usr/share/nginx/html/ +RUN nginx -t + +EXPOSE 80 + +# Endpoint /healthz definido en nginx.conf (sin dependencias). +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1 diff --git a/SPEC.md b/SPEC.md index bb1a531..0decca3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -16,7 +16,8 @@ Bases: `docs/research-api.md` (payloads y límites) y `docs/research-ui.md` (col "nombre": "Café Aurora", "avatar": "", "subtitulo": "en línea", - "verificado": true + "verificado": true, + "avisoCifrado": true }, "mensajes": [ ] } @@ -51,44 +52,122 @@ Convenciones: `src`/`avatar`/`headerMedia`/`media` = dataURL o URL remota; `""` de `WAM.svgPlaceholder(texto)`. Botones con `url` no vacía se renderizan como `` (cta_url, template URL, card de carousel); sin `url` → flujo interactivo con `respuestas`. `WAM.normalizar(o)` rellena campos faltantes por tipo y devuelve el estado válido (import/upgrade). +Campo opcional `autoPulsar` (entero ≥ 0) en `template`/`buttons`/`list`/`carousel` (también en respuestas +anidadas): índice del botón, de la fila aplanada de la lista o de la card que «Reproducir demo» pulsa sola +~1 s después de revelar el mensaje. `normalizar` lo omite si no es válido. + +**Guion frente a sesión.** `estado.mensajes` es el guion (persistente). Lo que se pulsa o escribe en el +teléfono es la sesión: `WAM.sesion` (array en memoria, IDs `s1`, `s2`…), pintada con `anadirMensajeDOM` +sin tocar el estado ni localStorage. Todo `WAM.render()` vuelve al guion: vacía la sesión, cierra la hoja y +cancela demo y flujos (interactivity.js envuelve `WAM.render`). ## 2. Contratos de módulos (namespace único `WAM`) e IDs DOM compartidos ### js/store.js (dueño D) — estado, persistencia, demo - `WAM.CLAVE = "wam-mockup-v1"` · `WAM.estado` (objeto vivo) - `WAM.datosDemo()` → estado nuevo de la demo (§6) · `WAM.cargar()` → estado (LS o demo; normaliza) -- `WAM.guardar()` → void (JSON a localStorage) · `WAM.resetear()` → void (borra LS, estado = demo) -- `WAM.normalizar(o)` → estado · `WAM.generarId()` → string · `WAM.nuevoMensaje(tipo)` → Mensaje vacío +- `WAM.guardar()` → void (JSON a localStorage; la 1.ª llamada escribe al instante y las de los 250 ms + siguientes se agrupan; se vacía en `pagehide`) · `WAM.guardarYa()` → bool (escritura inmediata) +- Error de guardado (cuota llena / LS bloqueado): llama UNA vez al hook opcional `WAM.onErrorGuardar(texto, error)` + (main.js lo pinta con `WAM.avisar`) hasta el siguiente guardado correcto. +- `WAM.resetear()` → void (borra LS, estado = demo con IDs desde `m1`) +- `WAM.normalizar(o)` → estado (acepta un array de mensajes como raíz; IDs duplicados o inválidos se + reasignan; vacía URLs con esquema no permitido; respuestas string → text; conserva campos extra primitivos) +- `WAM.generarId()` → string (nunca repite un ID ya entregado aunque aún no esté en el estado) +- `WAM.crearAlocador(extra?)` → `function tomar()` que reparte IDs únicos frente al estado, a los ya + entregados y a los de `extra` (para clonar mensajes con respuestas anidadas) +- `WAM.urlSegura(url, "enlace"|"media")` → bool (enlace: http(s)/mailto/tel/sin esquema; media: http(s)/blob/ + data:image|video|audio/sin esquema) +- `WAM.nuevoMensaje(tipo)` → Mensaje vacío - `WAM.horaAhora()` → "HH:MM" · `WAM.svgPlaceholder(texto)` → dataURI `data:image/svg+xml` ### js/render.js (dueño B) — pinta el chat, cero listeners de flujo -- `WAM.render()` → void: `renderHeader()` + repinta TODOS los mensajes de `estado.mensajes` en `#chat-cuerpo` +- `WAM.render()` → void: `cerrarSheet()` + `renderHeader()` + (si `cuenta.avisoCifrado !== false`) dos + `li.wa-sistema` (pastilla "Hoy" y aviso de cifrado de negocio) + TODOS los mensajes de `estado.mensajes` - `WAM.renderHeader()` → void: avatar/nombre/subtítulo/verificado en `#chat-header` -- `WAM.renderMensaje(msg, i)` → Node `li.wa-row` con todo el contenido del tipo (incluye `data-*`, § abajo) -- `WAM.anadirMensajeDOM(msg)` → void: append + `scrollAbajo()` (lo usa la interactividad, sin re-render total) +- `WAM.renderMensaje(msg, i)` → Node `li.wa-row` (+ `.grupo` si cambia el emisor, `.ultimo` si es el último + del grupo: solo ese lleva la cola de la burbuja, abajo) con todo el contenido del tipo y sus `data-*` +- `WAM.anadirMensajeDOM(msg)` → void: append + `scrollAbajo()`; recalcula `.grupo`/`.ultimo` contra la + última fila del DOM y reinserta los `li.wa-sistema` si faltan - `WAM.setTema(tema)` → void: `body[data-tema]` + clase `oscuro` en `#pantalla` - `WAM.setEscribiendo(visible)` → void: muestra/oculta `#chat-escribiendo`; subtítulo "escribiendo…" en verde -- `WAM.scrollAbajo()` → void · `WAM.abrirSheet(i)` / `WAM.cerrarSheet()` → void (sheet de lista en `#pantalla`) -- `WAM.tick(estado)` → string SVG inline (✓ gris / ✓✓ gris / ✓✓ celeste `#53bdeb`) +- `WAM.scrollAbajo()` → void · `WAM.abrirSheet(i | Mensaje)` / `WAM.cerrarSheet()` → void (hoja de lista en + `#pantalla`: título = `boton`, filas `button.sheet-fila[data-sec][data-fila]`, botón `.sheet-cerrar`) +- `WAM.abrirOpciones(i | Mensaje, li?)` → void: hoja "Todas las opciones" de un mensaje con >3 botones + (la abre el propio B al pulsar `[data-ver-todas]`). Sus filas de respuesta llevan `data-opcion=""` + y B reenvía el clic al `[data-btn=""]` oculto de la burbuja, que atiende la delegación normal de main.js. +- `WAM.tick(estado)` → string SVG inline (✓ / ✓✓ del color de la hora; ✓✓ leído azul iOS `#027bfc`) +- Botones del teléfono: ` + + + - - - - - - +
+ + + + + + +
Vista previa - +
+ + + +
@@ -150,18 +206,21 @@
-
- - + +
+ +
@@ -180,6 +239,18 @@
+ + + @@ -190,7 +261,9 @@ - + + + diff --git a/js/editor.js b/js/editor.js index c60e551..00493cd 100644 --- a/js/editor.js +++ b/js/editor.js @@ -37,6 +37,16 @@ ["carousel", "Carrusel"] ]; + // Agrupación de la paleta de alta (Básicos / Medios / Interactivos) + var GRUPOS_PALETA = [ + ["Básicos", ["text", "location", "contact"]], + ["Medios", ["image", "video", "audio", "document", "sticker"]], + ["Interactivos", ["template", "buttons", "list", "cta_url", "carousel"]] + ]; + + // Nombre corto para la paleta (el largo va en el tooltip) + var CORTO = { buttons: "Botones", cta_url: "Botón URL" }; + // Tipos de cabecera de template/buttons (SPEC §1.9/§1.10) var TIPOS_HEADER = [ ["texto", "Texto"], @@ -95,10 +105,24 @@ ------------------------------------------------------------------ */ var abiertas = Object.create(null); var panel = null; - var avisoEl = null; - var avisoTimer = 0; var panelEnlazado = false; - var obsTema = null; + var cuentaDecidida = false; // el
de Cuenta se abre/cierra solo la 1.ª vez + var ultimaAbierta = null; // id top-level abierto más reciente (posición de alta) + var insertarTras = null; // id tras el que insertar (botón «+» de una tarjeta) + var ultimoTipo = "text"; + var ultimoEnlazado = null; // id resaltado en la vista previa + var tResalte = 0; + var contadorIds = 0; // ids únicos para ------------------------------------------------------------------ */ + // "Restaurante Luna" → "restaurante-luna" (sin acentos ni símbolos) + function slug(texto) { + var s = String(texto || "").toLowerCase(); + if (s.normalize) s = s.normalize("NFD").replace(/[̀-ͯ]/g, ""); + s = s.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60); + return s; + } + + // Nombre del archivo exportado: demo guardada activa › nombre de la cuenta › wam-demo + function nombreExportacion() { + var base = ""; + if (typeof WAM.nombreDemoActiva === "function") { try { base = WAM.nombreDemoActiva(); } catch (e) { base = ""; } } + if (!base && WAM.estado && WAM.estado.cuenta) base = WAM.estado.cuenta.nombre; + return (slug(base) || "wam-demo") + ".json"; + } + WAM.exportarJSON = function () { try { + if (typeof WAM.guardarYa === "function") WAM.guardarYa(); + var nombre = nombreExportacion(); var datos = JSON.stringify(WAM.estado, null, 2); var blob = new Blob([datos], { type: "application/json" }); var url = URL.createObjectURL(blob); - var a = h("a", { href: url, download: "wam-demo.json" }); + var a = h("a", { href: url, download: nombre }); document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(url); }, 1500); - aviso("Demo exportada como wam-demo.json"); + aviso("Demo exportada como " + nombre); } catch (e) { aviso("No se pudo exportar el JSON", "error"); } @@ -572,6 +888,7 @@ function procesarArchivo(ruta, archivo, origenEl) { if (!ruta || !archivo) return; WAM.aDataUrl(archivo).then(function (dataURL) { + snap(); WAM.setCampo(ruta, dataURL); if (ruta === "cuenta.avatar") refrescarAvatarVista(); else repintarDesde(origenEl); @@ -603,15 +920,33 @@ lector.onload = function () { try { var datos = JSON.parse(String(lector.result)); - if (!datos || typeof datos !== "object" || Array.isArray(datos)) throw new Error("no es un objeto"); - if (typeof WAM.normalizar === "function") datos = WAM.normalizar(datos); + if (!datos || typeof datos !== "object") throw new Error("no es un objeto"); + var previo = WAM.estado; + // El estado se sustituye entero: no se reservan los IDs del vivo + WAM.estado = null; + try { + if (typeof WAM.normalizar === "function") datos = WAM.normalizar(datos); + } finally { + WAM.estado = previo; + } + if (!datos || !Array.isArray(datos.mensajes)) throw new Error("sin mensajes"); + snap(); WAM.estado = datos; - if (typeof WAM.guardar === "function") WAM.guardar(); + if (typeof WAM.guardarYa === "function") WAM.guardarYa(); + else if (typeof WAM.guardar === "function") WAM.guardar(); + marcarEditado(); + try { window.localStorage.removeItem("wam-demo-activa"); } catch (e2) { /* sin LS */ } if (typeof WAM.setTema === "function") WAM.setTema(WAM.estado.tema); abiertas = Object.create(null); + ultimaAbierta = null; + insertarTras = null; if (typeof WAM.render === "function") WAM.render(); WAM.renderEditor(); - aviso("Demo importada correctamente"); + var n = WAM.estado.mensajes.length; + WAM.toast("Demo importada: " + n + " mensaje" + (n === 1 ? "" : "s"), + typeof WAM.deshacer === "function" ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null); + var primero = listaNodo() && listaNodo().querySelector(".ed-cab-principal"); + if (primero) primero.focus(); } catch (e) { aviso("El archivo no contiene una demo válida", "error"); } @@ -629,18 +964,18 @@ var t = ""; switch (m.tipo) { case "text": t = m.texto; break; - case "image": t = "Imagen" + (m.caption ? " · " + m.caption : ""); break; - case "video": t = "Vídeo" + (m.caption ? " · " + m.caption : ""); break; + case "image": + case "video": t = m.caption || (m.src ? "(sin caption)" : "(placeholder, sin caption)"); break; case "audio": t = (m.voz ? "Nota de voz" : "Audio") + (m.duracion ? " · " + m.duracion : ""); break; case "document": t = m.filename || "Documento"; break; case "sticker": t = "Sticker" + (m.src ? "" : " (placeholder)"); break; case "location": t = m.nombre || m.direccion || "Ubicación"; break; case "contact": t = m.nombre || "Contacto"; break; - case "template": t = "Plantilla" + (m.body ? " · " + m.body : ""); break; - case "buttons": t = m.body || "Botones rápidos"; break; - case "list": t = "Lista · " + (m.header || m.boton || "sin título"); break; - case "cta_url": t = (m.textoBoton || "CTA") + (m.url ? " · " + m.url : ""); break; - case "carousel": t = "Carrusel · " + ((m.cards || []).length) + " tarjetas"; break; + case "template": t = (m.headerTipo === "texto" && m.headerTexto ? m.headerTexto + " · " : "") + (m.body || ""); break; + case "buttons": t = m.body; break; + case "list": t = m.header || m.body; break; + case "cta_url": t = m.body || m.textoBoton; break; + case "carousel": t = ((m.cards || []).length) + " tarjetas" + (m.body ? " · " + m.body : ""); break; default: t = m.tipo || ""; } t = String(t === undefined || t === null ? "" : t).replace(/\s+/g, " ").trim(); @@ -648,6 +983,245 @@ return t || "(vacío)"; } + /* ------------------------------------------------------------------ + Validación: lo que la Cloud API rechazaría (docs/research-api.md) + validar(m) → [avisos]. Recorre también las respuestas encadenadas. + ------------------------------------------------------------------ */ + function largo(v) { return String(v === undefined || v === null ? "" : v).length; } + function vacioTxt(v) { return !String(v === undefined || v === null ? "" : v).trim(); } + function urlValida(v) { return /^https?:\/\/[^\s/$.?#][^\s]*$/i.test(String(v || "").trim()); } + + function validar(m, prefijo, profundidad) { + var av = []; + if (!m || typeof m !== "object") return av; + var p = prefijo || ""; + var add = function (t) { av.push(p + t); }; + var lim = function (v, max, nombre) { if (largo(v) > max) add(nombre + " supera " + max + " caracteres (" + largo(v) + ")"); }; + var cabecera = function () { + if (m.headerTipo === "texto") { + if (vacioTxt(m.headerTexto)) add("La cabecera de texto está vacía"); + lim(m.headerTexto, 60, "La cabecera"); + } + }; + switch (m.tipo) { + case "text": + if (vacioTxt(m.texto)) add("El texto está vacío"); + lim(m.texto, 4096, "El texto"); + break; + case "image": + case "video": + case "document": + lim(m.caption, 1024, "El caption"); + if (m.tipo === "document" && !/\.[a-z0-9]{1,5}$/i.test(String(m.filename || "").trim())) { + add("El nombre de archivo necesita extensión (p. ej. .pdf)"); + } + break; + case "location": { + var la = Number(m.latitud), lo = Number(m.longitud); + if (vacioTxt(m.latitud) || isNaN(la) || la < -90 || la > 90) add("Latitud no válida (entre -90 y 90)"); + if (vacioTxt(m.longitud) || isNaN(lo) || lo < -180 || lo > 180) add("Longitud no válida (entre -180 y 180)"); + break; + } + case "contact": + if (vacioTxt(m.nombre)) add("El contacto necesita un nombre"); + break; + case "template": { + cabecera(); + if (vacioTxt(m.body)) add("El cuerpo es obligatorio"); + lim(m.body, 1024, "El cuerpo"); + lim(m.footer, 60, "El pie"); + var bt = m.botones || []; + if (bt.length > LIMITES.botonesPlantilla) add("Máximo " + LIMITES.botonesPlantilla + " botones"); + var conUrl = bt.filter(function (b) { return b && !vacioTxt(b.url); }).length; + if (conUrl > 2) add("Máximo 2 botones con URL (" + conUrl + ")"); + bt.forEach(function (b, i) { + if (vacioTxt(b.texto)) add("Botón " + (i + 1) + ": sin texto"); + else lim(b.texto, 25, "Botón " + (i + 1)); + if (!vacioTxt(b.url) && !urlValida(b.url)) add("Botón " + (i + 1) + ": URL no válida (usa https://…)"); + }); + break; + } + case "buttons": { + cabecera(); + if (vacioTxt(m.body)) add("El cuerpo es obligatorio"); + lim(m.body, 1024, "El cuerpo"); + lim(m.footer, 60, "El pie"); + var bq = m.botones || []; + if (!bq.length) add("Necesita al menos 1 botón"); + if (bq.length > LIMITES.botonesRapidos) add("Máximo " + LIMITES.botonesRapidos + " botones rápidos"); + var vistos = Object.create(null); + bq.forEach(function (b, i) { + if (vacioTxt(b.texto)) { add("Botón " + (i + 1) + ": sin texto"); return; } + lim(b.texto, 20, "Botón " + (i + 1)); + var k = String(b.texto).trim().toLowerCase(); + if (vistos[k]) add("Botón " + (i + 1) + ": texto repetido («" + b.texto + "»)"); + vistos[k] = true; + }); + break; + } + case "list": { + if (vacioTxt(m.body)) add("El cuerpo es obligatorio"); + lim(m.body, 4096, "El cuerpo"); + lim(m.header, 60, "La cabecera"); + lim(m.footer, 60, "El pie"); + if (vacioTxt(m.boton)) add("La etiqueta del botón es obligatoria"); + lim(m.boton, 20, "La etiqueta del botón"); + var secs = m.secciones || []; + var filas = totalFilas(secs); + if (!secs.length) add("Necesita al menos 1 sección"); + if (secs.length > LIMITES.secciones) add("Máximo " + LIMITES.secciones + " secciones"); + if (!filas) add("La lista no tiene filas"); + if (filas > LIMITES.filas) add("Máximo " + LIMITES.filas + " filas en total (" + filas + ")"); + secs.forEach(function (s, si) { + if (secs.length > 1 && vacioTxt(s.titulo)) add("Sección " + (si + 1) + ": el título es obligatorio con varias secciones"); + lim(s.titulo, 24, "Título de la sección " + (si + 1)); + (s.filas || []).forEach(function (f, fi) { + var n = "Fila " + (si + 1) + "." + (fi + 1); + if (vacioTxt(f.titulo)) add(n + ": sin título"); + lim(f.titulo, 24, n); + lim(f.descripcion, 72, n + " (descripción)"); + }); + }); + break; + } + case "cta_url": + if (vacioTxt(m.body)) add("El cuerpo es obligatorio"); + lim(m.body, 1024, "El cuerpo"); + lim(m.footer, 60, "El pie"); + if (vacioTxt(m.textoBoton)) add("El texto del botón es obligatorio"); + lim(m.textoBoton, 20, "El texto del botón"); + if (vacioTxt(m.url)) add("Falta la URL del botón"); + else if (!urlValida(m.url)) add("URL no válida (usa https://…)"); + break; + case "carousel": { + var cs = m.cards || []; + if (cs.length < LIMITES.tarjetasMin) add("El carrusel necesita al menos " + LIMITES.tarjetasMin + " tarjetas"); + if (cs.length > LIMITES.tarjetasMax) add("Máximo " + LIMITES.tarjetasMax + " tarjetas"); + lim(m.body, 1024, "El cuerpo"); + cs.forEach(function (c, ci) { + var n = "Tarjeta " + (ci + 1); + lim(c.texto, 160, n); + if ((String(c.texto || "").match(/\n/g) || []).length > 2) add(n + ": máximo 2 saltos de línea"); + if (vacioTxt(c.textoBoton)) add(n + ": el botón necesita texto"); + lim(c.textoBoton, 20, n + " (botón)"); + if (!vacioTxt(c.url) && !urlValida(c.url)) add(n + ": URL no válida (usa https://…)"); + }); + break; + } + default: break; + } + if ((profundidad || 0) < 6) { + origenesRespuesta(m).forEach(function (o) { + o.respuestas.forEach(function (r) { + av = av.concat(validar(r, p + "Respuesta de «" + o.etiqueta + "»: ", (profundidad || 0) + 1)); + }); + }); + } + return av; + } + + // Elementos pulsables de un mensaje que pueden tener respuestas (sin URL) + function origenesRespuesta(m) { + var out = []; + var agregar = function (etiqueta, x) { + if (!x || !vacioTxt(x.url)) return; // con URL abre el enlace: no hay flujo + out.push({ etiqueta: String(etiqueta || "sin texto"), respuestas: Array.isArray(x.respuestas) ? x.respuestas : [] }); + }; + if (m.tipo === "template" || m.tipo === "buttons") (m.botones || []).forEach(function (b) { agregar(b.texto, b); }); + if (m.tipo === "list") (m.secciones || []).forEach(function (s) { (s.filas || []).forEach(function (f) { agregar(f.titulo, f); }); }); + if (m.tipo === "carousel") (m.cards || []).forEach(function (c) { agregar(c.textoBoton, c); }); + return out; + } + + function contarFlujos(m) { + var o = origenesRespuesta(m); + var con = o.filter(function (x) { return x.respuestas.length > 0; }).length; + return { con: con, sin: o.length - con, total: o.length }; + } + + // Chips de la cabecera de la tarjeta: ⚠ avisos y ↳ flujos + function chipsDe(m) { + var frag = document.createDocumentFragment(); + var av = validar(m); + if (av.length) { + frag.appendChild(h("span", { + class: "ed-chip ed-chip-aviso", + title: av.join("\n"), + "aria-label": av.length + (av.length === 1 ? " aviso" : " avisos") + }, [h("span", { "aria-hidden": "true", text: "⚠ " + av.length })])); + } + var f = contarFlujos(m); + if (f.total) { + var txt = "↳ " + f.con + (f.sin ? "/" + f.total : ""); + var titulo = f.con + (f.con === 1 ? " flujo con respuesta" : " flujos con respuesta") + + (f.sin ? " · " + f.sin + " sin respuesta" : ""); + frag.appendChild(h("span", { + class: "ed-chip ed-chip-flujo" + (f.sin ? " ed-chip-flujo-incompleto" : ""), + title: titulo, + "aria-label": titulo + }, [h("span", { "aria-hidden": "true", text: txt })])); + } + return frag; + } + + // Caja de avisos dentro del cuerpo de la tarjeta abierta + function cajaValidacion(m) { + var av = validar(m); + var caja = h("div", { class: "ed-validacion", hidden: av.length ? null : true }); + if (av.length) { + caja.appendChild(h("p", { class: "ed-validacion-titulo", text: "La Cloud API rechazaría este mensaje:" })); + var ul = h("ul"); + av.slice(0, 8).forEach(function (t) { ul.appendChild(h("li", { text: t })); }); + if (av.length > 8) ul.appendChild(h("li", { text: "… y " + (av.length - 8) + " más" })); + caja.appendChild(ul); + } + return caja; + } + + // Refresca resumen, chips y avisos de una tarjeta ya pintada (sin repintarla) + function refrescarTarjetaInfo(t) { + if (!t) return; + var ruta = t.getAttribute("data-ruta-msg"); + var m = ruta ? rutaGet(ruta) : null; + if (!m) return; + var cab = t.querySelector(":scope > .ed-tarjeta-cab"); + if (!cab) return; + var res = cab.querySelector(".ed-resumen"); + if (res) res.textContent = resumenDe(m); + var chips = cab.querySelector(".ed-chips"); + if (chips) { + while (chips.firstChild) chips.removeChild(chips.firstChild); + if (!t.classList.contains("ed-anidada")) chips.appendChild(chipsDe(m)); + } + if (!t.classList.contains("ed-anidada")) { + var vieja = t.querySelector(":scope > .ed-cuerpo > .ed-validacion"); + if (vieja) vieja.parentNode.replaceChild(cajaValidacion(m), vieja); + } + } + + // Contador global de avisos en la sección Mensajes + function actualizarAvisosGlobal() { + var el = document.getElementById("ed-avisos-global"); + if (!el) return; + var mensajes = (WAM.estado && WAM.estado.mensajes) || []; + var total = 0, primero = -1; + mensajes.forEach(function (m, i) { + var n = validar(m).length; + if (n && primero < 0) primero = i; + total += n; + }); + el.hidden = !total; + el.setAttribute("data-primero", primero >= 0 ? mensajes[primero].id : ""); + el.textContent = "⚠ " + total + (total === 1 ? " aviso" : " avisos"); + el.title = total ? "Ir al primer mensaje con avisos (n.º " + (primero + 1) + ")" : ""; + } + + var tAvisosGlobal = 0; + function programarAvisosGlobal() { + clearTimeout(tAvisosGlobal); + tAvisosGlobal = setTimeout(actualizarAvisosGlobal, 250); + } + /* ------------------------------------------------------------------ Constructores de formulario (nodos ed-*) ------------------------------------------------------------------ */ @@ -658,41 +1232,55 @@ function edCampo(etiqueta, ruta, valor, opts) { opts = opts || {}; var maxContador = opts.limite || (opts.contar ? opts.max : 0); + var id = opts.id || nuevoIdCampo(); var input = h("input", { type: opts.tipo || "text", class: "ed-input", value: predeterminado(valor, ""), - id: opts.id || null, + id: id, "data-ruta": ruta, placeholder: opts.placeholder || "", maxlength: opts.max || null, + inputmode: opts.inputmode || null, autocomplete: "off", - spellcheck: opts.spell === false ? "false" : null + spellcheck: opts.spell === false ? "false" : null, + "aria-describedby": maxContador ? id + "-cont" : null }); - var hijos = [h("span", { class: "ed-campo-e", text: etiqueta })]; + return campoEnvuelto(etiqueta, id, input, valor, maxContador, opts); + } + + /* El contenedor es un
: un