WIP: mejoras de fidelidad, editor, lógica y suite e2e (checkpoint tras reinicio)

This commit is contained in:
urieljareth
2026-09-27 20:46:51 -06:00
parent dc6e31a9d7
commit 9a5f21badf
33 changed files with 7719 additions and 1129 deletions
+21
View File
@@ -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*
+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.png binary
+4
View File
@@ -3,3 +3,7 @@ test-results/
playwright-report/ playwright-report/
.zcode/ .zcode/
*.log *.log
docs/referencias/*.png
docs/referencias/*.jpg
docs/referencias/*.webp
.tmp-audit/
+30
View File
@@ -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
+116 -23
View File
@@ -16,7 +16,8 @@ Bases: `docs/research-api.md` (payloads y límites) y `docs/research-ui.md` (col
"nombre": "Café Aurora", "nombre": "Café Aurora",
"avatar": "", "avatar": "",
"subtitulo": "en línea", "subtitulo": "en línea",
"verificado": true "verificado": true,
"avisoCifrado": true
}, },
"mensajes": [ ] "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 `<a target="_blank">` de `WAM.svgPlaceholder(texto)`. Botones con `url` no vacía se renderizan como `<a target="_blank">`
(cta_url, template URL, card de carousel); sin `url` → flujo interactivo con `respuestas`. (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). `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 ## 2. Contratos de módulos (namespace único `WAM`) e IDs DOM compartidos
### js/store.js (dueño D) — estado, persistencia, demo ### js/store.js (dueño D) — estado, persistencia, demo
- `WAM.CLAVE = "wam-mockup-v1"` · `WAM.estado` (objeto vivo) - `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.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.guardar()` → void (JSON a localStorage; la 1.ª llamada escribe al instante y las de los 250 ms
- `WAM.normalizar(o)` → estado · `WAM.generarId()` → string · `WAM.nuevoMensaje(tipo)` → Mensaje vacío 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` - `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 ### 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.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.renderMensaje(msg, i)` → Node `li.wa-row` (+ `.grupo` si cambia el emisor, `.ultimo` si es el último
- `WAM.anadirMensajeDOM(msg)` → void: append + `scrollAbajo()` (lo usa la interactividad, sin re-render total) 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.setTema(tema)` → void: `body[data-tema]` + clase `oscuro` en `#pantalla`
- `WAM.setEscribiendo(visible)` → void: muestra/oculta `#chat-escribiendo`; subtítulo "escribiendo…" en verde - `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.scrollAbajo()` → void · `WAM.abrirSheet(i | Mensaje)` / `WAM.cerrarSheet()` → void (hoja de lista en
- `WAM.tick(estado)` → string SVG inline (✓ gris / ✓✓ gris / ✓✓ celeste `#53bdeb`) `#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="<j>"`
y B reenvía el clic al `[data-btn="<j>"]` 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: `<button type="button" class="qr-btn">` (Enter/Espacio nativos); URLs solo con
esquema `http(s):`, `mailto:` o `tel:` (otro esquema → botón inerte `.qr-btn-inactiva`); `tel:` pinta
el icono de llamada. Con >3 botones se ven 2 + "Ver todas las opciones" y el resto va `hidden` en la fila.
### js/editor.js (dueño C) — formularios; listeners SOLO dentro de `#panel-editor` e inputs propios ### js/editor.js (dueño C) — formularios; listeners SOLO dentro de `#panel-editor` e inputs propios
- `WAM.renderEditor()` → void: formulario de cuenta + repinta tarjetas - `WAM.renderEditor()` → void: formulario de cuenta + repinta tarjetas
- `WAM.renderTarjetas()` → void: una tarjeta colapsable por mensaje en `#lista-mensajes` (todos los campos del tipo) - `WAM.renderTarjetas()` → void: una tarjeta colapsable por mensaje en `#lista-mensajes` (todos los campos del tipo)
- `WAM.anadirMensaje(tipo)` → void · `WAM.moverMensaje(i, delta)` → void · `WAM.duplicarMensaje(i)` → void - `WAM.anadirMensaje(tipo, indice?)` → void: inserta en `indice` (defecto: al final) con texto de ejemplo y hora =
- `WAM.borrarMensaje(i)` → void · `WAM.setCampo(ruta, valor)` → void (ruta `"mensajes.3.body"`; muta + guardar + `WAM.render()`) anterior + 1 min; abre su tarjeta y enfoca su primer campo · `WAM.moverMensaje(i, delta)` · `WAM.duplicarMensaje(i)`
- `WAM.aDataUrl(archivo)` → Promise<dataURL> (FileReader) · `WAM.exportarJSON()` → void (Blob + `<a download>`) (ids del clon y de sus respuestas con un solo alocador `WAM.crearAlocador`)
- `WAM.importarJSON(archivo)` → Promise<void> (parse + `normalizar` + guardar + re-render total) - `WAM.borrarMensaje(i)` → void: sin `confirm()`; toast «Mensaje borrado · Deshacer» (6 s)
- `WAM.setCampo(ruta, valor, opts?)` → void (ruta `"mensajes.3.body"`; muta + guardar + `WAM.render()`).
`opts.diferido` (edición tecla a tecla): el chat se repinta en el siguiente frame y SOLO la fila
`li.wa-row[data-id]` afectada (usa `WAM.renderUno(i)` si existe; si no, `renderMensaje` + `replaceChild`), sin scroll
- `WAM.aDataUrl(archivo)` → Promise<dataURL> (FileReader) · `WAM.exportarJSON()` → void (Blob + `<a download>`;
nombre = slug de la demo guardada activa o de `cuenta.nombre`, p. ej. `cafe-aurora.json`; `wam-demo.json` si vacío)
- `WAM.importarJSON(archivo)` → Promise<void> (parse + `normalizar` sin reservar los IDs del estado vivo + guardar +
re-render total; deja punto de deshacer)
- `WAM.toast(texto, {tipo:"error", ms, accion:{texto, fn}})` → fn cerrar: aviso `#ed-toasts` fijo abajo a la izquierda
- `WAM.enfocarEnChat(id)` → bool: centra y resalta 1,2 s (`.ed-resaltado`) la burbuja en `#chat-cuerpo`; lo usa el
editor al abrir/enfocar una tarjeta · `WAM.abrirTarjeta(id)` → bool: abre y enfoca la tarjeta (desde el chat)
- Validación propia (no pública): chip ⚠ por tarjeta, caja «La Cloud API rechazaría…» y `#ed-avisos-global`;
chip ↳ con botones/filas/cards con y sin respuesta.
### js/escenarios.js (C) — datos, sin DOM
- `WAM.ESCENARIOS` → `[{ id, nombre, descripcion, datos() }]` (en blanco, restaurante, e-commerce, clínica,
inmobiliaria, soporte, cobranza; textos ficticios) · `WAM.datosEscenario(id)` → estado crudo (se normaliza al usarlo)
### js/ux.js (C) — capa de UX de la app; `WAM.initUX()` lo llama `renderEditor()` una sola vez
- Historial: `WAM.puntoDeshacer()` (instantánea antes de mutar, máx. 30; los strings/dataURL se comparten),
`WAM.deshacer()`, `WAM.rehacer()`, `WAM.puedeDeshacer()`; `#btn-deshacer` / `#btn-rehacer`, Ctrl+Z / Ctrl+Mayús+Z
fuera de campos de texto
- `WAM.confirmarReemplazo("reset"|"importar", ejecutar)`: `<dialog>` con «Exportar antes» (main.js lo usa en
`#btn-reset` y `#btn-importar`; sin mensajes no pregunta). Reset deja punto de deshacer.
- `WAM.abrirDemos(seccion?)`: escenarios + demos guardadas (`wam-demos` = índice, `wam-demo-<id>` = estado,
`wam-demo-activa`) con guardar/abrir/renombrar/duplicar/borrar · `WAM.nombreDemoActiva()` · `WAM.abrirAtajos()`
- `WAM.marcarEditado()` → indicador `#estado-guardado` («Guardando…» → «Guardado · hace N s»; rojo con
`WAM.onErrorGuardar`, que ux.js sustituye por toast con «Exportar JSON»)
- `WAM.setVistaMovil("editor"|"vista")` (≤ 900 px; `body.vista-movil`, `wam-ui-vista`) · `WAM.recordarEnfoque(id)`
- Atajos globales (ignorados en campos): P, Espacio, T, F, N, Esc, Ctrl+S, Ctrl+Z, ?; Alt+↑/↓ mueve en el editor.
Alt+clic o doble clic en una burbuja abre su tarjeta. Envuelve `WAM.anadirMensajeDOM` para anunciar el mensaje
en `#wam-anuncios` (sr-only) y quita `aria-live` de `#chat-cuerpo`.
### js/interactivity.js (dueño D) — flujos, typing, demo ### js/interactivity.js (dueño D) — flujos, typing, demo
- `WAM.resolverFlujo(el)` → `{ texto, respuestas }` a partir de los `data-*` del elemento clicado - `WAM.resolverFlujo(el)` → `{ texto, respuestas }` a partir de los `data-*` del elemento clicado
- `WAM.flujo(texto, respuestas)` → Promise: burbuja OUT con `texto` (ticks auto sent→delivered→read, ~800 ms c/u), - `WAM.flujo(texto, respuestas)` → Promise: burbuja OUT con `texto` (ticks auto sent→delivered→read, ~800 ms c/u),
typing ON ~900 ms, luego por cada `respuestas`: typing ON ~900 ms → `anadirMensajeDOM` → scroll; typing OFF typing ON ~900 ms, luego por cada `respuestas`: typing ON ~900 ms → `anadirMensajeDOM` → scroll; typing OFF
- `WAM.enviarLibre(texto)` → void: burbuja OUT del composer (hora `horaAhora()`, estado `sent`, ticks auto) - `WAM.enviarLibre(texto)` → void: burbuja OUT del composer (hora `horaAhora()`, estado `sent`, ticks auto)
- `WAM.reproducirDemo()` / `WAM.detenerDemo()` → void: vacía `#chat-cuerpo` y revela `estado.mensajes` uno a uno - `WAM.flujo(texto, respuestas, cita?)`: `cita` opcional `{nombre, titulo, extracto, miniatura, foto}` → bloque
`.wa-cita` dentro de la burbuja OUT. La burbuja sale al instante; las respuestas de flujos simultáneos se
encolan. El indicador «escribiendo…» es un contador de referencias.
- `WAM.resolverFlujo(el)` → `{ texto, respuestas, cita, msg, tipo }`; resuelve el mensaje por el `data-id` de la
fila (válido para mensajes de sesión); etiquetas vacías = las del render («Botón n», «Ver más», «Opción»);
fila de lista: `titulo + "
" + descripcion`.
- `WAM.mensajeDeElemento(el)` → Mensaje · `WAM.abrirLista(msg)` → abre la hoja (`abrirSheet(i | msg)`)
- `WAM.marcarUsado(el)` → clase `.usado` (gris, sin más clics) · `WAM.reiniciarChat()` (= `render()`)
- `WAM.sesionAGuion()` → n: añade la sesión al guion (acción explícita) + guardar + re-render
- `WAM.getVelocidad()` / `WAM.setVelocidad(0.5|1|2)` (persistida en `wam-mockup-v1-velocidad`)
- `WAM.demoActiva()` → bool · hook opcional `WAM.onDemoChange(activa)`
- `WAM.reproducirDemo()` / `WAM.detenerDemo()` → void: cancela flujos en curso, vacía `#chat-cuerpo` y revela
`estado.mensajes` uno a uno (pulsando solas las opciones `autoPulsar`); detener vuelve al guion
(~600 ms; mensaje `in` precedido de typing ~700 ms; cancelable) (~600 ms; mensaje `in` precedido de typing ~700 ms; cancelable)
- `WAM.espera(ms)` → Promise cancelable (todo timer pasa por aquí para poder detener) - `WAM.espera(ms, grupo?)` → Promise cancelable; grupo `"flujo"` (defecto) o `"demo"`; la duración se divide
por la velocidad (todo timer pasa por aquí para poder detener)
### js/main.js (dueño D) — bootstrap ### js/main.js (dueño D) — bootstrap
- `WAM.avisar(texto, {tipo:"info"|"error", duracion?})` → aviso flotante `#wam-aviso` (estilos por CSSOM)
- Controles de `#barra-movil`: `#sel-velocidad`, `#btn-reiniciar`, `#btn-reproducir` (estilos en `css/interactividad.css`, D).
Teclado en `#pantalla`: Enter/Espacio activan `[data-flujo]`/`[data-ver-todas]`/`.sheet-cerrar`; Escape cierra la hoja.
`#btn-reset` pide confirmación. Enlaces del chat con esquema no permitido no se abren.
- `WAM.init()` → void (se ejecuta al final del body): `estado = cargar()`; `setTema`; `render()`; `renderEditor()`; - `WAM.init()` → void (se ejecuta al final del body): `estado = cargar()`; `setTema`; `render()`; `renderEditor()`;
delegación de clics en `#chat-cuerpo` (ver data-*), submit de `#composer`, `#btn-reproducir`, toolbar delegación de clics en `#chat-cuerpo` (ver data-*), submit de `#composer`, `#btn-reproducir`, toolbar
(`#btn-tema`, `#btn-presentacion`, `#btn-exportar`, `#btn-importar`→`#importar-archivo`, `#btn-reset`) (`#btn-tema`, `#btn-presentacion`, `#btn-exportar`, `#btn-importar`→`#importar-archivo`, `#btn-reset`)
@@ -99,7 +178,10 @@ de `WAM.svgPlaceholder(texto)`. Botones con `url` no vacía se renderizan como `
| `app`, `panel-editor`, `panel-movil`, `toolbar` | A | shell de dos columnas y barra superior | | `app`, `panel-editor`, `panel-movil`, `toolbar` | A | shell de dos columnas y barra superior |
| `btn-tema`, `btn-presentacion`, `btn-exportar`, `btn-importar`, `btn-reset`, `importar-archivo` (hidden) | A/C | toolbar; main (D) los enlaza | | `btn-tema`, `btn-presentacion`, `btn-exportar`, `btn-importar`, `btn-reset`, `importar-archivo` (hidden) | A/C | toolbar; main (D) los enlaza |
| `editor-cuenta` | C | form identidad: `cuenta-nombre`, `cuenta-subtitulo`, `cuenta-verificado`, `cuenta-avatar-vista`, `cuenta-avatar-archivo`, `cuenta-avatar-url` | | `editor-cuenta` | C | form identidad: `cuenta-nombre`, `cuenta-subtitulo`, `cuenta-verificado`, `cuenta-avatar-vista`, `cuenta-avatar-archivo`, `cuenta-avatar-url` |
| `nuevo-tipo` (paleta de tarjetas de los 13 tipos), `nuevo-tipo-valor` (hidden), `btn-anadir`, `lista-mensajes` | C | alta por paleta (el tipo elegido vive en `#nuevo-tipo-valor` y `data-valor`) y tarjetas de mensajes | | `nuevo-tipo` (paleta `role=toolbar` de los 13 tipos: un clic AÑADE), `nuevo-tipo-valor` (hidden), `btn-anadir` («+ Otro …»), `ed-destino-alta`, `lista-mensajes` | C | alta por paleta (el último tipo usado vive en `#nuevo-tipo-valor` y `data-valor`) y tarjetas de mensajes |
| `bloque-cuenta` (`<details>`), `cuenta-resumen`, `ed-avisos-global`, `btn-ver-telefono` | C | cuenta plegable, contador de avisos, salto a la vista previa (≤ 900 px) |
| `btn-deshacer`, `btn-rehacer`, `btn-demos`, `btn-atajos`, `btn-mas` + `tb-menu`, `estado-guardado` | C (ux.js) | toolbar: historial, demos, atajos, menú «⋯» (≤ 900 px), indicador de guardado |
| `tabs-movil`, `tab-editor`, `tab-vista` | C (ux.js) | pestañas Editor / Vista previa (≤ 900 px) |
| `barra-movil`, `btn-reproducir` | A | barra sobre el teléfono (Reproducir demo) | | `barra-movil`, `btn-reproducir` | A | barra sobre el teléfono (Reproducir demo) |
| `iphone`, `pantalla` (class `wa`), `isla`, `statusbar` ("9:41" + señal/wifi/batería SVG), `home-indicator` | A | marco iPhone 393×852 | | `iphone`, `pantalla` (class `wa`), `isla`, `statusbar` ("9:41" + señal/wifi/batería SVG), `home-indicator` | A | marco iPhone 393×852 |
| `chat-header` (`chat-avatar`, `chat-nombre`, `chat-subtitulo`, `chat-verificado`), `chat-cuerpo`, `chat-escribiendo` | A estructura / B contenido | header del chat, scroll de mensajes, typing | | `chat-header` (`chat-avatar`, `chat-nombre`, `chat-subtitulo`, `chat-verificado`), `chat-cuerpo`, `chat-escribiendo` | A estructura / B contenido | header del chat, scroll de mensajes, typing |
@@ -116,10 +198,15 @@ Contrato de clics B→D (delegación en `#chat-cuerpo`): B estampa en cada eleme
- `css/iphone.css` (A): `#panel-movil`, `.iphone` (393×852, radio 55, negro), `.pantalla`, `.island` - `css/iphone.css` (A): `#panel-movil`, `.iphone` (393×852, radio 55, negro), `.pantalla`, `.island`
(126×37, top 11), `.statusbar` (54 pt), `.home-indicator` (139×5). (126×37, top 11), `.statusbar` (54 pt), `.home-indicator` (139×5).
- `css/whatsapp.css` (B): todo bajo `.wa`; variables `--wa-*` del research-ui y overrides `.wa.oscuro`; - `css/whatsapp.css` (B): todo bajo `.wa`; variables `--wa-*` del research-ui y overrides `.wa.oscuro`;
clases: `.wa-row .in .out .bubble .meta .tick .tick-read`, cola con `clip-path`, `.qr-btns .qr-btn`, clases: `.wa-row .in .out .bubble .meta .tick .tick-read`, cola con `clip-path` (abajo, en `.ultimo`), `.qr-btns .qr-btn .qr-ico`, `.wa-sistema`,
`.tpl-*`, `.cta-*`, `.carousel .card`, `.doc-* .audio-* .loc-* .contact-* .sticker-*`, `.sheet*`; `.tpl-*`, `.cta-*`, `.carousel .card`, `.doc-* .audio-* .loc-* .contact-* .sticker-*`, `.sheet*`;
keyframes de typing con prefijo `wam-`. keyframes de typing con prefijo `wam-`.
- `css/editor.css` (C): prefijo `ed-` (`.ed-seccion .ed-campo .ed-tarjeta .ed-btn .ed-fila .ed-badge`). - `css/editor.css` (C): prefijo `ed-` (`.ed-seccion .ed-campo .ed-tarjeta .ed-btn .ed-fila .ed-badge`,
`.ed-chip .ed-toast .ed-seg .ed-validacion`, `#chat-cuerpo .wa-row.ed-resaltado`). `.ed-campo` es un `<div>` con
`<label for>` solo sobre la etiqueta (un label envolvente activaba el primer botón de la barra de formato).
- `css/base.css`: además, layout de una columna con pestañas ≤ 900 px (escala del teléfono con el ancho completo y
`--wam-toolbar-alto` medido por ux.js), `.wam-dlg*` (diálogos), `.wam-sr`, `--wam-primario` (#008069, AA con blanco).
- `css/interactividad.css` (D): `.wa-cita*` (respuesta citada), `.qr-btn.usado`, `.wam-pulsando`, controles de `#barra-movil`.
- Tema: B alterna `oscuro` en `#pantalla`; editor/shell ya reaccionan por `body[data-tema]` (lo setea `setTema`). - Tema: B alterna `oscuro` en `#pantalla`; editor/shell ya reaccionan por `body[data-tema]` (lo setea `setTema`).
## 3. Orden exacto de scripts en index.html (todos al final del `<body>`, sin `defer`/`module`) ## 3. Orden exacto de scripts en index.html (todos al final del `<body>`, sin `defer`/`module`)
@@ -129,11 +216,14 @@ Contrato de clics B→D (delegación en `#chat-cuerpo`): B estampa en cada eleme
<link rel="stylesheet" href="css/iphone.css"> <link rel="stylesheet" href="css/iphone.css">
<link rel="stylesheet" href="css/whatsapp.css"> <link rel="stylesheet" href="css/whatsapp.css">
<link rel="stylesheet" href="css/editor.css"> <link rel="stylesheet" href="css/editor.css">
<link rel="stylesheet" href="css/interactividad.css">
… …
<script src="js/store.js"></script> <!-- define WAM.CLAVE/estado/normalizar; NO toca DOM al cargar --> <script src="js/store.js"></script> <!-- define WAM.CLAVE/estado/normalizar; NO toca DOM al cargar -->
<script src="js/render.js"></script> <!-- define funciones de render; NO toca DOM al cargar --> <script src="js/render.js"></script> <!-- define funciones de render; NO toca DOM al cargar -->
<script src="js/editor.js"></script> <!-- define funciones del editor; NO toca DOM al cargar --> <script src="js/editor.js"></script> <!-- define funciones del editor; NO toca DOM al cargar -->
<script src="js/interactivity.js"></script> <!-- define flujos/typing/demo; NO toca DOM al cargar --> <script src="js/interactivity.js"></script> <!-- define flujos/typing/demo; NO toca DOM al cargar -->
<script src="js/escenarios.js"></script> <!-- datos de escenarios; NO toca DOM al cargar -->
<script src="js/ux.js"></script> <!-- WAM.initUX (lo invoca renderEditor); NO toca DOM al cargar -->
<script src="js/main.js"></script> <!-- única ejecución: WAM.init() --> <script src="js/main.js"></script> <!-- única ejecución: WAM.init() -->
``` ```
@@ -146,11 +236,13 @@ Ningún archivo lee `WAM.estado` en carga (solo dentro de funciones) → el orde
tabla (contenedores del editor y del chat vacíos), `<body data-tema="claro">`, `#pantalla class="wa"`, tabla (contenedores del editor y del chat vacíos), `<body data-tema="claro">`, `#pantalla class="wa"`,
grid de dos columnas, toolbar, marco iPhone con Dynamic Island y status bar. Sin JS propio. grid de dos columnas, toolbar, marco iPhone con Dynamic Island y status bar. Sin JS propio.
**B — chat:** `css/whatsapp.css` + `js/render.js`. Render de los 13 tipos según research-ui (burbuja **B — chat:** `css/whatsapp.css` + `js/render.js`. Render de los 13 tipos con el aspecto de WhatsApp iOS
17 px, cola arriba, meta flotante abajo-derecha, media full-bleed, template header→body→footer→botones, según `docs/referencias/README.md` (burbuja 17 px con radio 18 y cola abajo solo en la última del grupo,
tarjeta apilada de quick replies con hairlines, sheet de lista, waveform de audio, tarjeta de contacto, media inset 4 px, template/buttons/list/cta_url en UNA burbuja con separador y botones con icono verde,
pin de mapa, scroll-snap del carousel), ticks SVG, indicador "escribiendo…", claro/oscuro. Sin listeners hoja inferior iOS, carrusel sin puntos, nota de voz con avatar, documento en panel gris, contacto con
de flujo; solo el cierre de `#sheet-fondo` (elemento suyo) y `data-*`. "Mensaje | Guardar contacto", "Hoy" + aviso de cifrado), ticks SVG, indicador "escribiendo…", claro/oscuro
(negro neutro). Sin listeners de flujo; solo presentación: cierre de la hoja, "Ver todas las opciones" y
el reenvío del clic de sus filas al botón oculto de la burbuja.
**C — editor:** `css/editor.css` + `js/editor.js`. Formulario de cuenta (nombre, avatar por archivo vía **C — editor:** `css/editor.css` + `js/editor.js`. Formulario de cuenta (nombre, avatar por archivo vía
`aDataUrl` o por URL, subtítulo, verificado), alta por la paleta de tarjetas `#nuevo-tipo` (una tarjeta con `aDataUrl` o por URL, subtítulo, verificado), alta por la paleta de tarjetas `#nuevo-tipo` (una tarjeta con
@@ -178,7 +270,8 @@ Prohibido `document.getElementById` de IDs ajenos a tu panel salvo los listados
pulsar de nuevo lo detiene. pulsar de nuevo lo detiene.
6. F5 conserva TODO el estado (localStorage `wam-mockup-v1`; nota: en file:// Chrome/Edge/Firefox lo 6. F5 conserva TODO el estado (localStorage `wam-mockup-v1`; nota: en file:// Chrome/Edge/Firefox lo
comparten por origen file://); `#btn-reset` restaura la demo. comparten por origen file://); `#btn-reset` restaura la demo.
7. Exportar descarga `wam-demo.json`; importar ese mismo archivo restaura un estado idéntico. 7. Exportar descarga `<nombre-de-la-demo>.json` (`wam-demo.json` si no hay nombre); importar ese mismo archivo
restaura un estado idéntico.
8. Tema alterna editor+chat; modo presentación oculta el editor y centra/escala el iPhone. 8. Tema alterna editor+chat; modo presentación oculta el editor y centra/escala el iPhone.
9. Subir avatar o imagen por archivo funciona sin red (dataURL) y persiste tras recargar. 9. Subir avatar o imagen por archivo funciona sin red (dataURL) y persiste tras recargar.
10. Sin dependencias de red obligatorias: con `src` vacíos se usa `svgPlaceholder` (las URLs remotas 10. Sin dependencias de red obligatorias: con `src` vacíos se usa `svgPlaceholder` (las URLs remotas
+423 -44
View File
@@ -1,6 +1,7 @@
/* ===================================================================== /* =====================================================================
WhatsApp Cloud API Mockup — css/base.css (Módulo A · shell) WhatsApp Cloud API Mockup — css/base.css (shell)
Reset, variables --wam-*, layout grid de #app y barra superior (.toolbar). Reset, variables --wam-*, layout grid de #app, barra superior (.toolbar),
pestañas de ventana estrecha, diálogos (<dialog>) y utilidades.
El tema del shell se conmuta con body[data-tema] (lo fija WAM.setTema). El tema del shell se conmuta con body[data-tema] (lo fija WAM.setTema).
Sin dependencias externas. Sin dependencias externas.
===================================================================== */ ===================================================================== */
@@ -11,7 +12,7 @@ html, body { margin: 0; padding: 0; height: 100%; }
body { overflow: hidden; } body { overflow: hidden; }
button, input, select, textarea { font: inherit; color: inherit; } button, input, select, textarea { font: inherit; color: inherit; }
img { max-width: 100%; } img { max-width: 100%; }
h1, h2, p { margin: 0; } h1, h2, h3, p { margin: 0; }
ul, ol { margin: 0; padding: 0; } ul, ol { margin: 0; padding: 0; }
[hidden] { display: none !important; } /* respeta #importar-archivo y stubs estáticos (#sheet) */ [hidden] { display: none !important; } /* respeta #importar-archivo y stubs estáticos (#sheet) */
@@ -22,14 +23,19 @@ ul, ol { margin: 0; padding: 0; }
--wam-panel: #ffffff; --wam-panel: #ffffff;
--wam-panel-alt: #f3f6f8; --wam-panel-alt: #f3f6f8;
--wam-texto: #1c2b33; --wam-texto: #1c2b33;
--wam-texto-suave: #5d6f7a; --wam-texto-suave: #4e5f69; /* ≥ 5:1 sobre --wam-fondo y --wam-panel */
--wam-borde: rgba(11, 20, 26, .14); --wam-borde: rgba(11, 20, 26, .14);
--wam-acento: #00a884; --wam-acento: #00a884; /* bordes, iconos, estados */
--wam-acento-hover: #008069; --wam-acento-hover: #008069;
--wam-primario: #008069; /* fondo de botones con texto blanco (≈ 5:1) */
--wam-primario-hover: #006e5a;
--wam-acento-contraste: #ffffff; --wam-acento-contraste: #ffffff;
--wam-peligro: #c94a3d; --wam-peligro: #b93a2e;
--wam-ok: #1a8a5a;
--wam-sombra: 0 10px 30px rgba(11, 20, 26, .14); --wam-sombra: 0 10px 30px rgba(11, 20, 26, .14);
--wam-radio: 12px; --wam-radio: 12px;
--wam-toolbar-alto: 57px; /* lo actualiza js/ux.js con la altura real */
--wam-tabs-alto: 58px;
} }
body[data-tema="oscuro"] { body[data-tema="oscuro"] {
@@ -38,12 +44,15 @@ body[data-tema="oscuro"] {
--wam-panel: #111b21; --wam-panel: #111b21;
--wam-panel-alt: #1c272f; --wam-panel-alt: #1c272f;
--wam-texto: #e9edef; --wam-texto: #e9edef;
--wam-texto-suave: #8696a0; --wam-texto-suave: #9aa9b2;
--wam-borde: rgba(233, 237, 239, .15); --wam-borde: rgba(233, 237, 239, .15);
--wam-acento: #00a884; --wam-acento: #00a884;
--wam-acento-hover: #008069; --wam-acento-hover: #06cf9c;
--wam-primario: #008069;
--wam-primario-hover: #00a884;
--wam-acento-contraste: #ffffff; --wam-acento-contraste: #ffffff;
--wam-peligro: #e57164; --wam-peligro: #f08a7e;
--wam-ok: #3fcf8e;
--wam-sombra: 0 10px 30px rgba(0, 0, 0, .5); --wam-sombra: 0 10px 30px rgba(0, 0, 0, .5);
} }
@@ -57,12 +66,23 @@ body {
:focus-visible { outline: 2px solid var(--wam-acento); outline-offset: 2px; } :focus-visible { outline: 2px solid var(--wam-acento); outline-offset: 2px; }
/* Solo para lectores de pantalla */
.wam-sr {
position: absolute !important;
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* --- Layout: grid de dos columnas de #app ----------------------------- */ /* --- Layout: grid de dos columnas de #app ----------------------------- */
#app { #app {
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
display: grid; display: grid;
grid-template-columns: clamp(320px, 34vw, 470px) minmax(0, 1fr); grid-template-columns: clamp(340px, 34vw, 480px) minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
grid-template-areas: grid-template-areas:
"toolbar toolbar" "toolbar toolbar"
@@ -74,6 +94,8 @@ body {
/* --- Barra superior de la app (.toolbar) ------------------------------- */ /* --- Barra superior de la app (.toolbar) ------------------------------- */
.toolbar { .toolbar {
position: relative;
z-index: 20;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -103,14 +125,44 @@ body {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.toolbar-subtitulo { font-size: 11.5px; color: var(--wam-texto-suave); white-space: nowrap; } .toolbar-subtitulo { font-size: 12px; color: var(--wam-texto-suave); white-space: nowrap; }
.toolbar-acciones { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } /* Indicador de guardado */
.tb-separador { width: 1px; height: 22px; background: var(--wam-borde); margin: 0 2px; } .tb-guardado {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 6px;
padding: 4px 10px;
border-radius: 999px;
background: var(--wam-panel-alt);
font-size: 12px;
color: var(--wam-texto-suave);
white-space: nowrap;
}
.tb-guardado-punto {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--wam-ok);
}
.tb-guardado[data-estado="pendiente"] .tb-guardado-punto { background: #c98a00; }
.tb-guardado[data-estado="error"] {
background: rgba(185, 58, 46, .12);
color: var(--wam-peligro);
font-weight: 600;
}
.tb-guardado[data-estado="error"] .tb-guardado-punto { background: var(--wam-peligro); }
.toolbar-acciones { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: nowrap; min-width: 0; }
.tb-grupo-sec { display: flex; align-items: center; gap: 6px; }
.tb-separador { width: 1px; height: 22px; background: var(--wam-borde); margin: 0 2px; flex: none; }
.tb-btn { .tb-btn {
flex: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
min-height: 34px;
padding: 7px 12px; padding: 7px 12px;
border: 1px solid var(--wam-borde); border: 1px solid var(--wam-borde);
border-radius: 10px; border-radius: 10px;
@@ -119,16 +171,29 @@ body {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
white-space: nowrap;
cursor: pointer; cursor: pointer;
transition: background-color .15s ease, border-color .15s ease, color .15s ease, transform .05s ease; transition: background-color .15s ease, border-color .15s ease, color .15s ease, transform .05s ease;
} }
.tb-btn-icono { padding: 7px 9px; }
.tb-btn svg { flex: none; } .tb-btn svg { flex: none; }
.tb-btn:hover { background: var(--wam-panel-alt); border-color: var(--wam-texto-suave); } .tb-btn:hover { background: var(--wam-panel-alt); border-color: var(--wam-texto-suave); }
.tb-btn:active { transform: translateY(1px); } .tb-btn:active { transform: translateY(1px); }
.tb-btn:disabled { opacity: .4; cursor: default; }
.tb-btn:disabled:hover { background: var(--wam-panel); border-color: var(--wam-borde); }
.tb-btn:disabled:active { transform: none; }
.tb-btn[aria-pressed="true"] {
background: rgba(0, 168, 132, .12);
border-color: var(--wam-acento);
color: var(--wam-acento-hover);
}
#btn-reset:hover { color: var(--wam-peligro); border-color: var(--wam-peligro); } #btn-reset:hover { color: var(--wam-peligro); border-color: var(--wam-peligro); }
#btn-mas { display: none; }
.tb-solo-menu { display: none; }
/* --- Editor (columna izquierda · solo el cascarón; contenido: módulo C) */ /* --- Editor (columna izquierda · cascarón; contenido: editor.js) -------- */
#panel-editor { #panel-editor {
position: relative;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
@@ -139,40 +204,30 @@ body {
.wam-bloque:last-child { border-bottom: 0; } .wam-bloque:last-child { border-bottom: 0; }
.wam-bloque-titulo { .wam-bloque-titulo {
margin-bottom: 12px; margin-bottom: 12px;
font-size: 11px; font-size: 12px;
font-weight: 700; font-weight: 700;
letter-spacing: .12em; letter-spacing: .12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--wam-texto-suave); color: var(--wam-texto-suave);
} }
.wam-alta { display: flex; gap: 8px; margin-bottom: 14px; } .wam-alta { display: flex; gap: 8px; margin-bottom: 12px; }
.wam-alta select {
flex: 1;
min-width: 0;
height: 36px;
padding: 0 10px;
border: 1px solid var(--wam-borde);
border-radius: 10px;
background: var(--wam-panel-alt);
color: var(--wam-texto);
cursor: pointer;
}
.wam-btn-anadir { .wam-btn-anadir {
flex: none; flex: none;
height: 36px; height: 32px;
padding: 0 14px; padding: 0 12px;
border: 0; border: 1px solid var(--wam-borde);
border-radius: 10px; border-radius: 9px;
background: var(--wam-acento); background: var(--wam-panel);
color: var(--wam-acento-contraste); color: var(--wam-texto);
font-size: 13px; font-size: 12.5px;
font-weight: 700; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background-color .15s ease; transition: background-color .15s ease, border-color .15s ease;
} }
.wam-btn-anadir:hover { background: var(--wam-acento-hover); } .wam-btn-anadir:hover { border-color: var(--wam-acento); background: var(--wam-panel-alt); }
.ed-ver-telefono { display: none; }
/* --- Modo presentación (lo activa D con body.presentacion) ------------- */ /* --- Modo presentación (lo activa main.js con body.presentacion) -------- */
body.presentacion #panel-editor { display: none; } body.presentacion #panel-editor { display: none; }
body.presentacion #app { body.presentacion #app {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -181,6 +236,13 @@ body.presentacion #app {
"movil"; "movil";
} }
/* Contraste AA del botón primario de la vista previa (texto blanco 13 px) */
#barra-movil #btn-reproducir { background: var(--wam-primario); }
#barra-movil #btn-reproducir:hover { background: var(--wam-primario-hover); }
/* --- Pestañas Editor / Vista previa (solo ventana estrecha) ------------- */
.tabs-movil { display: none; }
/* --- Noscript ----------------------------------------------------------- */ /* --- Noscript ----------------------------------------------------------- */
.wam-noscript { .wam-noscript {
position: fixed; position: fixed;
@@ -196,15 +258,332 @@ body.presentacion #app {
text-align: center; text-align: center;
} }
/* --- Ajustes de ventana estrecha --------------------------------------- */ /* --- Diálogos (<dialog> nativo: confirmar, demos, atajos) --------------- */
@media (max-width: 860px) { .wam-dlg {
.toolbar { flex-wrap: wrap; } width: min(560px, calc(100vw - 32px));
.tb-btn { padding: 7px 9px; } max-height: min(86vh, 760px);
.tb-btn span { display: none; } /* solo iconos cuando escasea el ancho */ padding: 0;
border: 1px solid var(--wam-borde);
border-radius: 16px;
background: var(--wam-panel);
color: var(--wam-texto);
box-shadow: 0 24px 60px rgba(0, 0, 0, .3);
overflow: hidden;
}
.wam-dlg[open] { display: flex; flex-direction: column; }
.wam-dlg::backdrop { background: rgba(11, 20, 26, .45); }
.wam-dlg-chico { width: min(460px, calc(100vw - 32px)); }
.wam-dlg-grande { width: min(760px, calc(100vw - 32px)); }
.wam-dlg-cab {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px 10px;
}
.wam-dlg-titulo { font-size: 17px; font-weight: 700; }
.wam-dlg-cerrar {
width: 32px; height: 32px;
border: 0;
border-radius: 8px;
background: transparent;
font-size: 22px;
line-height: 1;
color: var(--wam-texto-suave);
cursor: pointer;
}
.wam-dlg-cerrar:hover { background: var(--wam-panel-alt); color: var(--wam-texto); }
.wam-dlg-cuerpo {
display: grid;
gap: 18px;
padding: 4px 18px 16px;
overflow-y: auto;
font-size: 14px;
line-height: 1.5;
}
.wam-dlg-pie {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px 16px;
border-top: 1px solid var(--wam-borde);
}
.wam-dlg-nota { font-size: 12.5px; color: var(--wam-texto-suave); }
.wam-dlg-sec { display: grid; gap: 10px; }
.wam-dlg-subtitulo {
font-size: 12px;
font-weight: 700;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--wam-texto-suave);
}
.wam-dlg-btn {
min-height: 36px;
padding: 8px 14px;
border: 1px solid var(--wam-borde);
border-radius: 10px;
background: var(--wam-panel);
color: var(--wam-texto);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
}
.wam-dlg-btn:hover { background: var(--wam-panel-alt); border-color: var(--wam-texto-suave); }
.wam-dlg-btn:disabled { opacity: .6; cursor: default; }
.wam-dlg-primario { background: var(--wam-primario); border-color: var(--wam-primario); color: #fff; }
.wam-dlg-primario:hover { background: var(--wam-primario-hover); border-color: var(--wam-primario-hover); }
.wam-dlg-peligro { color: var(--wam-peligro); }
.wam-dlg-peligro:hover { border-color: var(--wam-peligro); background: rgba(185, 58, 46, .08); }
button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini) { background: #b93a2e; border-color: #b93a2e; color: #fff; }
button.wam-dlg-peligro.wam-dlg-btn:not(.wam-dlg-mini):hover { background: #9c2f25; }
.wam-dlg-mini { min-height: 30px; padding: 5px 10px; font-size: 12.5px; }
.wam-dlg-fila { display: flex; gap: 8px; }
.wam-dlg-input {
flex: 1;
min-width: 0;
min-height: 36px;
padding: 6px 10px;
border: 1px solid var(--wam-borde);
border-radius: 9px;
background: var(--wam-panel);
color: var(--wam-texto);
font-size: 14px;
}
.wam-dlg-input:focus { outline: 2px solid var(--wam-acento); outline-offset: 0; }
/* Escenarios */
.wam-esc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 8px;
}
.wam-esc {
display: grid;
gap: 4px;
align-content: start;
padding: 12px;
border: 1px solid var(--wam-borde);
border-radius: 12px;
background: var(--wam-panel);
color: var(--wam-texto);
text-align: left;
cursor: pointer;
transition: border-color .15s ease, background-color .15s ease;
}
.wam-esc:hover { border-color: var(--wam-acento); background: var(--wam-panel-alt); }
.wam-esc-nombre { font-size: 14px; font-weight: 700; }
.wam-esc-desc { font-size: 12.5px; line-height: 1.4; color: var(--wam-texto-suave); }
.wam-esc-meta { font-size: 11.5px; font-weight: 600; color: var(--wam-acento-hover); }
/* Demos guardadas */
.wam-demos-lista { list-style: none; display: grid; gap: 6px; }
.wam-demos-vacio {
padding: 12px;
border: 1px dashed var(--wam-borde);
border-radius: 10px;
font-size: 13px;
color: var(--wam-texto-suave);
}
.wam-demo {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--wam-borde);
border-radius: 10px;
}
.wam-demo-activa { border-color: var(--wam-acento); }
.wam-demo-info { display: grid; min-width: 0; }
.wam-demo-nombre { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wam-demo-meta { font-size: 12px; color: var(--wam-texto-suave); }
.wam-demo-acc { display: flex; flex-wrap: wrap; gap: 4px; }
/* Atajos */
.wam-atajos {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 14px;
margin: 0;
font-size: 13.5px;
}
.wam-atajos dt { white-space: nowrap; }
.wam-atajos dd { margin: 0; color: var(--wam-texto-suave); }
.wam-atajos kbd {
display: inline-block;
min-width: 22px;
padding: 2px 6px;
border: 1px solid var(--wam-borde);
border-bottom-width: 2px;
border-radius: 6px;
background: var(--wam-panel-alt);
font: 600 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-align: center;
}
/* --- Ventana mediana: iconos sin texto en la toolbar -------------------- */
@media (max-width: 1240px) {
.toolbar-subtitulo { display: none; }
.tb-guardado-rel { display: none; }
}
@media (max-width: 1100px) {
.toolbar-acciones .tb-btn > span:not(.tb-solo-menu) { display: none; }
.toolbar-acciones .tb-btn { padding: 7px 9px; }
}
/* --- Ventana estrecha (≤ 900 px): una columna con pestañas -------------- */
@media (max-width: 900px) {
#app {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
grid-template-areas:
"toolbar"
"main";
}
#panel-editor,
#panel-movil { grid-area: main; }
#panel-editor { border-right: 0; padding-bottom: calc(var(--wam-tabs-alto) + 64px); }
#panel-movil { padding-bottom: calc(var(--wam-tabs-alto) + 12px) !important; }
body:not(.vista-movil) #panel-movil { display: none; }
body.vista-movil #panel-editor { display: none; }
/* En presentación no hay pestañas: solo el teléfono */
body.presentacion #app {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"toolbar"
"main";
}
body.presentacion #panel-movil { display: flex; padding-bottom: 18px !important; }
body.presentacion .tabs-movil { display: none; }
.tabs-movil {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 30;
display: grid;
grid-template-columns: 1fr 1fr;
height: calc(var(--wam-tabs-alto) + env(safe-area-inset-bottom, 0px));
padding-bottom: env(safe-area-inset-bottom, 0px);
background: var(--wam-panel);
border-top: 1px solid var(--wam-borde);
box-shadow: 0 -4px 16px rgba(11, 20, 26, .08);
}
.tab-movil {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
border: 0;
background: transparent;
color: var(--wam-texto-suave);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.tab-movil[aria-selected="true"] { color: var(--wam-acento-hover); }
.tab-movil[aria-selected="true"] svg { stroke-width: 2.3; }
.tab-movil:focus-visible { outline-offset: -4px; }
/* «Ver teléfono»: botón flotante dentro del editor */
.ed-ver-telefono {
position: fixed;
right: 16px;
bottom: calc(var(--wam-tabs-alto) + 14px + env(safe-area-inset-bottom, 0px));
z-index: 25;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 10px 16px;
border: 0;
border-radius: 999px;
background: var(--wam-primario);
color: #fff;
font-size: 13.5px;
font-weight: 700;
box-shadow: 0 6px 20px rgba(0, 0, 0, .25);
cursor: pointer;
}
body.vista-movil .ed-ver-telefono,
body.presentacion .ed-ver-telefono { display: none; }
/* Toolbar compacta: secundarias en el menú «⋯» */
.toolbar { gap: 8px; padding: 8px 12px; }
.toolbar-logo { width: 30px; height: 30px; }
.toolbar h1 { font-size: 14px; }
.tb-guardado { padding: 4px 8px; margin-left: 2px; }
.tb-guardado-txt { display: none; }
.tb-guardado[data-estado="error"] .tb-guardado-txt { display: inline; }
#btn-mas { display: inline-flex; }
.tb-sep-sec { display: none; }
.tb-grupo-sec {
display: none;
position: absolute;
top: calc(100% + 6px);
right: 10px;
z-index: 40;
flex-direction: column;
align-items: stretch;
gap: 2px;
min-width: 200px;
padding: 6px;
border: 1px solid var(--wam-borde);
border-radius: 12px;
background: var(--wam-panel);
box-shadow: var(--wam-sombra);
}
.toolbar.tb-menu-abierto .tb-grupo-sec { display: flex; }
.tb-grupo-sec .tb-btn {
justify-content: flex-start;
border-color: transparent;
padding: 10px 12px;
min-height: 40px;
}
.toolbar-acciones .tb-grupo-sec .tb-btn > span,
.tb-grupo-sec .tb-solo-menu { display: inline; }
.tb-grupo-sec .tb-btn:hover { border-color: transparent; }
/* Escala del teléfono con el ancho completo (sin restar el editor) y la
altura real de la toolbar + barra de vista previa + pestañas. */
@supports (scale: calc(1vh / 1px)) {
html body {
--wam-escala: max(.3, min(
1,
(100vw - 32px) / 393px,
(100dvh - var(--wam-toolbar-alto) - var(--wam-tabs-alto) - 124px) / 852px
));
}
html body.presentacion {
--wam-escala: max(.3, min(
1,
(100vw - 32px) / 393px,
(100dvh - var(--wam-toolbar-alto) - 44px) / 852px
));
}
}
}
@media (max-width: 520px) {
/* la pestaña ya dice «Vista previa»: se gana una fila para el teléfono */
#barra-movil .barra-titulo { display: none; }
#barra-movil { justify-content: center; }
}
@media (max-width: 420px) {
.toolbar-titulos { display: none; }
.toolbar-marca { flex: 1; }
}
/* Destinos táctiles más grandes */
@media (pointer: coarse) {
.tb-btn { min-height: 40px; }
} }
/* --- Accesibilidad: menos movimiento ------------------------------------ */ /* --- Accesibilidad: menos movimiento ------------------------------------ */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
#chat-cuerpo { scroll-behavior: auto; } #chat-cuerpo { scroll-behavior: auto; }
.tb-btn, .wam-btn-anadir { transition: none; } .tb-btn, .wam-btn-anadir, .wam-esc { transition: none; }
} }
+357 -12
View File
@@ -12,7 +12,7 @@
--ed-panel: #ffffff; --ed-panel: #ffffff;
--ed-fondo-suave: #f0f4f3; --ed-fondo-suave: #f0f4f3;
--ed-texto: #111b21; --ed-texto: #111b21;
--ed-texto-suave: #667781; --ed-texto-suave: #4e5f69;
--ed-borde: #d9e2e3; --ed-borde: #d9e2e3;
--ed-acento: var(--wam-acento, #00a884); --ed-acento: var(--wam-acento, #00a884);
--ed-acento-hondo: #008069; --ed-acento-hondo: #008069;
@@ -105,7 +105,8 @@ body[data-tema="oscuro"] #panel-editor {
font-size: 12px; font-size: 12px;
color: var(--wam-texto, var(--ed-texto-suave)); color: var(--wam-texto, var(--ed-texto-suave));
} }
.ed-campo-e { font-size: 11.5px; } .ed-campo-e { font-size: 12px; font-weight: 500; color: var(--wam-texto-suave, var(--ed-texto-suave)); cursor: default; }
label.ed-campo-e { cursor: pointer; }
.ed-input { .ed-input {
width: 100%; width: 100%;
@@ -173,15 +174,15 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
} }
.ed-btn-primario { .ed-btn-primario {
background: var(--ed-acento); background: var(--wam-primario, #008069);
border-color: var(--ed-acento); border-color: var(--wam-primario, #008069);
color: #ffffff; color: #ffffff;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
} }
.ed-btn-primario:hover { .ed-btn-primario:hover {
background: var(--ed-acento-hondo); background: var(--wam-primario-hover, #006e5a);
border-color: var(--ed-acento-hondo); border-color: var(--wam-primario-hover, #006e5a);
color: #ffffff; color: #ffffff;
} }
@@ -304,9 +305,9 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
.ed-dir { .ed-dir {
flex: 0 0 auto; flex: 0 0 auto;
padding: 1px 6px; padding: 1px 6px;
font-size: 10px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.4px; letter-spacing: 0.3px;
text-transform: uppercase; text-transform: uppercase;
border-radius: 999px; border-radius: 999px;
} }
@@ -322,9 +323,9 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
.ed-badge { .ed-badge {
flex: 0 0 auto; flex: 0 0 auto;
padding: 1px 6px; padding: 1px 6px;
font-size: 10px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.4px; letter-spacing: 0.3px;
text-transform: uppercase; text-transform: uppercase;
color: var(--wam-texto, var(--ed-texto-suave)); color: var(--wam-texto, var(--ed-texto-suave));
border: 1px solid var(--wam-borde, var(--ed-borde)); border: 1px solid var(--wam-borde, var(--ed-borde));
@@ -382,7 +383,7 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
} }
.ed-sub-titulo { .ed-sub-titulo {
flex: 1; flex: 1;
font-size: 10.5px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.6px; letter-spacing: 0.6px;
text-transform: uppercase; text-transform: uppercase;
@@ -552,7 +553,7 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
/* ---------- Contadores de caracteres ---------- */ /* ---------- Contadores de caracteres ---------- */
.ed-contador { .ed-contador {
justify-self: end; justify-self: end;
font-size: 10.5px; font-size: 11px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
color: var(--wam-texto-suave, var(--ed-texto-suave)); color: var(--wam-texto-suave, var(--ed-texto-suave));
transition: color 0.12s ease; transition: color 0.12s ease;
@@ -782,3 +783,347 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
transition: none; transition: none;
} }
} }
/* =====================================================================
UX v2: paleta compacta por grupos, cabecera de «Cuenta» plegable,
chips de avisos y flujos, control segmentado, toasts, enlace con la
vista previa, estados vacíos y destinos táctiles.
===================================================================== */
/* ---------- Paleta: una fila por grupo (Básicos / Medios / Interactivos) ---------- */
#panel-editor .wam-alta { display: grid; gap: 8px; }
#panel-editor .wam-alta .wam-btn-anadir { width: auto; }
.ed-paleta {
display: grid;
grid-template-columns: none;
gap: 6px;
}
.ed-paleta-grupo {
display: grid;
grid-template-columns: 84px minmax(0, 1fr);
align-items: start;
gap: 5px;
}
.ed-paleta-items {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.ed-paleta-titulo {
padding-top: 7px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--wam-texto-suave, var(--ed-texto-suave));
}
.ed-paleta .ed-tipo-card {
flex: 0 0 auto;
gap: 5px;
padding: 5px 9px 5px 7px;
border-radius: 999px;
font-size: 12.5px;
}
.ed-paleta .ed-tipo-card .ed-tipo-card-e { flex: none; overflow: visible; }
.ed-paleta .ed-tipo-card.ed-seleccionada {
/* último tipo usado: marca sutil (la paleta añade al hacer clic) */
background: var(--wam-panel, var(--ed-panel));
color: var(--wam-texto, var(--ed-texto));
font-weight: 500;
box-shadow: none;
border-color: var(--ed-seleccion);
}
.ed-paleta .ed-tipo-card:hover { background: var(--ed-seleccion-suave); }
.ed-alta-pie {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 6px 10px;
}
.ed-destino-alta {
font-size: 12px;
color: var(--wam-texto-suave, var(--ed-texto-suave));
}
.ed-enlace {
padding: 0;
border: 0;
background: none;
font: inherit;
color: var(--ed-seleccion);
text-decoration: underline;
cursor: pointer;
}
/* ---------- Bloque «Cuenta» como <details> ---------- */
.ed-bloque-cuenta > .ed-cuenta-sumario {
display: flex;
align-items: center;
gap: 10px;
margin: -4px 0;
padding: 4px 0;
list-style: none;
cursor: pointer;
border-radius: 8px;
}
.ed-bloque-cuenta > .ed-cuenta-sumario::-webkit-details-marker { display: none; }
.ed-bloque-cuenta > .ed-cuenta-sumario::before {
content: "";
flex: none;
width: 7px;
height: 7px;
margin: 0 2px 0 3px;
border-right: 2px solid var(--wam-texto-suave, var(--ed-texto-suave));
border-bottom: 2px solid var(--wam-texto-suave, var(--ed-texto-suave));
transform: rotate(-45deg);
transition: transform 0.15s ease;
}
.ed-bloque-cuenta[open] > .ed-cuenta-sumario::before { transform: rotate(45deg); }
.ed-bloque-cuenta > .ed-cuenta-sumario .wam-bloque-titulo { margin: 0; }
.ed-bloque-cuenta[open] > .ed-cuenta-sumario { margin-bottom: 12px; }
.ed-bloque-cuenta:not([open]) { padding-bottom: 16px; }
.ed-cuenta-resumen {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: var(--wam-texto, var(--ed-texto));
}
.ed-bloque-cuenta[open] .ed-cuenta-resumen { display: none; }
.ed-cuenta-mini {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--wam-borde, var(--ed-borde));
}
.ed-cuenta-mini-nombre { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.ed-cuenta-mini-ok {
flex: none;
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: #1d9bf0;
color: #fff;
font-size: 10px;
font-weight: 800;
}
/* ---------- Cabecera de la sección Mensajes ---------- */
.ed-bloque-cab {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.ed-avisos-global {
padding: 3px 9px;
border: 1px solid rgba(180, 83, 9, 0.35);
border-radius: 999px;
background: rgba(180, 83, 9, 0.08);
color: var(--ed-ambar);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.ed-avisos-global:hover { background: rgba(180, 83, 9, 0.16); }
.ed-lista-herr {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
margin-bottom: 8px;
}
/* ---------- Cabecera de tarjeta: chips y acciones ---------- */
.ed-chips { flex: 0 0 auto; display: inline-flex; gap: 4px; }
.ed-chips:empty { display: none; }
.ed-chip {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ed-chip-aviso { color: var(--ed-ambar); background: rgba(180, 83, 9, 0.12); }
.ed-chip-flujo { color: var(--ed-acento-hondo); background: var(--ed-acento-suave); }
.ed-chip-flujo-incompleto { color: var(--ed-entrada); background: var(--ed-entrada-suave); }
.ed-num-resp { font-size: 12px; }
/* Las flechas ↑/↓ se muestran al abrir, al pasar el ratón o con el foco
dentro (siguen en el orden de Tab: el foco las hace visibles) */
.ed-tarjeta { position: relative; }
.ed-tarjeta:not(.ed-abierta):not(:hover):not(:focus-within) > .ed-tarjeta-cab .ed-mov {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
padding: 0;
border: 0;
}
.ed-acciones .ed-btn-mini { min-width: 26px; min-height: 26px; padding: 2px 6px; }
.ed-btn-mini:disabled { opacity: 0.35; }
/* ---------- Caja de avisos dentro de la tarjeta ---------- */
.ed-validacion {
padding: 8px 10px;
border: 1px solid rgba(180, 83, 9, 0.3);
border-radius: 8px;
background: rgba(180, 83, 9, 0.07);
color: var(--wam-texto, var(--ed-texto));
font-size: 12px;
}
.ed-validacion-titulo { margin: 0 0 3px; font-weight: 700; color: var(--ed-ambar); }
.ed-validacion ul { margin: 0; padding-left: 18px; display: grid; gap: 2px; }
/* ---------- Respuestas encadenadas ---------- */
.ed-resps { border-left: 3px solid var(--ed-acento); }
.ed-resps.ed-resps-vacias { border-left-color: var(--ed-entrada); }
.ed-resps-vacias .ed-nota { color: var(--ed-entrada); }
/* ---------- Control segmentado Entrada / Salida ---------- */
.ed-comunes > .ed-campo-seg { flex: 0 0 auto; }
.ed-comunes > .ed-campo-hora { flex: 0 1 84px; }
.ed-seg {
display: inline-flex;
padding: 2px;
border: 1px solid var(--wam-borde, var(--ed-borde));
border-radius: 8px;
background: var(--ed-fondo-suave);
}
.ed-seg-op { position: relative; cursor: pointer; }
.ed-seg-op input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
margin: 0;
cursor: pointer;
}
.ed-seg-op span {
display: inline-block;
padding: 3px 11px;
border-radius: 6px;
font-size: 12.5px;
font-weight: 600;
color: var(--wam-texto-suave, var(--ed-texto-suave));
transition: background-color 0.12s ease, color 0.12s ease;
}
.ed-seg-op input:checked + span {
background: var(--wam-panel, var(--ed-panel));
color: var(--wam-texto, var(--ed-texto));
box-shadow: 0 1px 2px rgba(16, 24, 32, 0.18);
}
.ed-seg-op input:focus-visible + span { outline: 2px solid var(--ed-seleccion); outline-offset: 1px; }
/* ---------- Estado vacío con acciones ---------- */
.ed-vacio { display: grid; gap: 8px; justify-items: center; padding: 20px 14px; }
.ed-vacio p { margin: 0; }
.ed-vacio-titulo { font-weight: 700; font-size: 13.5px; color: var(--wam-texto, var(--ed-texto)); }
.ed-vacio-acciones { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 4px; }
/* ---------- Toasts (fuera del scroll del panel) ---------- */
.ed-toasts {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 1000;
display: grid;
gap: 8px;
max-width: min(440px, calc(100vw - 32px));
pointer-events: none;
}
.ed-toast {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 8px 10px 14px;
border-radius: 12px;
background: #1f2c33;
color: #fff;
font-size: 13.5px;
line-height: 1.4;
box-shadow: 0 8px 26px rgba(0, 0, 0, 0.28);
pointer-events: auto;
animation: ed-toast-in 0.18s ease-out;
}
.ed-toast-error { background: #a8322a; }
.ed-toast-txt { flex: 1; min-width: 0; }
.ed-toast-accion {
flex: none;
padding: 5px 10px;
border: 1px solid rgba(255, 255, 255, 0.45);
border-radius: 8px;
background: transparent;
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.ed-toast-accion:hover { background: rgba(255, 255, 255, 0.14); }
.ed-toast-cerrar {
flex: none;
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgba(255, 255, 255, 0.8);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.ed-toast-cerrar:hover { background: rgba(255, 255, 255, 0.14); color: #fff; }
.ed-toast button:focus-visible { outline: 2px solid #fff; outline-offset: 1px; }
@keyframes ed-toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
@media (max-width: 900px) {
.ed-toasts { bottom: calc(var(--wam-tabs-alto, 58px) + 70px); }
}
/* ---------- Enlace con la vista previa ---------- */
#chat-cuerpo .wa-row.ed-resaltado {
outline: 2px solid var(--wam-acento, #00a884);
outline-offset: 2px;
border-radius: 10px;
}
body.presentacion #chat-cuerpo .wa-row.ed-resaltado { outline: 0; }
.ed-tarjeta.ed-destello { animation: ed-destello 1.2s ease-out; }
@keyframes ed-destello {
0% { box-shadow: 0 0 0 3px var(--ed-acento); }
100% { box-shadow: 0 0 0 0 transparent; }
}
/* ---------- Destinos táctiles ---------- */
@media (pointer: coarse) {
.ed-btn-mini,
.ed-acciones .ed-btn-mini { min-width: 32px; min-height: 32px; }
.ed-fmt-btn { min-width: 32px; height: 32px; }
.ed-paleta .ed-tipo-card { padding: 8px 11px 8px 9px; }
.ed-seg-op span { padding: 6px 14px; }
}
@media (max-width: 480px) {
/* cabecera de tarjeta: la dirección se lee en el color del número */
.ed-tarjeta-cab .ed-dir { display: none; }
.ed-num.ed-num-in { color: var(--ed-entrada); background: var(--ed-entrada-suave); }
.ed-paleta-grupo { grid-template-columns: 1fr; gap: 4px; }
.ed-paleta-titulo { padding-top: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ed-toast, .ed-tarjeta.ed-destello { animation: none; }
.ed-bloque-cuenta > .ed-cuenta-sumario::before, .ed-seg-op span { transition: none; }
}
+119
View File
@@ -0,0 +1,119 @@
/* ==========================================================================
css/interactividad.css — dueño D (núcleo: js/interactivity.js, js/main.js)
Piezas que pinta la interactividad sobre el chat: cita de la respuesta
(quoted reply), botón ya pulsado (.usado), pulsación automática de la demo
(.wam-pulsando) y controles propios de #barra-movil (velocidad, reiniciar).
========================================================================== */
/* ---------- Cita dentro de la burbuja OUT (lista-respuesta-cita.png) ---------- */
.wa {
--wa-cita-fondo: rgba(0, 0, 0, .05);
--wa-cita-acento: #a0763a; /* barra + nombre, tostado de la captura */
--wa-cita-texto: rgba(0, 0, 0, .62);
}
.wa.oscuro {
--wa-cita-fondo: rgba(0, 0, 0, .18);
--wa-cita-acento: #d8a35d;
--wa-cita-texto: rgba(255, 255, 255, .72);
}
.wa .bubble.wa-con-cita { min-width: min(240px, 100%); }
.wa .wa-cita {
display: flex;
align-items: stretch;
margin: -2px -5px 5px -3px;
min-height: 56px;
border-radius: 8px;
border-left: 4px solid var(--wa-cita-acento);
background: var(--wa-cita-fondo);
overflow: hidden;
}
.wa .wa-cita-txt {
flex: 1 1 auto;
min-width: 0;
padding: 6px 10px 7px 9px;
}
.wa .wa-cita-nombre {
font-weight: 600;
font-size: 15px;
line-height: 1.3;
color: var(--wa-cita-acento);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wa .wa-cita-titulo {
font-weight: 600;
font-size: 15px;
line-height: 1.3;
color: var(--wa-cita-texto);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wa .wa-cita-extracto {
font-size: 15px;
line-height: 1.3;
color: var(--wa-cita-texto);
white-space: pre-line;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
overflow-wrap: anywhere;
}
.wa .wa-cita-cam {
display: inline-block;
vertical-align: -2px;
margin-right: 4px;
opacity: .8;
}
.wa .wa-cita-mini {
flex: none;
width: 56px;
min-height: 56px;
object-fit: cover;
display: block;
}
/* ---------- Botón ya pulsado (botones-respuesta-tras-pulsar.png) ---------- */
.wa .qr-btn.usado,
.wa .qr-btn.usado * {
color: #b0b0b0 !important;
fill: currentColor;
}
.wa .qr-btn.usado { pointer-events: none; cursor: default; }
.wa.oscuro .qr-btn.usado,
.wa.oscuro .qr-btn.usado * { color: #6f7b82 !important; }
/* ---------- Pulsación automática de la demo (autoPulsar) ---------- */
.wa .wam-pulsando { background: rgba(0, 0, 0, .10) !important; transition: background-color .08s ease; }
.wa.oscuro .wam-pulsando { background: rgba(255, 255, 255, .12) !important; }
/* ---------- Controles de #barra-movil ---------- */
#barra-movil .barra-acciones {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
#btn-reiniciar,
#sel-velocidad {
height: 34px;
padding: 0 12px;
border: 1px solid var(--wam-borde, rgba(0, 0, 0, .15));
border-radius: 999px;
background: var(--wam-panel, #fff);
color: var(--wam-texto, #111);
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
#sel-velocidad { padding: 0 8px; }
#btn-reiniciar:hover,
#sel-velocidad:hover { border-color: var(--wam-acento, #00a884); }
#btn-reiniciar:focus-visible,
#sel-velocidad:focus-visible { outline: 2px solid var(--wam-acento, #00a884); outline-offset: 2px; }
+12 -9
View File
@@ -30,15 +30,19 @@
)); ));
} }
body.presentacion { body.presentacion {
/* sin editor cabe más teléfono (el ahorro es horizontal; el vertical igual) */ /* sin editor ni barra de vista previa: solo resta la toolbar (~57 px) + aire */
--wam-escala: max(.3, min( --wam-escala: max(.3, min(
1, 1,
(100vh - 150px) / 852px, (100vh - 96px) / 852px,
(100vw - 64px) / 393px (100vw - 64px) / 393px
)); ));
} }
} }
/* Presentación: se oculta la barra "Vista previa / Reproducir demo" para
que el teléfono crezca (en 1280×720 pasa de .67 a ~.73). */
body.presentacion #barra-movil { display: none; }
/* --- Columna de vista previa (#panel-movil) ---------------------------- */ /* --- Columna de vista previa (#panel-movil) ---------------------------- */
#panel-movil { #panel-movil {
min-width: 0; min-width: 0;
@@ -160,7 +164,7 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 18px 30px 0 34px; padding: 18px 30px 0 34px;
color: #111b21; /* en claro, status bar oscura (research-ui §7) */ color: #000; /* en claro, status bar negra */
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
letter-spacing: .02em; letter-spacing: .02em;
@@ -177,14 +181,14 @@
bottom: 8px; bottom: 8px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 139px; width: 134px;
height: 5px; height: 5px;
border-radius: 3px; border-radius: 3px;
background: #fff; /* en oscuro (research-ui §7) */ background: #fff; /* en oscuro */
z-index: 45; z-index: 45;
pointer-events: none; pointer-events: none;
} }
#pantalla:not(.oscuro) .home-indicator { background: #0b141a; } /* en claro, oscuro */ #pantalla:not(.oscuro) .home-indicator { background: #000; } /* en claro, negro */
/* --- Colocación del chat (estructura A; la estética es css/whatsapp.css B) */ /* --- Colocación del chat (estructura A; la estética es css/whatsapp.css B) */
#chat-header { flex: none; } #chat-header { flex: none; }
@@ -215,9 +219,8 @@
background: none; background: none;
cursor: pointer; cursor: pointer;
} }
#composer .attach { color: var(--wa-text-meta, #667781); } #composer .attach { color: var(--wa-chrome-text, #000); }
#composer .enviar { color: var(--wa-accent, #00a884); } #composer .field svg { flex: none; }
#composer .field svg { flex: none; opacity: .55; color: var(--wa-text-meta, #667781); }
#composer-input { #composer-input {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
+536 -347
View File
File diff suppressed because one or more lines are too long
+193
View File
@@ -0,0 +1,193 @@
# Referencias visuales — WhatsApp iOS + Cloud API
Capturas reales de referencia para ajustar el mockup y la especificación visual que se deriva de ellas.
Recopiladas el 27-09-2026.
**Cómo se obtuvieron.** Casi todas vienen de la documentación oficial de Meta
(`developers.facebook.com/documentation/business-messaging/whatsapp/...`). Esas capturas son de
**WhatsApp iOS** (colita abajo, botones con icono, fuente SF, hoja inferior iOS) y están a ~3x
(1190 px ≈ 393 pt). Meta devuelve error a `curl`, así que la página se renderizó con Playwright y la
imagen se descargó con la misma sesión. Las dos capturas completas del chat vienen de la ficha oficial
de WhatsApp en el App Store (iPhone 6,9").
**Cómo leer los valores.** Los hex están **medidos por muestreo de píxeles** (el color dominante de
una región) sobre estas capturas. Las medidas en pt salen de dividir entre 3. Son aproximaciones:
PNG comprimido, antialiasing y perfil de color. El texto indica la fuente de cada valor: `[medido]`,
`[observado]` (se ve en la captura pero no tiene número exacto) o `[estimado]` (no está confirmado).
## 1. Capturas
| Archivo | Fuente | Qué muestra |
|---|---|---|
| `ios-chat-claro-appstore.png` | https://apps.apple.com/us/app/whatsapp-messenger/id310633997 (captura 6.9" Display 2) | Chat iOS completo en claro: header (chevron negro, avatar, nombre, "online", iconos vídeo y llamada en línea), aviso de cifrado amarillo, nota de voz, burbuja saliente con ticks azules, foto, ubicación en vivo con botón verde dentro de la burbuja |
| `ios-chat-oscuro-appstore.png` | ídem (captura 6.9" Display 4) | Chat iOS en **oscuro**: fondo casi negro con doodle, entrante gris neutro, saliente verde oscuro, ubicación, sticker y composer oscuro (+, píldora, cámara, micro) |
| `ios-composer-ticks-leido.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/mark-message-as-read | Saliente con ✓✓ **azul iOS** y composer iOS: "+", píldora blanca con borde e icono de sticker, cámara y micro, home indicator negro |
| `botones-respuesta-header-imagen.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-reply-buttons-messages | Reply buttons con header de imagen **inset**, body, footer gris, hora, y 2 botones **dentro de la misma burbuja** con icono ↩ verde |
| `botones-respuesta-tras-pulsar.png` | ídem (2.ª captura) | Estado tras pulsar: el botón usado queda **gris/deshabilitado** y la respuesta saliente **cita** el mensaje original (nombre, miniatura, extracto) |
| `template-imagen-url-llamada-quickreply.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/utility-templates/utility-templates | Plantilla de utilidad: header imagen, body con primera línea en negrita, footer, botones **URL (↗ en caja)**, **llamada (teléfono)** y **quick reply (↩)** apilados dentro de la burbuja |
| `template-ver-todas-opciones-android.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/components | (Android, estructura) Plantilla con más de 3 botones: se ven 2 y luego "See all options" (icono de lista), que abre una hoja "All Options" con todos los botones y sus iconos |
| `lista-burbuja.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-list-messages | List message: header en negrita, body, footer gris, hora y botón con icono de lista **dentro de la burbuja** |
| `lista-hoja-inferior.png` | ídem (2.ª) | Hoja inferior iOS de la lista: título centrado (= texto del botón), X en círculo gris, títulos de sección en gris y negrita, filas título/descripción **sin chevrons**, separadores con sangría y el pie "Tap an item to select it" |
| `lista-respuesta-cita.png` | ídem (3.ª) | Respuesta a la lista: burbuja saliente con cita del mensaje + título y descripción de la fila elegida |
| `cta-url-header-imagen.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-cta-url-messages | CTA URL: todo en **una burbuja** (imagen inset, body, footer, hora, separador, botón ↗ "See Dates" verde); **sin fila de dominio** |
| `carrusel-plantilla-media-cards.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/marketing-templates/media-card-carousel-templates | Carrusel de plantilla: burbuja de body con hora y debajo tarjetas horizontales (imagen inset, texto 17 pt, botones ↩ y ↗); la siguiente tarjeta asoma; **sin puntos** |
| `carrusel-interactivo-quick-replies.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-media-carousel-messages | Carrusel interactivo: 3 tarjetas con título en negrita + descripción y 2 quick replies con icono |
| `carrusel-productos.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/marketing-templates/product-card-carousel-templates | Carrusel de productos: panel gris claro con título y precio, botón "View" |
| `imagen-caption.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/image-messages | Imagen inset con esquinas redondeadas, caption y hora a la derecha, botón de reenviar fuera |
| `video-caption.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/video-messages | Vídeo: play en círculo claro translúcido, icono de cámara con duración abajo a la izquierda, caption |
| `documento.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/document-messages | Documento: panel gris interno con icono de página "PDF", nombre en 2 líneas, "243 KB • html", descarga en círculo verde y caption |
| `audio-nota-voz.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/audio-messages | Nota de voz: triángulo de play gris sin círculo, punto verde de progreso, waveform, avatar con micro verde, duración y transcripción |
| `ubicacion.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/location-messages | Ubicación: mapa inset con pin rojo, nombre en regular 17 pt y dirección gris subrayada |
| `contacto.png` | https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/contacts-messages | Contacto: avatar genérico gris, nombre en negrita y chevron, hora, y dos botones lado a lado "Message" / "Save contact" |
## 2. Especificación visual (iOS 2025–2026)
Valores en pt/CSS px sobre un ancho de 393. Si `#pantalla` está escalada, se escala todo junto.
### 2.1 Paleta
| Token | Claro | Oscuro | Fuente |
|---|---|---|---|
| Fondo del chat (wall) | `#f4f1ec` (App Store `#f6f1ea`) | `#0a0a0a` | [medido] |
| Trazo del doodle | `#e8e0d4` (tostado cálido, opaco) | `#1f2121` | [medido] |
| Burbuja entrante | `#ffffff` | `#232626` (gris neutro, no azulado) | [medido] |
| Burbuja saliente | `#d9fdd3` | `#004f35` (App Store comprimido; revisar a ojo) | [medido] |
| Texto de burbuja | `#000000` | `#ffffff` | [observado] |
| Hora y footer (entrante) | `#7f7f7f` | ≈`#8d9494` | [medido] / [estimado] |
| Hora en saliente | `#6b7d68` (gris verdoso) | ≈`#8fb3a3` | [medido] / [estimado] |
| ✓ / ✓✓ entregado | igual que la hora | igual que la hora | [observado] |
| ✓✓ leído | **`#027bfc`** (≈ azul iOS `#007aff`) | `#0a84ff` [estimado] | [medido] |
| Texto de botones de acción (quick reply, URL, lista, carrusel) | **`#408559`** | ≈`#21c063` [estimado] | [medido] |
| Separador de botones | `#cccccc` (1 px físico ≈ 0,33 pt → CSS `0.5px` o `1px rgba(0,0,0,.2)`) | `rgba(255,255,255,.12)` [estimado] | [medido] |
| Header / barra del composer | `#f4f3f2` (translúcido sobre el wall) | header `#121313`, barra `#232626` | [medido] |
| Campo del composer | `#ffffff` con borde `#d1d1d6` 0,5 pt | `#393c3c` sin borde | [medido] / [observado] |
| Iconos del chrome (chevron, vídeo, llamada, +, cámara, micro) | `#000000` (de línea) | `#ffffff` | [observado] |
| Aviso de cifrado | fondo `#ffefd1`, texto `#1f1f1f`, radio 10 | ≈`#2a2a26` [estimado] | [medido] |
| Hoja de lista: fondo | `#ffffff` | ≈`#1c1c1e` [estimado] | [medido] |
| Hoja: título de sección / descripción | `#757778` | ≈`#8d8d93` [estimado] | [medido] |
| Hoja: separadores | `#c6c6c8` | ≈`#38383a` [estimado] | [medido] |
| Hoja: botón cerrar | círculo `#eeeeef`, X `#838388` | ≈`#2c2c2e` / `#a0a0a5` | [medido] / [estimado] |
| Velo detrás de la hoja | `rgba(0,0,0,.26)` | `rgba(0,0,0,.5)` [estimado] | [medido] |
| Panel interno de documento / producto | `#f6f5f4` | ≈`#1b1d1d` [estimado] | [medido] |
| Home indicator | `#000000` (claro) | `#ffffff` (oscuro) | [observado] |
### 2.2 Burbuja
- Radio **18 pt** en las cuatro esquinas. **La colita va abajo**: la entrante abajo a la izquierda y la saliente abajo a la derecha, como una curva que sale del borde inferior (se ve en todas las capturas de Meta). Solo la lleva la **última** burbuja de un grupo del mismo emisor. La esquina de arriba no se afila. [observado]
- Ancho máximo aproximado de 280–290 pt (~72 % de 393). Las plantillas y las burbujas con media usan ese ancho completo. [medido]
- Padding de texto aproximado: 7 pt arriba, 12 pt a izquierda y derecha, 6 pt abajo. [medido]
- Fuente del body: 17 pt SF Pro, interlineado de ~22 pt, negro. [medido]
- Hora: ~12 pt con números tabulares, abajo a la derecha. Con footer, la hora se alinea con la última línea del footer. Ticks de ~16×11 pt. [medido]
- Separación: ~2 pt entre burbujas del mismo emisor y ~8–10 pt al cambiar de emisor. [observado]
### 2.3 Media dentro de la burbuja (imagen, vídeo, ubicación y header de plantilla o interactivo)
- **Inset**, no a sangre: margen de **4 pt** (arriba y a los lados) entre la media y el borde de la burbuja. Radio interior de **~13–14 pt**. [medido]
- Con caption o body debajo, el texto sigue con el padding normal. Sin caption, la hora va sobre la imagen en blanco con una sombra suave. [observado]
- Proporción del header: en plantillas se recorta a ~**1,91:1** (utility, CTA). En interactivos se mantiene la del archivo, hasta cuadrado (reply buttons). [observado]
- Vídeo: play centrado en un círculo de 50 pt `rgba(230,230,230,.8)` con triángulo gris oscuro. Abajo a la izquierda, icono de cámara y duración en blanco de 13 pt. [observado]
### 2.4 Botones de plantilla y botones interactivos (reply buttons)
En iOS **se pintan igual**: van **dentro de la misma burbuja**, no en una tarjeta aparte.
- Bajo el body/footer/hora va un separador a todo el ancho de la burbuja, de borde a borde y sin sangría. [medido]
- Cada botón ocupa una fila de **~43–44 pt**, con el contenido centrado. Entre botones hay otro separador a todo el ancho. [medido]
- Texto de 17 pt regular en `#408559`, con un icono de ~20 pt a la izquierda y 8 pt de hueco. [medido]
- Quick reply → flecha de responder **↩** en línea (arco curvo).
- URL → **cuadrado redondeado con flecha ↗** dentro.
- Llamada → auricular en línea.
- Lista → icono **≡ con puntos** (tres puntos + tres líneas).
- "Ver todas las opciones" → icono de lista.
- Con más de 3 botones se muestran 2 y una tercera fila "**Ver todas las opciones**" sin contador. Esa fila **abre una hoja** "Todas las opciones" con todos los botones (icono + texto, alineados a la izquierda) y un separador entre URL/llamada y quick replies. No se despliegan en línea. [observado, Android; mismo comportamiento documentado por Meta]
- Tras pulsar un quick reply, esa fila queda en gris (`#b0b0b0`, sin interacción). [observado]
- La respuesta saliente **cita** el mensaje: un bloque interno con radio de 8 pt, fondo `rgba(0,0,0,.04)` sobre verde (`#d5f3cf`), barra izquierda de 4 pt de color, nombre del negocio en semibold 15 pt, extracto de 1–3 líneas en 15 pt gris y miniatura a la derecha si hay media. Debajo va el texto del botón en 17 pt. [medido/observado]
### 2.5 Plantilla (template)
Orden: header → body → footer → hora → separador → botones, en **una sola burbuja** de 18 pt con colita.
- Header de texto: 17 pt semibold, negro.
- Body: 17 pt regular. Admite `*negrita*`.
- Footer: **15 pt** gris `#7f7f7f`, 4 pt por debajo del body.
- Hora a la derecha, en la línea del footer.
- Header de documento: el mismo panel gris interno que el mensaje de documento (§2.9).
### 2.6 Lista
- Burbuja: header 17 pt semibold, body 17 pt, footer 15 pt gris, hora, separador y botón con icono ≡ y el texto `boton` en verde. Todo dentro de la burbuja.
- Hoja inferior:
- Panel blanco, esquinas superiores de ~12 pt y altura según el contenido.
- Cabecera de ~56 pt con el **título centrado = texto del botón**, en 17 pt semibold.
- A la derecha, un **círculo de 30 pt** `#eeeeef` con una X de 13 pt `#838388`.
- Título de sección: 15 pt semibold `#757778`, con 16 pt de sangría y ~24 pt de aire encima.
- Fila: título 17 pt negro y descripción 15 pt `#757778`. Padding vertical de ~12 pt (~76 pt de alto con descripción). **Sin chevron ni radio** en iOS.
- Separador de 0,33 pt `#c6c6c8` con 16 pt de sangría izquierda que llega al borde derecho.
- Pie centrado "Toca un elemento para seleccionarlo" en 15 pt gris, con el home indicator debajo.
- Velo `rgba(0,0,0,.26)`.
### 2.7 CTA URL
- Una sola burbuja: media inset opcional, body, footer, hora, separador y botón ↗ + etiqueta en verde.
- **No hay fila de dominio** ni texto azul.
### 2.8 Carrusel (plantilla media card o interactivo)
- Burbuja de body normal (con hora y colita) y, debajo, tarjetas en scroll horizontal.
- Tarjeta de **~287 pt de ancho (~73 %)**. La primera se alinea con el borde izquierdo de las burbujas, las demás se desbordan hasta el borde de la pantalla y la siguiente asoma. Separación de **~4 pt**. [medido]
- Tarjeta blanca de 18 pt de radio, **sin colita y sin hora**. Imagen inset de 4 pt, radio de ~12 pt y proporción ~1,7:1. [medido]
- Texto de la tarjeta en 17 pt. En interactivo: título semibold 17 pt + descripción 17 pt.
- Botones como en §2.4: separador a todo el ancho, filas de 44 pt con icono ↩/↗ en verde.
- **Sin puntos de paginación.** [observado]
- Producto: panel `#f6f5f4` con título 17 pt semibold y precio 15 pt gris; botón "Ver".
### 2.9 Otros tipos
- **Documento:**
- Panel interno `#f6f5f4` con radio de 10 pt y 4 pt de inset.
- Dentro: icono de página blanca de 30×38 pt con la extensión en gris de 9 pt, nombre de archivo en 17 pt (hasta 2 líneas) y "243 KB • pdf" en 15 pt gris.
- A la derecha, descarga en círculo de 30 pt con **borde verde `#4caf6a`** y flecha verde.
- Debajo, caption de 17 pt con la hora.
- **Nota de voz:**
- Triángulo de play gris `#6b6b6b` de 22 pt **sin círculo**.
- Waveform de barras de 2 pt `#b3b3b3` con un punto verde de progreso de 12 pt al inicio.
- Duración en 13 pt gris debajo de la waveform.
- A la derecha, avatar de 44 pt con **micro verde** en la esquina inferior izquierda.
- Hora abajo a la derecha.
- **Ubicación:**
- Mapa inset de 4 pt, radio de 13 pt y ~155 pt de alto.
- Nombre en 17 pt **regular**.
- Dirección en 15 pt gris **subrayada**.
- Hora en una línea propia a la derecha.
- **Contacto:**
- Avatar genérico de 44 pt (degradado gris con silueta blanca), nombre en 17 pt semibold y chevron `›` gris.
- Hora a la derecha.
- Separador y **dos botones lado a lado** "Mensaje" | "Guardar contacto" (o uno solo, "Invitar a WhatsApp"), en verde, 17 pt y 44 pt de alto, con un separador vertical entre ellos.
- **No** muestra teléfono, email ni empresa dentro de la burbuja: eso aparece al abrir el perfil.
- **Sticker:** sin burbuja. La hora va en una pastilla `rgba(0,0,0,.35)` con texto blanco debajo, a la derecha. [observado en oscuro]
### 2.10 Chrome iOS
- **Header** (~44 pt bajo la status bar):
- Chevron `‹` **negro** de ~22 pt, sin texto.
- Avatar de 36 pt.
- Nombre en 17 pt semibold y subtítulo ("en línea") en 13 pt gris.
- A la derecha, iconos **de línea**: videocámara de 28 pt y teléfono de 24 pt, en negro con ~0,6 de opacidad o gris `#8e8e93`. En oscuro, blancos.
- Fondo `#f4f3f2`, sin borde visible.
- **Verificado:** insignia azul de Meta Verified junto al nombre. Tono `#1d9bf0`–`#0866ff` [estimado, sin captura iOS de negocio verificado].
- **Aviso de cifrado** (primer elemento del chat):
- Tarjeta centrada de ~90 % de ancho, fondo `#ffefd1`, radio de 10 pt y padding de 6×12 pt.
- Texto centrado de 13 pt con candado inicial y "Más información" en negrita.
- En chats de negocio el texto es el de negocio: "Este negocio usa un servicio seguro de Meta…". [observado en Android; en iOS se asume el mismo texto]
- **Separador de fecha "Hoy":** pastilla centrada de 12–13 pt semibold, fondo blanco translúcido (`rgba(255,255,255,.9)`) con sombra de 0,5 pt, y 4×10 pt de padding. [estimado a partir de capturas Android/Web; sin captura iOS]
- **Composer:**
- Barra `#f4f3f2`, con el padding inferior del safe area (**34 pt**) bajo el home indicator.
- "+" negro de 24 pt.
- Píldora blanca de 36 pt de alto y radio de 18 pt, con borde de 0,5 pt `#d1d1d6`, **icono de sticker dentro a la derecha** y sin placeholder visible.
- Después, **cámara** y **micro** en negro, fuera de la píldora.
- El botón de enviar (círculo verde con flecha) **solo aparece al escribir** y sustituye a cámara y micro.
- **Home indicator:** negro en claro y blanco en oscuro, de 134×5 pt, **encima** de la barra del composer.
---
**Antes de reutilizar esto fuera del equipo, valida** que las capturas de Meta y de Apple solo se usan como referencia interna (tienen derechos de sus autores) y revisa a ojo en un iPhone real los hex de modo oscuro marcados como [estimado].
+110 -37
View File
@@ -12,6 +12,7 @@
<link rel="stylesheet" href="css/iphone.css"> <link rel="stylesheet" href="css/iphone.css">
<link rel="stylesheet" href="css/whatsapp.css"> <link rel="stylesheet" href="css/whatsapp.css">
<link rel="stylesheet" href="css/editor.css"> <link rel="stylesheet" href="css/editor.css">
<link rel="stylesheet" href="css/interactividad.css">
</head> </head>
<body data-tema="claro"> <body data-tema="claro">
@@ -27,64 +28,119 @@
<h1>WhatsApp Cloud API</h1> <h1>WhatsApp Cloud API</h1>
<span class="toolbar-subtitulo">Mockup visual · demo estática</span> <span class="toolbar-subtitulo">Mockup visual · demo estática</span>
</div> </div>
<!-- Estado del guardado local (lo pinta js/ux.js) -->
<span id="estado-guardado" class="tb-guardado" data-estado="ok" title="Guardado en este navegador">
<span class="tb-guardado-punto" aria-hidden="true"></span>
<span class="tb-guardado-txt">Guardado</span>
<span class="tb-guardado-rel" aria-hidden="true"></span>
</span>
</div> </div>
<div class="toolbar-acciones"> <div class="toolbar-acciones">
<button id="btn-tema" class="tb-btn" type="button" title="Alternar tema claro / oscuro" aria-label="Alternar tema"> <button id="btn-deshacer" class="tb-btn tb-btn-icono" type="button" title="Deshacer (Ctrl+Z)" aria-label="Deshacer" disabled>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/></svg>
</button>
<button id="btn-rehacer" class="tb-btn tb-btn-icono" type="button" title="Rehacer (Ctrl+Mayús+Z)" aria-label="Rehacer" disabled>
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 14 5-5-5-5"/><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"/></svg>
</button>
<span class="tb-separador" aria-hidden="true"></span>
<button id="btn-tema" class="tb-btn" type="button" title="Alternar tema claro / oscuro (T)" aria-label="Tema oscuro" aria-pressed="false">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 2.6a9.4 9.4 0 1 0 0 18.8 9.4 9.4 0 0 0 0-18.8zm0 16.9V4.5a7.5 7.5 0 0 1 0 15z"/></svg> <svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 2.6a9.4 9.4 0 1 0 0 18.8 9.4 9.4 0 0 0 0-18.8zm0 16.9V4.5a7.5 7.5 0 0 1 0 15z"/></svg>
<span>Tema</span> <span>Tema</span>
</button> </button>
<button id="btn-presentacion" class="tb-btn" type="button" title="Modo presentación (oculta el editor)"> <button id="btn-presentacion" class="tb-btn" type="button" title="Modo presentación: oculta el editor (P · Esc para salir)" aria-pressed="false">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" fill-rule="evenodd" aria-hidden="true"><path d="M3 4.5h18a1 1 0 0 1 1 1V16a1 1 0 0 1-1 1h-6.4v2.1h1.9a.9.9 0 1 1 0 1.8H7.5a.9.9 0 1 1 0-1.8h1.9V17H3a1 1 0 0 1-1-1V5.5a1 1 0 0 1 1-1zm8 3.4v5.7l4.6-2.85z"/></svg> <svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" fill-rule="evenodd" aria-hidden="true"><path d="M3 4.5h18a1 1 0 0 1 1 1V16a1 1 0 0 1-1 1h-6.4v2.1h1.9a.9.9 0 1 1 0 1.8H7.5a.9.9 0 1 1 0-1.8h1.9V17H3a1 1 0 0 1-1-1V5.5a1 1 0 0 1 1-1zm8 3.4v5.7l4.6-2.85z"/></svg>
<span>Presentación</span> <span>Presentación</span>
</button> </button>
<span class="tb-separador" aria-hidden="true"></span> <span class="tb-separador tb-sep-sec" aria-hidden="true"></span>
<button id="btn-exportar" class="tb-btn" type="button" title="Exportar el estado a JSON"> <!-- «⋯»: en ventana estrecha agrupa las acciones secundarias -->
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 3.2c.5 0 .9.4.9.9v8.2l2.7-2.7a.9.9 0 1 1 1.3 1.3l-4.2 4.2a.9.9 0 0 1-1.3 0L7.2 11a.9.9 0 1 1 1.3-1.3l2.7 2.7V4.1c0-.5.4-.9.9-.9zM4.9 18.5h14.2a.9.9 0 1 1 0 1.8H4.9a.9.9 0 1 1 0-1.8z"/></svg> <button id="btn-mas" class="tb-btn tb-btn-icono" type="button" aria-label="Más acciones" aria-expanded="false" aria-controls="tb-menu" title="Más acciones">
<span>Exportar</span> <svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="19" cy="12" r="1.9"/></svg>
</button>
<button id="btn-importar" class="tb-btn" type="button" title="Importar un estado JSON">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 15.6c-.5 0-.9-.4-.9-.9V6.5L8.4 9.2A.9.9 0 1 1 7.1 7.9l4.2-4.2a.9.9 0 0 1 1.3 0l4.2 4.2a.9.9 0 1 1-1.3 1.3l-2.7-2.7v8.2c0 .5-.4.9-.9.9zM4.9 18.5h14.2a.9.9 0 1 1 0 1.8H4.9a.9.9 0 1 1 0-1.8z"/></svg>
<span>Importar</span>
</button>
<input type="file" id="importar-archivo" accept="application/json,.json" hidden>
<button id="btn-reset" class="tb-btn" type="button" title="Restablecer la demo (borra localStorage)">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 5V2.8a.7.7 0 0 0-1.13-.55L6.9 5.2a.7.7 0 0 0 0 1.1l3.97 2.95A.7.7 0 0 0 12 8.7V6.5a5.5 5.5 0 1 1-5.5 5.5H4.75A7.25 7.25 0 1 0 12 5z"/></svg>
<span>Restablecer</span>
</button> </button>
<div id="tb-menu" class="tb-grupo-sec">
<button id="btn-demos" class="tb-btn" type="button" title="Nueva demo: escenarios por industria y demos guardadas (N)">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.6"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.6"/><path d="M17 14v6M14 17h6"/></svg>
<span>Demos</span>
</button>
<button id="btn-exportar" class="tb-btn" type="button" title="Exportar la demo a JSON (Ctrl+S)">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 3.2c.5 0 .9.4.9.9v8.2l2.7-2.7a.9.9 0 1 1 1.3 1.3l-4.2 4.2a.9.9 0 0 1-1.3 0L7.2 11a.9.9 0 1 1 1.3-1.3l2.7 2.7V4.1c0-.5.4-.9.9-.9zM4.9 18.5h14.2a.9.9 0 1 1 0 1.8H4.9a.9.9 0 1 1 0-1.8z"/></svg>
<span>Exportar</span>
</button>
<button id="btn-importar" class="tb-btn" type="button" title="Importar una demo desde JSON (sustituye la actual)">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 15.6c-.5 0-.9-.4-.9-.9V6.5L8.4 9.2A.9.9 0 1 1 7.1 7.9l4.2-4.2a.9.9 0 0 1 1.3 0l4.2 4.2a.9.9 0 1 1-1.3 1.3l-2.7-2.7v8.2c0 .5-.4.9-.9.9zM4.9 18.5h14.2a.9.9 0 1 1 0 1.8H4.9a.9.9 0 1 1 0-1.8z"/></svg>
<span>Importar</span>
</button>
<input type="file" id="importar-archivo" accept="application/json,.json" hidden>
<button id="btn-reset" class="tb-btn" type="button" title="Restablecer la demo de ejemplo (pide confirmación)">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 5V2.8a.7.7 0 0 0-1.13-.55L6.9 5.2a.7.7 0 0 0 0 1.1l3.97 2.95A.7.7 0 0 0 12 8.7V6.5a5.5 5.5 0 1 1-5.5 5.5H4.75A7.25 7.25 0 1 0 12 5z"/></svg>
<span>Restablecer</span>
</button>
<button id="btn-atajos" class="tb-btn tb-btn-icono" type="button" title="Atajos de teclado (?)" aria-label="Atajos de teclado">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="6" width="19" height="12" rx="2.2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7.5 14h9"/></svg>
<span class="tb-solo-menu">Atajos</span>
</button>
</div>
</div> </div>
</header> </header>
<!-- ============ Panel editor (columna izquierda · contenido: módulo C) ============ --> <!-- ============ Panel editor (columna izquierda · contenido: módulo C) ============ -->
<aside id="panel-editor" aria-label="Editor del mockup"> <aside id="panel-editor" aria-label="Editor del mockup">
<section class="wam-bloque"> <!-- Cuenta: se edita una vez por demo; plegada si ya hay mensajes -->
<h2 class="wam-bloque-titulo">Cuenta</h2> <details id="bloque-cuenta" class="wam-bloque ed-bloque-cuenta" open>
<summary class="ed-cuenta-sumario">
<span class="wam-bloque-titulo">Cuenta</span>
<span id="cuenta-resumen" class="ed-cuenta-resumen"></span>
</summary>
<!-- El formulario de identidad lo pinta js/editor.js (C) en runtime --> <!-- El formulario de identidad lo pinta js/editor.js (C) en runtime -->
<div id="editor-cuenta"></div> <div id="editor-cuenta"></div>
</section> </details>
<section class="wam-bloque"> <section class="wam-bloque ed-bloque-mensajes" aria-labelledby="titulo-mensajes">
<h2 class="wam-bloque-titulo">Mensajes</h2> <div class="ed-bloque-cab">
<h2 id="titulo-mensajes" class="wam-bloque-titulo">Mensajes</h2>
<button id="ed-avisos-global" class="ed-avisos-global" type="button" data-acc="ir-aviso" hidden></button>
</div>
<div class="wam-alta"> <div class="wam-alta">
<!-- Paleta de tarjetas de los 13 tipos; la rellena js/editor.js (C) en runtime. <!-- Paleta de los 13 tipos (la rellena js/editor.js): un clic AÑADE el mensaje.
El tipo elegido queda en #nuevo-tipo-valor (hidden) y en data-valor de la paleta. --> El último tipo usado queda en #nuevo-tipo-valor (hidden) y en data-valor. -->
<div id="nuevo-tipo" class="ed-paleta" role="radiogroup" aria-label="Tipo de mensaje a añadir"></div> <div id="nuevo-tipo" class="ed-paleta" role="toolbar" aria-label="Añadir un mensaje: elige el tipo"></div>
<input type="hidden" id="nuevo-tipo-valor" value="text"> <input type="hidden" id="nuevo-tipo-valor" value="text">
<button id="btn-anadir" class="wam-btn-anadir" type="button">Añadir</button> <div class="ed-alta-pie">
<span id="ed-destino-alta" class="ed-destino-alta" aria-live="polite"></span>
<button id="btn-anadir" class="wam-btn-anadir" type="button">+ Otro «Texto»</button>
</div>
</div>
<div class="ed-lista-herr">
<button type="button" class="ed-btn ed-btn-mini" data-acc="horas-secuencia" title="Reparte las horas desde la del primer mensaje, +1 min por mensaje">Horas en secuencia</button>
<button type="button" class="ed-btn ed-btn-mini" data-acc="plegar-todo">Plegar todo</button>
</div> </div>
<!-- Una tarjeta colapsable por mensaje la pinta js/editor.js (C) --> <!-- Una tarjeta colapsable por mensaje la pinta js/editor.js (C) -->
<div id="lista-mensajes"></div> <div id="lista-mensajes"></div>
</section> </section>
<!-- Solo en ventana estrecha: salta a la vista previa del mensaje en edición -->
<button id="btn-ver-telefono" class="ed-ver-telefono" type="button">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><rect x="6.5" y="2.5" width="11" height="19" rx="2.5"/><path d="M11 18.5h2"/></svg>
Ver teléfono
</button>
</aside> </aside>
<!-- ============ Panel móvil (columna derecha · shell A) ============ --> <!-- ============ Panel móvil (columna derecha · shell A) ============ -->
<section id="panel-movil" aria-label="Vista previa en un iPhone"> <section id="panel-movil" aria-label="Vista previa en un iPhone">
<div id="barra-movil"> <div id="barra-movil">
<span class="barra-titulo">Vista previa</span> <span class="barra-titulo">Vista previa</span>
<button id="btn-reproducir" type="button" title="Reproducir la demo paso a paso (clic de nuevo para detener)"> <div class="barra-acciones">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M8.4 5.6v12.8c0 .8.9 1.3 1.6.9l10-6.4c.6-.4.6-1.4 0-1.8l-10-6.4c-.7-.4-1.6.1-1.6.9z"/></svg> <select id="sel-velocidad" title="Velocidad de la reproducción" aria-label="Velocidad de la reproducción">
<span>Reproducir demo</span> <option value="0.5">0,5x</option>
</button> <option value="1" selected>1x</option>
<option value="2">2x</option>
</select>
<button id="btn-reiniciar" type="button" title="Descartar lo pulsado y escrito en el teléfono y volver al guion">Reiniciar chat</button>
<button id="btn-reproducir" type="button" title="Reproducir la demo paso a paso (clic de nuevo para detener)">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M8.4 5.6v12.8c0 .8.9 1.3 1.6.9l10-6.4c.6-.4.6-1.4 0-1.8l-10-6.4c-.7-.4-1.6.1-1.6.9z"/></svg>
<span>Reproducir demo</span>
</button>
</div>
</div> </div>
<div class="iphone-escena"> <div class="iphone-escena">
@@ -150,18 +206,21 @@
<!-- Composer (estructura A · lógica D) --> <!-- Composer (estructura A · lógica D) -->
<form id="composer" class="compose" autocomplete="off"> <form id="composer" class="compose" autocomplete="off">
<button type="button" class="attach" aria-label="Adjuntar"> <button type="button" class="attach" aria-label="Adjuntar" tabindex="-1">
<svg viewBox="0 0 24 24" width="25" height="25" fill="currentColor" aria-hidden="true"><path d="M11.1 11.1V5.4a.9.9 0 1 1 1.8 0v5.7h5.7a.9.9 0 1 1 0 1.8h-5.7v5.7a.9.9 0 1 1-1.8 0v-5.7H5.4a.9.9 0 1 1 0-1.8z"/></svg> <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><path d="M12 4v16M4 12h16"/></svg>
</button> </button>
<div class="field"> <div class="field">
<input id="composer-input" type="text" placeholder="Mensaje" aria-label="Mensaje" autocomplete="off" enterkeyhint="send"> <input id="composer-input" type="text" placeholder=" " aria-label="Mensaje" autocomplete="off" enterkeyhint="send">
<svg class="mic" viewBox="0 0 24 24" width="21" height="21" fill="currentColor" aria-hidden="true"> <svg class="sticker" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" aria-hidden="true"><path d="M20 12.8V8a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v8a4 4 0 0 0 4 4h4.8Z"/><path d="M20 12.8h-3.4a3.8 3.8 0 0 0-3.8 3.8V20"/></svg>
<path d="M12 13.6a3.2 3.2 0 0 0 3.2-3.2V5.2a3.2 3.2 0 1 0-6.4 0v5.2A3.2 3.2 0 0 0 12 13.6z"/>
<path d="M18.4 10.4a.9.9 0 1 0-1.8 0 4.6 4.6 0 0 1-9.2 0 .9.9 0 1 0-1.8 0 6.4 6.4 0 0 0 5.5 6.3v2.1H8.6a.9.9 0 1 0 0 1.8h6.8a.9.9 0 1 0 0-1.8h-2.5v-2.1a6.4 6.4 0 0 0 5.5-6.3z"/>
</svg>
</div> </div>
<button type="button" class="cmp-icono cmp-camara" aria-label="Cámara" tabindex="-1">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" aria-hidden="true"><path d="M3 8.6A2.1 2.1 0 0 1 5.1 6.5h2.3l1.5-2h6.2l1.5 2h2.3A2.1 2.1 0 0 1 21 8.6v8.8a2.1 2.1 0 0 1-2.1 2.1H5.1A2.1 2.1 0 0 1 3 17.4Z"/><circle cx="12" cy="12.8" r="3.7"/></svg>
</button>
<button type="button" class="cmp-icono cmp-mic" aria-label="Nota de voz" tabindex="-1">
<svg viewBox="0 0 24 24" width="24" height="26" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><rect x="8.6" y="2.8" width="6.8" height="11.6" rx="3.4"/><path d="M5.6 11a6.4 6.4 0 0 0 12.8 0M12 17.4v3.8"/></svg>
</button>
<button id="composer-enviar" class="enviar" type="submit" aria-label="Enviar mensaje"> <button id="composer-enviar" class="enviar" type="submit" aria-label="Enviar mensaje">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M3.5 20.2 21.4 12 3.5 3.8v6.3L15 12 3.5 13.9z"/></svg> <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M4.4 19.6 21 12 4.4 4.4l.1 5.9L15 12 4.5 13.7Z"/></svg>
</button> </button>
</form> </form>
@@ -180,6 +239,18 @@
</div> </div>
</section> </section>
<!-- Ventana estrecha (≤ 900 px): pestañas fijas Editor / Vista previa (js/ux.js) -->
<nav id="tabs-movil" class="tabs-movil" role="tablist" aria-label="Vista">
<button id="tab-editor" class="tab-movil" type="button" role="tab" aria-selected="false" aria-controls="panel-editor" tabindex="-1">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/></svg>
<span>Editor</span>
</button>
<button id="tab-vista" class="tab-movil" type="button" role="tab" aria-selected="true" aria-controls="panel-movil">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true"><rect x="6.5" y="2.5" width="11" height="19" rx="2.5"/><path d="M11 18.5h2"/></svg>
<span>Vista previa</span>
</button>
</nav>
</div> </div>
<noscript><p class="wam-noscript">Esta demo necesita JavaScript habilitado.</p></noscript> <noscript><p class="wam-noscript">Esta demo necesita JavaScript habilitado.</p></noscript>
@@ -190,7 +261,9 @@
<script src="js/render.js"></script> <!-- B: render del chat; NO toca DOM al cargar --> <script src="js/render.js"></script> <!-- B: render del chat; NO toca DOM al cargar -->
<script src="js/editor.js"></script> <!-- C: formularios del editor; NO toca DOM al cargar --> <script src="js/editor.js"></script> <!-- C: formularios del editor; NO toca DOM al cargar -->
<script src="js/interactivity.js"></script> <!-- D: flujos/typing/demo; NO toca DOM al cargar --> <script src="js/interactivity.js"></script> <!-- D: flujos/typing/demo; NO toca DOM al cargar -->
<script src="js/main.js"></script> <!-- D: única ejecución — WAM.init() --> <script src="js/escenarios.js"></script> <!-- C: escenarios por industria (datos); NO toca DOM al cargar -->
<script src="js/ux.js"></script> <!-- C: deshacer, diálogos, atajos, pestañas; WAM.initUX() lo llama renderEditor -->
<script src="js/main.js"></script> <!-- D: única ejecución — WAM.init() -->
</body> </body>
</html> </html>
+1241 -158
View File
File diff suppressed because it is too large Load Diff
+330
View File
@@ -0,0 +1,330 @@
/* =====================================================================
js/escenarios.js — Escenarios de arranque por industria (editor, C).
Objetos JS planos (sin fetch: funciona por file://). Cada escenario
devuelve un estado que se pasa por WAM.normalizar antes de usarse.
Todos los negocios, personas, direcciones y cifras son FICTICIOS y
de ejemplo: el equipo debe revisar los textos antes de usarlos con
un cliente. No toca el DOM al cargar.
===================================================================== */
(function () {
"use strict";
window.WAM = window.WAM || {};
function escXml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" }[c];
});
}
// Ilustración SVG ligera en línea (sin red): fondo + círculo + rótulo
function ilus(texto, fondo, acento, simbolo) {
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="130" r="74" fill="' + acento + '" opacity=".9"/>' +
'<text x="240" y="152" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="62" fill="#fff">' + escXml(simbolo || "") + "</text>" +
'<text x="240" y="266" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="25" fill="#33414a">' + escXml(texto || "") + "</text>" +
"</svg>";
return "data:image/svg+xml," + encodeURIComponent(svg);
}
function avatar(letra, color) {
var svg =
'<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="' + color + '"/>' +
'<text x="48" y="62" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="40" font-weight="700" fill="#fff">' + escXml(letra) + "</text>" +
"</svg>";
return "data:image/svg+xml," + encodeURIComponent(svg);
}
// Atajos de construcción
function t(dir, hora, texto, extra) {
var m = { dir: dir, tipo: "text", hora: hora, texto: texto };
if (extra) Object.keys(extra).forEach(function (k) { m[k] = extra[k]; });
return m;
}
function r(texto) { return { dir: "in", tipo: "text", hora: "", texto: texto }; }
function msg(dir, tipo, hora, campos) {
var m = { dir: dir, tipo: tipo, hora: hora, estado: dir === "out" ? "read" : "sent" };
Object.keys(campos || {}).forEach(function (k) { m[k] = campos[k]; });
return m;
}
function estado(cuenta, mensajes) {
return { version: 1, tema: "claro", cuenta: cuenta, mensajes: mensajes };
}
var ESCENARIOS = [
{
id: "blanco",
nombre: "En blanco",
descripcion: "Solo la cuenta, sin mensajes. Para construir desde cero.",
datos: function () {
return estado({ nombre: "Mi negocio", avatar: "", subtitulo: "en línea", verificado: false }, []);
}
},
{
id: "restaurante",
nombre: "Restaurante · reservas",
descripcion: "Reserva de mesa con plantilla de confirmación, menú en lista y ubicación.",
datos: function () {
return estado({ nombre: "La Terraza del Puerto", avatar: avatar("T", "#b5523b"), subtitulo: "en línea", verificado: true }, [
t("out", "13:02", "Hola, ¿tienen mesa para este sábado?"),
msg("in", "buttons", "13:02", {
body: "¡Hola! 👋 Con gusto te ayudo con tu reserva. ¿Para cuántas personas sería?",
footer: "La Terraza del Puerto",
botones: [
{ texto: "2 personas", respuestas: [r("Perfecto, mesa para 2. ¿Prefieres comida (14:00) o cena (21:00)?")] },
{ texto: "3 a 5 personas", respuestas: [r("Genial. Para grupos de 3 a 5 tenemos terraza o salón. Te comparto el menú mientras eliges.")] },
{ texto: "Más de 5", respuestas: [r("Para grupos grandes te atiende nuestro equipo de eventos. Te escriben en unos minutos.")] }
]
}),
t("out", "13:03", "Somos 2, para cenar"),
msg("in", "template", "13:03", {
headerTipo: "imagen",
headerMedia: ilus("Tu mesa está lista", "#f6e7df", "#b5523b", "✓"),
body: "Hola Ana, tu mesa para *2 personas* quedó reservada el sábado a las *21:00*. ¿Confirmas la reserva?",
footer: "Tolerancia de 15 minutos",
botones: [
{ texto: "Confirmar", respuestas: [r("¡Listo! Reserva confirmada ✨ Te esperamos el sábado a las 21:00.")] },
{ texto: "Cambiar horario", respuestas: [r("Claro. ¿A qué hora te acomoda mejor? Tenemos 20:00, 21:30 y 22:00.")] }
]
}),
msg("in", "list", "13:04", {
header: "Menú de la casa",
body: "Mientras tanto, ¿quieres ver alguna sección del menú?",
footer: "Precios con impuestos incluidos",
boton: "Ver menú",
secciones: [
{ titulo: "Entradas", filas: [
{ titulo: "Ceviche del día", descripcion: "Pesca local con cítricos", respuestas: [r("El ceviche del día cambia según la pesca. Te lo recomendamos 🐟")] },
{ titulo: "Pan de la casa", descripcion: "Masa madre y mantequilla", respuestas: [r("Nuestro pan se hornea cada tarde 🥖")] }
] },
{ titulo: "Platos fuertes", filas: [
{ titulo: "Pescado a la talla", descripcion: "Para compartir", respuestas: [r("Buena elección para 2 personas. Se prepara en unos 25 minutos.")] },
{ titulo: "Pasta de mariscos", descripcion: "Receta de la casa", respuestas: [r("La pasta de mariscos es de las más pedidas 🍝")] }
] }
]
}),
msg("in", "location", "13:05", {
latitud: "19.4326", longitud: "-99.1332",
nombre: "La Terraza del Puerto", direccion: "Av. del Malecón 120, Centro"
})
]);
}
},
{
id: "ecommerce",
nombre: "E-commerce · pedido",
descripcion: "Confirmación y seguimiento de pedido, catálogo en carrusel y enlace de rastreo.",
datos: function () {
return estado({ nombre: "Tienda Brisa", avatar: avatar("B", "#2f6fb5"), subtitulo: "Cuenta de empresa", verificado: true }, [
msg("in", "template", "09:15", {
headerTipo: "texto",
headerTexto: "Pedido #10482 confirmado",
body: "¡Gracias por tu compra, Carlos! 🎉 Tu pedido ya está en preparación. Te avisaremos por aquí cuando salga a reparto.",
footer: "Tienda Brisa · Atención a clientes",
botones: [
{ texto: "Ver detalle", respuestas: [r("Tu pedido incluye: 1 × Sudadera Brisa (M) y 1 × Gorra clásica. Total: $1,250.")] },
{ texto: "Cambiar dirección", respuestas: [r("Sin problema. Envíame la nueva dirección completa con código postal.")] }
]
}),
t("out", "09:20", "¿Cuándo llega?"),
msg("in", "cta_url", "09:20", {
headerMedia: ilus("En camino", "#e3eef9", "#2f6fb5", "➜"),
body: "Tu pedido salió a reparto 🚚 La entrega estimada es *mañana entre 10:00 y 14:00*.",
footer: "Actualizado hace 5 minutos",
textoBoton: "Rastrear pedido",
url: "https://example.com/rastreo"
}),
msg("in", "carousel", "09:21", {
body: "Mientras llega, esto combina con tu compra:",
cards: [
{ media: ilus("Chamarra ligera", "#e8f0f7", "#2f6fb5", "1"), texto: "Chamarra ligera · impermeable y plegable.", textoBoton: "Me interesa", url: "", respuestas: [r("¡Buena elección! Te aparto una en talla M por 24 h.")] },
{ media: ilus("Mochila urbana", "#eef3ea", "#4f8a3c", "2"), texto: "Mochila urbana · 20 L con compartimento para laptop.", textoBoton: "Me interesa", url: "", respuestas: [r("La mochila tiene 10 % de descuento esta semana.")] },
{ media: ilus("Termo 750 ml", "#f7efe3", "#c07a2c", "3"), texto: "Termo de acero · 750 ml, frío 24 h.", textoBoton: "Me interesa", url: "", respuestas: [r("El termo viene en 4 colores. ¿Cuál prefieres?")] }
]
}),
msg("in", "buttons", "09:22", {
body: "¿Hay algo más en lo que te pueda ayudar?",
botones: [
{ texto: "Devoluciones", respuestas: [r("Tienes 30 días para devoluciones sin costo. Solo responde con tu número de pedido.")] },
{ texto: "Hablar con alguien", respuestas: [r("Te conecto con una persona del equipo. Tiempo de espera aproximado: 3 minutos.")] },
{ texto: "No, gracias", respuestas: [r("¡Gracias a ti! Que disfrutes tu compra 💙")] }
]
})
]);
}
},
{
id: "clinica",
nombre: "Clínica · citas",
descripcion: "Agenda de citas por especialidad, recordatorio con plantilla y documento de indicaciones.",
datos: function () {
return estado({ nombre: "Clínica Bienestar", avatar: avatar("C", "#1f8a7a"), subtitulo: "en línea", verificado: true }, [
t("out", "08:40", "Hola, quiero agendar una cita"),
msg("in", "list", "08:40", {
header: "Agenda tu cita",
body: "¡Hola! Soy el asistente de Clínica Bienestar. ¿Con qué especialidad necesitas tu cita?",
footer: "Horario: lunes a sábado",
boton: "Especialidades",
secciones: [
{ titulo: "Consulta", filas: [
{ titulo: "Medicina general", descripcion: "Consulta de 30 minutos", respuestas: [r("Tengo disponibles mañana a las 10:00 y a las 12:30. ¿Cuál prefieres?")] },
{ titulo: "Pediatría", descripcion: "Niñas y niños de 0 a 15 años", respuestas: [r("Pediatría tiene espacio el jueves a las 9:00 y a las 16:00.")] }
] },
{ titulo: "Estudios", filas: [
{ titulo: "Laboratorio", descripcion: "Análisis clínicos", respuestas: [r("Para laboratorio no necesitas cita: de 7:00 a 11:00, en ayuno.")] }
] }
]
}),
t("out", "08:42", "Mañana a las 10:00"),
msg("in", "template", "08:42", {
headerTipo: "texto",
headerTexto: "Cita confirmada",
body: "Hola Laura, tu cita de *Medicina general* quedó agendada para mañana a las *10:00*. Llega 10 minutos antes con una identificación.",
footer: "Clínica Bienestar",
botones: [
{ texto: "Confirmar asistencia", respuestas: [r("¡Gracias! Tu asistencia quedó confirmada ✅")] },
{ texto: "Reprogramar", respuestas: [r("Claro. ¿Qué día te acomoda mejor?")] },
{ texto: "Cancelar cita", respuestas: [r("Tu cita fue cancelada. Si necesitas otra, escríbenos cuando quieras.")] }
]
}),
msg("in", "document", "08:43", {
caption: "Indicaciones para tu consulta",
filename: "indicaciones-consulta.pdf"
}),
msg("in", "location", "08:43", {
latitud: "20.6597", longitud: "-103.3496",
nombre: "Clínica Bienestar", direccion: "Calle Robles 45, Col. Jardines"
})
]);
}
},
{
id: "inmobiliaria",
nombre: "Inmobiliaria · visitas",
descripcion: "Presentación de propiedades en carrusel, filtros por zona y agenda de visita.",
datos: function () {
return estado({ nombre: "Horizonte Inmuebles", avatar: avatar("H", "#6b4fa3"), subtitulo: "Cuenta de empresa", verificado: false }, [
t("out", "17:10", "Hola, vi un anuncio de departamentos en renta"),
msg("in", "buttons", "17:10", {
headerTipo: "imagen",
headerMedia: ilus("Encuentra tu espacio", "#efe9f7", "#6b4fa3", "⌂"),
body: "¡Hola! Gracias por escribirnos. ¿Qué tipo de propiedad buscas?",
botones: [
{ texto: "Departamento", respuestas: [r("Te muestro los departamentos disponibles esta semana 👇")] },
{ texto: "Casa", respuestas: [r("Por ahora tenemos 2 casas en renta. Un asesor te comparte los detalles.")] },
{ texto: "Oficina", respuestas: [r("Para oficinas te atiende nuestro equipo comercial. ¿Me compartes tu correo?")] }
]
}),
msg("in", "carousel", "17:11", {
body: "Departamentos disponibles:",
cards: [
{ media: ilus("2 recámaras · 78 m²", "#f1ecf8", "#6b4fa3", "A"), texto: "Zona norte · 2 recámaras, 1 baño, estacionamiento.", textoBoton: "Agendar visita", url: "", respuestas: [r("¿Qué día te gustaría visitarlo? Tenemos martes y jueves por la tarde.")] },
{ media: ilus("1 recámara · 52 m²", "#e9f2f1", "#2f8a7e", "B"), texto: "Zona centro · 1 recámara, amueblado, cerca del metro.", textoBoton: "Agendar visita", url: "", respuestas: [r("Este se renta amueblado. ¿Te agendo una visita el sábado a las 11:00?")] },
{ media: ilus("3 recámaras · 110 m²", "#f7efe6", "#b0763a", "C"), texto: "Zona sur · 3 recámaras, 2 baños, jardín común.", textoBoton: "Ver fotos", url: "https://example.com/propiedad", respuestas: [] }
]
}),
t("out", "17:13", "El de 2 recámaras, el jueves"),
msg("in", "template", "17:13", {
body: "Listo, tu visita al departamento de *2 recámaras* quedó agendada el jueves a las *18:00*. Te acompaña Daniel, tu asesor.",
footer: "Horizonte Inmuebles",
botones: [
{ texto: "Confirmar", respuestas: [r("¡Perfecto! Te enviamos la ubicación exacta un día antes.")] },
{ texto: "Cambiar hora", respuestas: [r("¿Qué horario te funciona mejor el jueves?")] }
]
}),
msg("in", "contact", "17:14", {
nombre: "Asesor de ejemplo", telefono: "+52 55 0000 0000", email: "[email protected]", empresa: "Horizonte Inmuebles"
})
]);
}
},
{
id: "soporte",
nombre: "Soporte · preguntas frecuentes",
descripcion: "Menú de ayuda en lista, respuestas encadenadas y paso a una persona del equipo.",
datos: function () {
return estado({ nombre: "Conecta Soporte", avatar: avatar("S", "#0f7c9b"), subtitulo: "Responde en minutos", verificado: true }, [
t("out", "11:00", "Hola, tengo un problema con mi cuenta"),
msg("in", "list", "11:00", {
header: "Centro de ayuda",
body: "Hola 👋 Soy el asistente virtual. Elige el tema y te ayudo al momento.",
footer: "Disponible 24/7",
boton: "Ver temas",
secciones: [
{ titulo: "Cuenta", filas: [
{ titulo: "Olvidé mi contraseña", descripcion: "Restablecer el acceso", respuestas: [
r("Puedes restablecerla desde *Iniciar sesión › ¿Olvidaste tu contraseña?*"),
r("Te llegará un correo con un enlace válido por 30 minutos.")
] },
{ titulo: "Cambiar mi correo", descripcion: "Actualizar datos de contacto", respuestas: [r("Ve a *Perfil › Datos de contacto* y confirma el cambio desde tu correo actual.")] }
] },
{ titulo: "Pagos", filas: [
{ titulo: "Cobro no reconocido", descripcion: "Revisar un cargo", respuestas: [r("Lo revisamos contigo. Te paso con una persona del equipo de pagos.")] },
{ titulo: "Descargar factura", descripcion: "Facturas de los últimos 12 meses", respuestas: [r("Tus facturas están en *Cuenta › Facturación*.")] }
] }
]
}),
msg("in", "buttons", "11:02", {
body: "¿Te sirvió esta respuesta?",
botones: [
{ texto: "Sí, gracias", respuestas: [r("¡Qué bueno! Si necesitas algo más, aquí estamos 🙌")] },
{ texto: "No, necesito ayuda", respuestas: [r("Te conecto con una persona del equipo. Tiempo de espera aproximado: 5 minutos.")] }
]
}),
msg("in", "cta_url", "11:03", {
body: "También puedes consultar las guías paso a paso en nuestro centro de ayuda.",
textoBoton: "Abrir guías",
url: "https://example.com/ayuda"
})
]);
}
},
{
id: "cobranza",
nombre: "Cobranza · recordatorio",
descripcion: "Recordatorio de pago con plantilla, opciones de pago y comprobante en documento.",
datos: function () {
return estado({ nombre: "Servicios Claros", avatar: avatar("S", "#3b7d4f"), subtitulo: "Cuenta de empresa", verificado: true }, [
msg("in", "template", "10:00", {
headerTipo: "texto",
headerTexto: "Recordatorio de pago",
body: "Hola Marta, te recordamos que tu pago de *$850* vence el *15 de octubre*. Evita recargos pagando a tiempo.",
footer: "Si ya pagaste, ignora este mensaje",
botones: [
{ texto: "Pagar ahora", url: "https://example.com/pago", respuestas: [] },
{ texto: "Ya pagué", respuestas: [r("¡Gracias! En cuanto se refleje tu pago te enviamos el comprobante.")] },
{ texto: "Necesito una prórroga", respuestas: [r("Entendido. Te comparto las opciones disponibles 👇")] }
]
}),
t("out", "10:05", "Necesito una prórroga"),
msg("in", "buttons", "10:05", {
body: "Estas son las opciones que tenemos para ti:",
footer: "Sujeto a aprobación",
botones: [
{ texto: "Pagar en 2 partes", respuestas: [r("Listo, dividimos tu saldo en 2 pagos quincenales. Te enviamos el detalle por aquí.")] },
{ texto: "Mover la fecha", respuestas: [r("Podemos mover la fecha de vencimiento hasta 7 días. ¿Te funciona?")] },
{ texto: "Hablar con asesor", respuestas: [r("Un asesor te contactará hoy en horario de oficina.")] }
]
}),
t("out", "10:07", "Pagar en 2 partes"),
msg("in", "document", "10:08", {
caption: "Aquí está el detalle de tus 2 pagos",
filename: "plan-de-pagos.pdf"
})
]);
}
}
];
WAM.ESCENARIOS = ESCENARIOS;
// Devuelve un estado CRUDO (sin normalizar) del escenario id, o null
WAM.datosEscenario = function (id) {
for (var i = 0; i < ESCENARIOS.length; i++) {
if (ESCENARIOS[i].id === id) return ESCENARIOS[i].datos();
}
return null;
};
})();
+560 -114
View File
@@ -1,7 +1,15 @@
/* WAM · interactivity.js — dueño D (núcleo). /* WAM · interactivity.js — dueño D (núcleo).
Flujos de clic (quick replies, filas de lista, botones de plantilla y de card), Flujos de clic (quick replies, filas de lista, botones de plantilla y de card),
composer libre, indicador "escribiendo…", ticks automáticos y modo composer libre, indicador "escribiendo…", ticks automáticos y modo
"reproducir demo" paso a paso. No toca el DOM al cargar. */ "reproducir demo" paso a paso (con autoPulsar y velocidad). No toca el DOM
al cargar.
Guion frente a sesión: WAM.estado.mensajes es el GUION (persistente, lo que
edita el editor y reproduce la demo). Lo que ocurre al pulsar botones o
escribir en el composer es la SESIÓN (WAM.sesion, solo en memoria): se pinta
con anadirMensajeDOM sin tocar el estado ni localStorage. Cualquier
WAM.render() vuelve al guion: vacía la sesión, cierra la hoja de lista y
cancela flujos y demo en curso. */
window.WAM = window.WAM || {}; window.WAM = window.WAM || {};
(function () { (function () {
"use strict"; "use strict";
@@ -11,42 +19,127 @@ window.WAM = window.WAM || {};
"text", "image", "video", "audio", "document", "sticker", "location", "text", "image", "video", "audio", "document", "sticker", "location",
"contact", "template", "buttons", "list", "cta_url", "carousel" "contact", "template", "buttons", "list", "cta_url", "carousel"
]; ];
var CLAVE_VELOCIDAD = "wam-mockup-v1-velocidad";
var VELOCIDADES = [0.5, 1, 2];
/* Todo temporizador pasa por WAM.espera(); cancelarTodo() aborta los flujos function nada() { /* noop */ }
y la reproducción de la demo en curso. */
var cancelaciones = [];
var demoAndando = false;
function quitar(fn) { /* Mensajes de la sesión en curso (efímeros). */
var i = cancelaciones.indexOf(fn); WAM.sesion = [];
if (i !== -1) cancelaciones.splice(i, 1); var contSesion = 0;
function idSesion() { contSesion++; return "s" + contSesion; }
/* ————————————————— temporizadores cancelables por grupo ————————————————— */
/* Dos grupos: "demo" (reproducción) y "flujo" (clics, composer, ticks).
Cancelar un grupo sube su generación: los pasos pendientes de esa
generación se rechazan con CANCELADO y ya no pueden programar más. */
var grupos = {
demo: { gen: 0, cancelar: [] },
flujo: { gen: 0, cancelar: [] }
};
function grupoDe(nombre) { return grupos[nombre] ? nombre : "flujo"; }
var velocidad = 1;
try {
var guardada = parseFloat(window.localStorage.getItem(CLAVE_VELOCIDAD));
if (VELOCIDADES.indexOf(guardada) !== -1) velocidad = guardada;
} catch (e) { /* sin localStorage */ }
WAM.getVelocidad = function () { return velocidad; };
WAM.setVelocidad = function (v) {
var n = parseFloat(v);
if (VELOCIDADES.indexOf(n) === -1) n = 1;
velocidad = n;
try { window.localStorage.setItem(CLAVE_VELOCIDAD, String(n)); } catch (e) { /* idem */ }
return velocidad;
};
function esperaEn(nombre, ms, gen) {
var g = grupos[grupoDe(nombre)];
return new Promise(function (resolver, rechazar) {
if (gen !== undefined && gen !== g.gen) { rechazar(CANCELADO); return; }
var dur = Math.max(0, (Number(ms) || 0) / velocidad);
var id = setTimeout(function () {
quitar();
if (gen !== undefined && gen !== g.gen) rechazar(CANCELADO);
else resolver();
}, dur);
function cancelar() {
clearTimeout(id);
quitar();
rechazar(CANCELADO);
}
function quitar() {
var i = g.cancelar.indexOf(cancelar);
if (i !== -1) g.cancelar.splice(i, 1);
}
g.cancelar.push(cancelar);
});
}
/* Promise cancelable (SPEC): todo temporizador pasa por aquí.
grupo opcional: "flujo" (por defecto) o "demo". La duración se divide
por la velocidad elegida (0,5x / 1x / 2x). */
WAM.espera = function (ms, grupo) { return esperaEn(grupo, ms); };
/* Contexto de una secuencia: fija grupo y generación al empezar. */
function contexto(nombre) {
var g = grupoDe(nombre);
var gen = grupos[g].gen;
return {
grupo: g,
vivo: function () { return grupos[g].gen === gen; },
esperar: function (ms) { return esperaEn(g, ms, gen); }
};
}
var colas = { demo: Promise.resolve(), flujo: Promise.resolve() };
function cancelarGrupo(nombre) {
var g = grupos[grupoDe(nombre)];
g.gen++;
var copia = g.cancelar.slice();
g.cancelar = [];
for (var i = 0; i < copia.length; i++) copia[i]();
colas[grupoDe(nombre)] = Promise.resolve();
} }
function cancelarTodo() { function cancelarTodo() {
var copia = cancelaciones.slice(); cancelarGrupo("demo");
cancelaciones = []; cancelarGrupo("flujo");
for (var i = 0; i < copia.length; i++) copia[i](); escritores.clear();
pintarEscribiendo();
} }
function escribiendo(visible) { /* ————————————————— indicador "escribiendo…" (contador de referencias) ————————————————— */
if (typeof WAM.setEscribiendo === "function") WAM.setEscribiendo(visible);
var escritores = new Set();
function pintarEscribiendo() {
if (typeof WAM.setEscribiendo === "function") WAM.setEscribiendo(escritores.size > 0);
} }
/* Promise cancelable: se rechaza con CANCELADO si detenerDemo() la aborta. */ function empezarEscribir() {
WAM.espera = function (ms) { var ficha = {};
return new Promise(function (resolver, rechazar) { escritores.add(ficha);
var id = setTimeout(function () { pintarEscribiendo();
quitar(cancelar); return ficha;
resolver(); }
}, ms);
function cancelar() { function dejarEscribir(ficha) {
clearTimeout(id); if (escritores.delete(ficha)) pintarEscribiendo();
quitar(cancelar); }
rechazar(CANCELADO);
} async function escribirDurante(ctx, ms) {
cancelaciones.push(cancelar); var ficha = empezarEscribir();
}); try { await ctx.esperar(ms); } finally { dejarEscribir(ficha); }
}; }
/* ————————————————— estado de la demo ————————————————— */
var demoAndando = false;
WAM.demoActiva = function () { return demoAndando; }; WAM.demoActiva = function () { return demoAndando; };
@@ -56,23 +149,83 @@ window.WAM = window.WAM || {};
} }
} }
/* ————————————————— alta de mensajes (mutación + guardar + DOM incremental) ————————————————— */ /* ————————————————— búsqueda de mensajes y filas ————————————————— */
function anadir(msg) { function cuerpoChat() { return document.getElementById("chat-cuerpo"); }
WAM.estado.mensajes.push(msg);
WAM.guardar(); function selectorId(id) {
if (typeof WAM.anadirMensajeDOM === "function") WAM.anadirMensajeDOM(msg); return '.wa-row[data-id="' + String(id).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"]';
} }
function mensajeOut(texto) { function filaDe(msg) {
return { var cuerpo = cuerpoChat();
id: WAM.generarId(), if (!cuerpo || !msg || typeof msg.id !== "string" || !msg.id) return null;
var filas = cuerpo.querySelectorAll(selectorId(msg.id));
return filas.length ? filas[filas.length - 1] : null;
}
function buscarPorId(id) {
if (!id) return null;
var guion = (WAM.estado && WAM.estado.mensajes) || [];
for (var i = 0; i < guion.length; i++) if (guion[i] && guion[i].id === id) return guion[i];
for (var j = WAM.sesion.length - 1; j >= 0; j--) if (WAM.sesion[j] && WAM.sesion[j].id === id) return WAM.sesion[j];
return null;
}
/* Mensaje de la hoja de lista abierta (guion o sesión). */
var hojaMsg = null;
/* Mensaje al que pertenece un elemento clicable del chat o de la hoja.
Se resuelve por el data-id de su fila (inmune a índices obsoletos y
válido para mensajes de sesión); el índice data-flujo queda de respaldo. */
WAM.mensajeDeElemento = function (el) {
if (!el || !el.closest) return null;
var hoja = el.closest("#sheet");
if (hoja) return hojaMsg;
var fila = el.closest(".wa-row[data-id]");
var msg = fila ? buscarPorId(fila.getAttribute("data-id")) : null;
if (msg) return msg;
var indice = parseInt(el.getAttribute("data-flujo"), 10);
var guion = (WAM.estado && WAM.estado.mensajes) || [];
return isNaN(indice) ? null : (guion[indice] || null);
};
/* Abre la hoja de lista de un mensaje (del guion o de la sesión). */
WAM.abrirLista = function (msg) {
if (!msg || typeof WAM.abrirSheet !== "function") return;
hojaMsg = msg;
var guion = (WAM.estado && WAM.estado.mensajes) || [];
var i = guion.indexOf(msg);
WAM.abrirSheet(i >= 0 ? i : msg);
};
/* ————————————————— pintado incremental ————————————————— */
/* grupo / cola (.grupo / .ultimo) los recalcula anadirMensajeDOM según el DOM. */
function pintar(msg) {
if (typeof WAM.anadirMensajeDOM !== "function") return null;
WAM.anadirMensajeDOM(msg);
var fila = filaDe(msg);
if (fila && msg.cita) pintarCita(fila, msg.cita);
return fila;
}
function anadirSesion(msg) {
WAM.sesion.push(msg);
return pintar(msg);
}
function mensajeOut(texto, cita) {
var msg = {
id: idSesion(),
dir: "out", dir: "out",
tipo: "text", tipo: "text",
hora: WAM.horaAhora(), hora: WAM.horaAhora(),
estado: "sent", estado: "sent",
texto: String(texto == null ? "" : texto) texto: String(texto == null ? "" : texto)
}; };
if (cita) msg.cita = cita;
return msg;
} }
/* Copia una respuesta anidada (Mensaje completo) para poder reutilizarla /* Copia una respuesta anidada (Mensaje completo) para poder reutilizarla
@@ -88,68 +241,188 @@ window.WAM = window.WAM || {};
} }
if (!copia || typeof copia !== "object" || Array.isArray(copia)) 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"; if (TIPOS_VALIDOS.indexOf(copia.tipo) === -1) copia.tipo = "text";
copia.id = WAM.generarId(); copia.id = idSesion();
copia.dir = "in"; copia.dir = "in";
copia.hora = WAM.horaAhora(); copia.hora = WAM.horaAhora();
copia.estado = "sent"; copia.estado = "sent";
delete copia.cita;
return copia; return copia;
} }
/* Ticks automáticos sent → delivered → read sobre la burbuja OUT indicada. /* ————————————————— cita de la respuesta (quoted reply) ————————————————— */
Se localiza por data-id del msg (no "la última fila") para que dos envíos
solapados avancen cada uno su propio tick. */ function vacio(v) { return v === undefined || v === null || String(v).trim() === ""; }
function sinFormato(s) {
return String(s == null ? "" : s)
.replace(/```([\s\S]*?)```/g, "$1")
.replace(/`([^`\n]+)`/g, "$1")
.replace(/\*([^*\n]+)\*/g, "$1")
.replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/g, "$1$2")
.replace(/(^|[^\w])~([^~\n]+)~(?=[^\w]|$)/g, "$1$2")
.trim();
}
function miniaturaSegura(u) {
if (vacio(u)) return "";
var s = String(u);
if (/^data:video\//i.test(s)) return "";
if (typeof WAM.urlSegura === "function" && !WAM.urlSegura(s, "media")) return "";
return s;
}
/* { nombre, titulo, extracto, miniatura, foto } del mensaje original. */
function citaDe(msg, opciones) {
if (!msg) return null;
var cuenta = (WAM.estado && WAM.estado.cuenta) || {};
var cita = {
nombre: vacio(cuenta.nombre) ? "Chat" : String(cuenta.nombre),
titulo: "",
extracto: "",
miniatura: "",
foto: false
};
var op = opciones || {};
var partes = [];
if (msg.tipo === "list") {
cita.titulo = sinFormato(msg.header);
partes = [msg.body, msg.footer];
} else if (msg.tipo === "carousel") {
var card = (msg.cards || [])[op.card] || {};
partes = [vacio(card.texto) ? msg.body : card.texto];
cita.miniatura = miniaturaSegura(card.media);
if (!cita.miniatura && vacio(card.media) && typeof WAM.svgPlaceholder === "function") {
cita.miniatura = WAM.svgPlaceholder("");
}
cita.foto = true;
} else {
if (msg.headerTipo === "texto") cita.titulo = sinFormato(msg.headerTexto);
partes = [msg.body];
if (msg.headerTipo === "imagen") {
cita.miniatura = vacio(msg.headerMedia)
? (typeof WAM.svgPlaceholder === "function" ? WAM.svgPlaceholder("") : "")
: miniaturaSegura(msg.headerMedia);
cita.foto = true;
} else if (msg.headerTipo === "video") {
cita.foto = true;
}
}
cita.extracto = partes.filter(function (p) { return !vacio(p); }).map(sinFormato).join("\n");
if (!cita.extracto && !cita.titulo) cita.extracto = cita.foto ? "Foto" : "Mensaje";
return cita;
}
var SVG_CAMARA = '<svg class="wa-cita-cam" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">' +
'<path fill="currentColor" d="M5.6 2.5h4.8l1 1.5H13a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 13 12.5H3A1.5 1.5 0 0 1 1.5 11V5.5A1.5 1.5 0 0 1 3 4h1.6l1-1.5Zm2.4 3a2.75 2.75 0 1 0 0 5.5 2.75 2.75 0 0 0 0-5.5Z"/></svg>';
/* Bloque de cita dentro de la burbuja OUT; todo por textContent (sin XSS). */
function pintarCita(fila, cita) {
var burbuja = fila.querySelector(".bubble");
if (!burbuja || burbuja.querySelector(".wa-cita")) return;
var caja = document.createElement("div");
caja.className = "wa-cita";
var txt = document.createElement("div");
txt.className = "wa-cita-txt";
var nombre = document.createElement("div");
nombre.className = "wa-cita-nombre";
nombre.textContent = cita.nombre;
txt.appendChild(nombre);
if (cita.titulo) {
var tit = document.createElement("div");
tit.className = "wa-cita-titulo";
tit.textContent = cita.titulo;
txt.appendChild(tit);
}
if (cita.extracto) {
var ext = document.createElement("div");
ext.className = "wa-cita-extracto";
if (cita.foto) {
var cam = document.createElement("span");
cam.innerHTML = SVG_CAMARA; /* constante propia, sin datos del usuario */
if (cam.firstChild) ext.appendChild(cam.firstChild);
}
ext.appendChild(document.createTextNode(cita.extracto));
txt.appendChild(ext);
}
caja.appendChild(txt);
if (cita.miniatura) {
var img = document.createElement("img");
img.className = "wa-cita-mini";
img.alt = "";
img.draggable = false;
img.src = cita.miniatura;
caja.appendChild(img);
}
burbuja.insertBefore(caja, burbuja.firstChild);
burbuja.classList.add("wa-con-cita");
}
/* ————————————————— ticks automáticos ————————————————— */
/* Ticks sent → delivered → read sobre la burbuja OUT indicada (por data-id,
para que dos envíos solapados avancen cada uno su propio tick). */
function repintarTickDe(msg, estado) { function repintarTickDe(msg, estado) {
var cuerpo = document.getElementById("chat-cuerpo"); if (typeof WAM.tick !== "function") return;
if (!cuerpo || typeof WAM.tick !== "function") return; var fila = filaDe(msg);
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; if (!fila || !fila.classList.contains("out")) return;
var tick = fila.querySelector(".tick"); var tick = fila.querySelector(".tick");
if (tick && tick.parentNode) { if (tick && tick.parentNode) {
/* WAM.tick devuelve un <svg> completo: se REEMPLAZA el nodo para no /* WAM.tick devuelve un <svg> completo: se REEMPLAZA el nodo. */
anidar un svg dentro de otro (se recortaría el segundo tick). */
var aux = document.createElement("span"); var aux = document.createElement("span");
aux.innerHTML = WAM.tick(estado); aux.innerHTML = WAM.tick(estado);
if (aux.firstChild) tick.parentNode.replaceChild(aux.firstChild, tick); if (aux.firstChild) tick.parentNode.replaceChild(aux.firstChild, tick);
} }
} }
async function animarTics(msg) { /* Los mensajes de sesión no se guardan; si se cancelan los ticks, la
await WAM.espera(800); burbuja salta a "read" para no quedar congelada en ✓ / ✓✓ gris. */
msg.estado = "delivered"; async function animarTics(msg, ctx) {
WAM.guardar(); try {
repintarTickDe(msg, "delivered"); await ctx.esperar(800);
await WAM.espera(800); msg.estado = "delivered";
msg.estado = "read"; repintarTickDe(msg, "delivered");
WAM.guardar(); await ctx.esperar(800);
repintarTickDe(msg, "read"); msg.estado = "read";
repintarTickDe(msg, "read");
} catch (e) {
if (msg.estado !== "read") {
msg.estado = "read";
repintarTickDe(msg, "read");
}
throw e;
}
} }
/* ————————————————— flujos ————————————————— */ /* ————————————————— flujos ————————————————— */
/* Burbuja OUT con el texto (ticks auto), typing ~900 ms y, por cada respuesta, /* Respuestas encadenadas: typing ~900 ms + burbuja IN por cada una.
typing ~900 ms + anadirMensajeDOM (con scroll). Cancelable por detenerDemo(). */ En la demo (enDemo) cada respuesta con autoPulsar se pulsa sola. */
WAM.flujo = function (texto, respuestas) { async function responder(lista, ctx, enDemo) {
for (var i = 0; i < lista.length; i++) {
await escribirDurante(ctx, 900);
if (!ctx.vivo()) throw CANCELADO;
var r = mensajeDesde(lista[i]);
anadirSesion(r);
if (enDemo) await autoPulsar(r, ctx);
}
}
/* Burbuja OUT con el texto (y cita opcional {nombre, extracto, miniatura}),
ticks auto y respuestas. La burbuja sale al instante; las respuestas de
flujos simultáneos se encolan para no intercalarse. Cancelable. */
WAM.flujo = function (texto, respuestas, cita) {
var lista = Array.isArray(respuestas) ? respuestas : []; var lista = Array.isArray(respuestas) ? respuestas : [];
var secuencia = (async function () { var ctx = contexto("flujo");
try { var out = mensajeOut(texto, cita || null);
var out = mensajeOut(texto); anadirSesion(out);
anadir(out); var tics = animarTics(out, ctx);
await animarTics(out); tics.catch(nada);
for (var i = 0; i < lista.length; i++) { var turno = colas.flujo.then(function () {
escribiendo(true); if (!ctx.vivo()) throw CANCELADO;
await WAM.espera(900); return tics.then(function () { return responder(lista, ctx, false); });
escribiendo(false); });
anadir(mensajeDesde(lista[i])); colas.flujo = turno.catch(nada);
} return turno.catch(nada);
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. */ /* Envío libre del composer: burbuja OUT con hora actual y ticks automáticos. */
@@ -157,69 +430,213 @@ window.WAM = window.WAM || {};
var limpio = String(texto == null ? "" : texto).trim(); var limpio = String(texto == null ? "" : texto).trim();
if (!limpio) return; if (!limpio) return;
var out = mensajeOut(limpio); var out = mensajeOut(limpio);
anadir(out); anadirSesion(out);
var tics = animarTics(out); animarTics(out, contexto("flujo")).catch(nada);
if (typeof tics.catch === "function") tics.catch(function () { /* cancelado */ });
}; };
/* Resuelve { texto, respuestas } desde los data-* que estampa render.js: /* Resuelve { texto, respuestas, cita, msg, tipo } desde los data-* que
data-flujo (índice del mensaje) + data-btn (template/buttons), estampa render.js: data-btn (template/buttons), data-sec + data-fila
data-sec + data-fila (lista) o data-card (carousel). */ (lista) o data-card (carousel). Etiquetas vacías = las del render. */
WAM.resolverFlujo = function (el) { WAM.resolverFlujo = function (el) {
var vacio = { texto: "", respuestas: [] }; var vacioR = { texto: "", respuestas: [], cita: null, msg: null, tipo: "" };
if (!el || typeof el.getAttribute !== "function") return vacio; if (!el || typeof el.getAttribute !== "function") return vacioR;
var indice = parseInt(el.getAttribute("data-flujo"), 10); var msg = WAM.mensajeDeElemento(el);
var mensajes = (WAM.estado && WAM.estado.mensajes) ? WAM.estado.mensajes : []; if (!msg) return vacioR;
var msg = isNaN(indice) ? null : mensajes[indice];
if (!msg) return vacio;
var ds = el.dataset || {}; var ds = el.dataset || {};
if (ds.sec !== undefined || ds.fila !== undefined) { if (ds.sec !== undefined || ds.fila !== undefined) {
var seccion = (msg.secciones || [])[parseInt(ds.sec, 10)]; var seccion = (msg.secciones || [])[parseInt(ds.sec, 10)];
var fila = ((seccion && seccion.filas) || [])[parseInt(ds.fila, 10)]; var fila = ((seccion && seccion.filas) || [])[parseInt(ds.fila, 10)];
return fila ? { texto: fila.titulo || "", respuestas: fila.respuestas || [] } : vacio; if (!fila) return vacioR;
var titulo = vacio(fila.titulo) ? "Opción" : String(fila.titulo);
return {
texto: titulo + (vacio(fila.descripcion) ? "" : "\n" + fila.descripcion),
respuestas: fila.respuestas || [],
cita: citaDe(msg),
msg: msg,
tipo: "fila"
};
} }
if (ds.btn !== undefined) { if (ds.btn !== undefined) {
var boton = (msg.botones || [])[parseInt(ds.btn, 10)]; var n = parseInt(ds.btn, 10);
return boton ? { texto: boton.texto || "", respuestas: boton.respuestas || [] } : vacio; var boton = (msg.botones || [])[n];
if (!boton) return vacioR;
return {
texto: vacio(boton.texto) ? "Botón " + (n + 1) : String(boton.texto),
respuestas: boton.respuestas || [],
cita: citaDe(msg),
msg: msg,
tipo: "boton"
};
} }
if (ds.card !== undefined) { if (ds.card !== undefined) {
var card = (msg.cards || [])[parseInt(ds.card, 10)]; var c = parseInt(ds.card, 10);
return card ? { texto: card.textoBoton || "", respuestas: card.respuestas || [] } : vacio; var card = (msg.cards || [])[c];
if (!card) return vacioR;
return {
texto: vacio(card.textoBoton) ? "Ver más" : String(card.textoBoton),
respuestas: card.respuestas || [],
cita: citaDe(msg, { card: c }),
msg: msg,
tipo: "card"
};
} }
/* Elemento plano de un mensaje list: es el botón que abre la sheet /* Elemento plano de un mensaje list: es el botón que abre la hoja. */
(main.js lo detecta y llama WAM.abrirSheet); sin flujo directo. */ return vacioR;
return vacio; };
/* Deja el botón pulsado en gris y sin respuesta a más clics (como iOS). */
WAM.marcarUsado = function (el) {
if (!el || !el.classList) return;
el.classList.add("usado");
el.setAttribute("aria-disabled", "true");
el.setAttribute("tabindex", "-1");
};
/* Guarda la sesión actual como parte del guion (acción explícita). */
WAM.sesionAGuion = function () {
if (!WAM.sesion.length || !WAM.estado) return 0;
var tomar = typeof WAM.crearAlocador === "function" ? WAM.crearAlocador() : WAM.generarId;
var nuevos = WAM.sesion.map(function (m) {
var copia = JSON.parse(JSON.stringify(m));
delete copia.cita;
(function reasignar(o) {
if (!o || typeof o !== "object") return;
if (Array.isArray(o)) { o.forEach(reasignar); return; }
if (typeof o.id === "string") o.id = tomar();
["respuestas", "botones", "secciones", "filas", "cards"].forEach(function (k) { reasignar(o[k]); });
})(copia);
return copia;
});
var n = nuevos.length;
WAM.estado = WAM.normalizar ? WAM.normalizar({
version: WAM.estado.version, tema: WAM.estado.tema, cuenta: WAM.estado.cuenta,
mensajes: WAM.estado.mensajes.concat(nuevos)
}) : WAM.estado;
if (typeof WAM.guardar === "function") WAM.guardar();
if (typeof WAM.render === "function") WAM.render();
if (typeof WAM.renderEditor === "function") WAM.renderEditor();
return n;
}; };
/* ————————————————— reproducción de la demo ————————————————— */ /* ————————————————— reproducción de la demo ————————————————— */
function pulsacion(el, ctx) {
el.classList.add("wam-pulsando");
return ctx.esperar(260).then(function () { el.classList.remove("wam-pulsando"); },
function (e) { el.classList.remove("wam-pulsando"); throw e; });
}
function centrarCard(el) {
var car = el.closest(".carousel");
var card = el.closest(".card");
if (!car || !card) return;
var delta = card.getBoundingClientRect().left - car.getBoundingClientRect().left;
try { car.scrollTo({ left: car.scrollLeft + delta - 8, behavior: "smooth" }); }
catch (e) { car.scrollLeft += delta - 8; }
}
/* Pulsa sola la opción msg.autoPulsar (índice de botón, card o fila
aplanada de la lista) 1 s después de que aparezca el mensaje. */
async function autoPulsar(msg, ctx) {
if (!msg || ["template", "buttons", "list", "carousel"].indexOf(msg.tipo) === -1) return;
var idx = msg.autoPulsar;
if (idx === null || idx === undefined || idx === "") return;
idx = Number(idx);
if (!isFinite(idx) || idx < 0) return;
var fila = filaDe(msg);
if (!fila) return;
await ctx.esperar(1000);
var el = null;
if (msg.tipo === "list") {
var s, f, cuenta = 0, destino = null;
var secs = msg.secciones || [];
for (s = 0; s < secs.length && !destino; s++) {
var filas = (secs[s] && secs[s].filas) || [];
for (f = 0; f < filas.length; f++) {
if (cuenta === idx) { destino = [s, f]; break; }
cuenta++;
}
}
if (!destino) return;
var abridor = fila.querySelector("[data-flujo]:not([data-btn]):not([data-card])");
if (abridor) await pulsacion(abridor, ctx);
WAM.abrirLista(msg);
await ctx.esperar(900);
var hoja = document.getElementById("sheet");
el = hoja ? hoja.querySelector('[data-sec="' + destino[0] + '"][data-fila="' + destino[1] + '"]') : null;
if (!el) { if (WAM.cerrarSheet) WAM.cerrarSheet(); return; }
await pulsacion(el, ctx);
var selL = WAM.resolverFlujo(el);
if (WAM.cerrarSheet) WAM.cerrarSheet();
await flujoEnDemo(selL, ctx);
return;
}
el = fila.querySelector(msg.tipo === "carousel" ? '[data-card="' + idx + '"]' : '[data-btn="' + idx + '"]');
if (!el) return;
if (el.hidden) {
var plegado = fila.querySelector("[data-ver-todas]");
if (plegado) { plegado.click(); await ctx.esperar(400); }
/* B abre la hoja "Todas las opciones" (render.js): se pulsa su fila */
var enHoja = document.querySelector('#sheet [data-opcion="' + idx + '"]');
if (!enHoja) { if (WAM.cerrarSheet) WAM.cerrarSheet(); return; }
await pulsacion(enHoja, ctx);
if (WAM.cerrarSheet) WAM.cerrarSheet();
}
if (msg.tipo === "carousel") { centrarCard(el); await ctx.esperar(500); }
await pulsacion(el, ctx);
var sel = WAM.resolverFlujo(el);
WAM.marcarUsado(el);
await flujoEnDemo(sel, ctx);
}
async function flujoEnDemo(sel, ctx) {
if (!sel || !sel.msg) return;
var out = mensajeOut(sel.texto, sel.cita);
anadirSesion(out);
await animarTics(out, ctx);
await responder(Array.isArray(sel.respuestas) ? sel.respuestas : [], ctx, true);
}
function vaciarChat() {
var cuerpo = cuerpoChat();
if (!cuerpo) return;
var filas = cuerpo.querySelectorAll(".wa-row");
for (var i = 0; i < filas.length; i++) {
if (filas[i].id !== "chat-escribiendo") cuerpo.removeChild(filas[i]);
}
}
WAM.reproducirDemo = function () { WAM.reproducirDemo = function () {
if (demoAndando) return; if (demoAndando) return;
/* aborta flujos, ticks y typing en curso: no se mezclan con la demo */
cancelarTodo();
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet();
hojaMsg = null;
WAM.sesion.length = 0;
demoAndando = true; demoAndando = true;
avisarDemo(); avisarDemo();
var cuerpo = document.getElementById("chat-cuerpo"); vaciarChat();
if (cuerpo) cuerpo.innerHTML = ""; var ctx = contexto("demo");
var secuencia = ((WAM.estado && WAM.estado.mensajes) || []).slice(); var secuencia = ((WAM.estado && WAM.estado.mensajes) || []).slice();
(async function () { (async function () {
try { try {
for (var i = 0; i < secuencia.length; i++) { for (var i = 0; i < secuencia.length; i++) {
var msg = secuencia[i]; var msg = secuencia[i];
if (!msg) continue; if (!msg) continue;
if (msg.dir === "in") { if (msg.dir === "in") await escribirDurante(ctx, 700);
escribiendo(true); if (!ctx.vivo()) throw CANCELADO;
await WAM.espera(700); pintar(msg);
escribiendo(false); await ctx.esperar(600);
} await autoPulsar(msg, ctx);
if (typeof WAM.anadirMensajeDOM === "function") WAM.anadirMensajeDOM(msg);
await WAM.espera(600);
} }
demoAndando = false;
escribiendo(false);
avisarDemo();
} catch (e) { } catch (e) {
/* cancelado: detenerDemo() / render() ya dejaron el chat coherente */
}
if (ctx.vivo()) {
demoAndando = false; demoAndando = false;
escribiendo(false);
avisarDemo(); avisarDemo();
} }
})(); })();
@@ -228,9 +645,38 @@ window.WAM = window.WAM || {};
WAM.detenerDemo = function () { WAM.detenerDemo = function () {
var estaba = demoAndando; var estaba = demoAndando;
demoAndando = false; demoAndando = false;
cancelarTodo(); /* aborta también flujos y typing en curso */ cancelarGrupo("demo"); /* solo el grupo de la demo */
escribiendo(false); if (estaba) {
if (estaba && typeof WAM.render === "function") WAM.render(); /* vuelve al guion completo (y descarta la sesión: ver reiniciarInteraccion) */
if (estaba) avisarDemo(); if (typeof WAM.render === "function") WAM.render();
avisarDemo();
}
}; };
/* Vuelve al guion: cancela todo, vacía la sesión y cierra la hoja. */
function reiniciarInteraccion() {
var estaba = demoAndando;
demoAndando = false;
cancelarTodo();
WAM.sesion.length = 0;
hojaMsg = null;
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet();
if (estaba) avisarDemo();
}
WAM.reiniciarChat = function () {
if (typeof WAM.render === "function") WAM.render();
};
/* WAM.render() repinta el guion entero: antes se descarta la sesión y se
cancelan demo y flujos para que nada añada filas sobre el repintado
(evita data-id duplicados y la hoja con índices obsoletos). */
if (typeof WAM.render === "function" && !WAM.render.__wamEnvuelto) {
var renderBase = WAM.render;
WAM.render = function () {
reiniciarInteraccion();
return renderBase.apply(this, arguments);
};
WAM.render.__wamEnvuelto = true;
}
})(); })();
+175 -26
View File
@@ -1,7 +1,8 @@
/* WAM · main.js — dueño D (núcleo). /* WAM · main.js — dueño D (núcleo).
Bootstrap: carga el estado, aplica tema, renderiza chat y editor, Bootstrap: carga el estado, aplica tema, renderiza chat y editor,
y enlaza la delegación de clics del chat, el composer, la reproducción 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. */ de la demo (velocidad, reiniciar chat) y la toolbar. Único script con
ejecución al cargar. */
window.WAM = window.WAM || {}; window.WAM = window.WAM || {};
(function () { (function () {
"use strict"; "use strict";
@@ -15,23 +16,97 @@ window.WAM = window.WAM || {};
if (el) el.addEventListener(evento, manejador); if (el) el.addEventListener(evento, manejador);
} }
/* ————————————————— aviso visible (toast) ————————————————— */
/* Aviso flotante accesible. Estilos por CSSOM (compatibles con una CSP
style-src 'self'); no depende de ningún CSS ajeno. */
var avisoTimer = null;
WAM.avisar = function (texto, opciones) {
var op = opciones || {};
var caja = porId("wam-aviso");
if (!caja) {
caja = document.createElement("div");
caja.id = "wam-aviso";
caja.setAttribute("role", "alert");
caja.setAttribute("aria-live", "assertive");
var s = caja.style;
s.position = "fixed";
s.left = "50%";
s.bottom = "20px";
s.transform = "translateX(-50%)";
s.zIndex = "9999";
s.maxWidth = "min(560px, calc(100vw - 32px))";
s.padding = "12px 40px 12px 16px";
s.borderRadius = "12px";
s.font = "500 14px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif";
s.boxShadow = "0 8px 28px rgba(0,0,0,.28)";
var cerrar = document.createElement("button");
cerrar.type = "button";
cerrar.setAttribute("aria-label", "Cerrar aviso");
cerrar.textContent = "×";
var c = cerrar.style;
c.position = "absolute";
c.top = "6px";
c.right = "8px";
c.border = "0";
c.background = "transparent";
c.color = "inherit";
c.font = "600 20px/1 Arial, sans-serif";
c.cursor = "pointer";
cerrar.addEventListener("click", function () { caja.hidden = true; });
var txt = document.createElement("span");
txt.className = "wam-aviso-texto";
caja.appendChild(txt);
caja.appendChild(cerrar);
document.body.appendChild(caja);
}
var error = op.tipo !== "info";
caja.style.background = error ? "#b3261e" : "#1f2c33";
caja.style.color = "#fff";
caja.querySelector(".wam-aviso-texto").textContent = String(texto == null ? "" : texto);
caja.hidden = false;
if (avisoTimer) clearTimeout(avisoTimer);
avisoTimer = null;
if (op.duracion) {
avisoTimer = setTimeout(function () { caja.hidden = true; }, op.duracion);
}
};
/* ————————————————— botón "Reproducir demo" ————————————————— */ /* ————————————————— botón "Reproducir demo" ————————————————— */
function pintarBotonDemo() { function pintarBotonDemo() {
var btn = porId("btn-reproducir"); var btn = porId("btn-reproducir");
if (!btn) return; if (!btn) return;
var activa = WAM.demoActiva();
btn.setAttribute("aria-pressed", activa ? "true" : "false");
var span = btn.querySelector("span"); var span = btn.querySelector("span");
if (!span) return; if (!span) return;
if (!span.dataset.etiqueta) span.dataset.etiqueta = span.textContent; if (!span.dataset.etiqueta) span.dataset.etiqueta = span.textContent;
span.textContent = WAM.demoActiva() ? "■ Detener" : span.dataset.etiqueta; span.textContent = activa ? "■ Detener" : span.dataset.etiqueta;
} }
/* Evita que un doble clic arranque y detenga la demo en el mismo gesto. */
var ultimoReproducir = 0;
function alReproducir() { function alReproducir() {
var ahora = Date.now();
if (ahora - ultimoReproducir < 350) return;
ultimoReproducir = ahora;
if (WAM.demoActiva()) WAM.detenerDemo(); if (WAM.demoActiva()) WAM.detenerDemo();
else WAM.reproducirDemo(); else WAM.reproducirDemo();
pintarBotonDemo(); pintarBotonDemo();
} }
function alReiniciarChat() {
if (typeof WAM.reiniciarChat === "function") WAM.reiniciarChat();
else WAM.render();
pintarBotonDemo();
}
function alVelocidad(e) {
var v = WAM.setVelocidad(e.currentTarget.value);
e.currentTarget.value = String(v);
}
/* ————————————————— toolbar ————————————————— */ /* ————————————————— toolbar ————————————————— */
function alTema() { function alTema() {
@@ -50,19 +125,25 @@ window.WAM = window.WAM || {};
} }
function reRenderTotal() { function reRenderTotal() {
WAM.setTema(WAM.estado.tema); paso(function () { WAM.setTema(WAM.estado.tema); });
WAM.render(); paso(function () { WAM.render(); });
WAM.renderEditor(); paso(function () { WAM.renderEditor(); });
pintarBotonDemo(); pintarBotonDemo();
} }
function alExportar() { function alExportar() {
if (typeof WAM.guardarYa === "function") WAM.guardarYa();
if (typeof WAM.exportarJSON === "function") WAM.exportarJSON(); if (typeof WAM.exportarJSON === "function") WAM.exportarJSON();
} }
function alImportar() { function alImportar() {
var archivo = porId("importar-archivo"); var abrir = function () {
if (archivo) archivo.click(); var archivo = porId("importar-archivo");
if (archivo) archivo.click();
};
/* Hook opcional de js/ux.js: <dialog> con «Exportar antes» */
if (typeof WAM.confirmarReemplazo === "function") { WAM.confirmarReemplazo("importar", abrir); return; }
abrir();
} }
function alArchivoImport(e) { function alArchivoImport(e) {
@@ -71,15 +152,29 @@ window.WAM = window.WAM || {};
input.value = ""; /* permite reimportar el mismo archivo */ input.value = ""; /* permite reimportar el mismo archivo */
if (!archivo || typeof WAM.importarJSON !== "function") return; if (!archivo || typeof WAM.importarJSON !== "function") return;
WAM.detenerDemo(); WAM.detenerDemo();
var promesa = WAM.importarJSON(archivo); var promesa;
try { promesa = WAM.importarJSON(archivo); } catch (err) { promesa = Promise.reject(err); }
if (promesa && typeof promesa.then === "function") { if (promesa && typeof promesa.then === "function") {
promesa.then(reRenderTotal, reRenderTotal); promesa.then(reRenderTotal, function () {
reRenderTotal();
WAM.avisar("No se pudo importar: el archivo no es un JSON válido de la demo.", { duracion: 6000 });
});
} else { } else {
reRenderTotal(); reRenderTotal();
} }
} }
function alReset() { function alReset() {
var ejecutar = function () {
WAM.detenerDemo();
WAM.resetear();
reRenderTotal();
};
/* Hook opcional de js/ux.js: <dialog> con «Exportar antes» + deshacer */
if (typeof WAM.confirmarReemplazo === "function") { WAM.confirmarReemplazo("reset", ejecutar); return; }
var nombre = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "la demo";
if (!window.confirm("¿Restablecer la demo «Café Aurora»? Se perderán los cambios no exportados de «" +
nombre + "».")) return;
WAM.detenerDemo(); WAM.detenerDemo();
WAM.resetear(); WAM.resetear();
reRenderTotal(); reRenderTotal();
@@ -87,39 +182,76 @@ window.WAM = window.WAM || {};
/* ————————————————— chat: delegación de clics (contrato B→D) ————————————————— */ /* ————————————————— chat: delegación de clics (contrato B→D) ————————————————— */
/* Enlaces del chat con esquema no permitido (javascript:, data:…) no se abren. */
function bloquearEnlaceInseguro(e) {
var a = (e.target && e.target.closest) ? e.target.closest("a[href]") : null;
if (!a) return false;
var href = a.getAttribute("href");
if (typeof WAM.urlSegura === "function" && !WAM.urlSegura(href, "enlace")) {
e.preventDefault();
return true;
}
return false;
}
function alClicChat(e) { function alClicChat(e) {
if (bloquearEnlaceInseguro(e)) return;
var objetivo = e.target; var objetivo = e.target;
var el = (objetivo && objetivo.closest) ? objetivo.closest("[data-flujo]") : null; var el = (objetivo && objetivo.closest) ? objetivo.closest("[data-flujo]") : null;
if (!el) return; if (!el) return;
e.preventDefault(); /* protege frente a botones montados sobre <a href="#"> */ e.preventDefault(); /* protege frente a botones montados sobre <a href="#"> */
var ds = el.dataset || {}; var ds = el.dataset || {};
if (ds.fila !== undefined) return; /* filas del sheet: se atienden en #pantalla */ if (ds.fila !== undefined) return; /* filas del sheet: se atienden en #pantalla */
if (el.classList.contains("usado") || el.getAttribute("aria-disabled") === "true") return;
var indice = parseInt(el.getAttribute("data-flujo"), 10); var msg = WAM.mensajeDeElemento(el);
var msg = (!isNaN(indice) && WAM.estado && WAM.estado.mensajes) ? WAM.estado.mensajes[indice] : null; var plano = ds.btn === undefined && ds.sec === undefined && ds.card === undefined;
var plano = ds.btn === undefined && ds.sec === undefined &&
ds.fila === undefined && ds.card === undefined;
if (plano && msg && msg.tipo === "list") { if (plano) {
if (typeof WAM.abrirSheet === "function") WAM.abrirSheet(indice); if (msg && msg.tipo === "list") WAM.abrirLista(msg);
return; return;
} }
var flujo = WAM.resolverFlujo(el); var flujo = WAM.resolverFlujo(el);
if (flujo && flujo.texto) WAM.flujo(flujo.texto, flujo.respuestas); if (!flujo || !flujo.msg) return;
WAM.marcarUsado(el); /* gris + bloquea el doble clic */
WAM.flujo(flujo.texto, flujo.respuestas, flujo.cita);
} }
/* Opciones del sheet de lista (vive en #pantalla, fuera de #chat-cuerpo). */ /* Opciones y cierre de la hoja de lista (vive en #pantalla, fuera de #chat-cuerpo). */
function alClicPantalla(e) { function alClicPantalla(e) {
var objetivo = e.target; var objetivo = e.target;
var fila = (objetivo && objetivo.closest) ? objetivo.closest("[data-fila]") : null; if (!objetivo || !objetivo.closest) return;
if (!fila) return;
var hoja = porId("sheet"); var hoja = porId("sheet");
if (!hoja || !hoja.contains(fila)) return; if (!hoja || !hoja.contains(objetivo)) return;
if (objetivo.closest(".sheet-cerrar")) {
e.preventDefault();
WAM.cerrarSheet();
return;
}
var fila = objetivo.closest("[data-fila]");
if (!fila) return;
e.preventDefault(); e.preventDefault();
var flujo = WAM.resolverFlujo(fila); var flujo = WAM.resolverFlujo(fila);
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet(); WAM.cerrarSheet();
if (flujo && flujo.texto) WAM.flujo(flujo.texto, flujo.respuestas); if (flujo && flujo.msg) WAM.flujo(flujo.texto, flujo.respuestas, flujo.cita);
}
/* Teclado: Enter / Espacio activan los botones del teléfono (span role=button);
Escape cierra la hoja de lista. */
function alTeclaPantalla(e) {
if (e.key === "Escape") {
if (porId("sheet")) { e.preventDefault(); WAM.cerrarSheet(); }
return;
}
if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return;
var t = e.target;
if (!t || !t.closest || t.tagName === "A" || t.tagName === "BUTTON" ||
t.tagName === "INPUT" || t.tagName === "TEXTAREA") return;
var el = t.closest("[data-flujo],[data-ver-todas],.sheet-cerrar");
if (!el || el !== t) return;
e.preventDefault();
el.click();
} }
/* ————————————————— composer ————————————————— */ /* ————————————————— composer ————————————————— */
@@ -137,17 +269,34 @@ window.WAM = window.WAM || {};
/* ————————————————— bootstrap ————————————————— */ /* ————————————————— bootstrap ————————————————— */
/* Un fallo en un módulo (render o editor) no debe dejar sin enlazar el
resto: cada paso del arranque se aísla. */
function paso(fn) {
try { fn(); } catch (e) {
setTimeout(function () { throw e; }, 0); /* visible en consola, sin cortar init */
}
}
WAM.init = function () { WAM.init = function () {
WAM.estado = WAM.cargar(); WAM.onErrorGuardar = function (texto) { WAM.avisar(texto); };
WAM.setTema(WAM.estado.tema); paso(function () { WAM.estado = WAM.cargar(); });
WAM.render(); if (!WAM.estado) WAM.estado = WAM.normalizar(WAM.datosDemo());
WAM.renderEditor(); paso(function () { WAM.setTema(WAM.estado.tema); });
paso(function () { WAM.render(); });
paso(function () { WAM.renderEditor(); });
WAM.onDemoChange = pintarBotonDemo; WAM.onDemoChange = pintarBotonDemo;
al("chat-cuerpo", "click", alClicChat); al("chat-cuerpo", "click", alClicChat);
al("pantalla", "click", alClicPantalla); al("pantalla", "click", alClicPantalla);
al("pantalla", "keydown", alTeclaPantalla);
al("composer", "submit", alComposer); al("composer", "submit", alComposer);
al("btn-reproducir", "click", alReproducir); al("btn-reproducir", "click", alReproducir);
al("btn-reiniciar", "click", alReiniciarChat);
var vel = porId("sel-velocidad");
if (vel) {
vel.value = String(WAM.getVelocidad());
vel.addEventListener("change", alVelocidad);
}
al("btn-tema", "click", alTema); al("btn-tema", "click", alTema);
al("btn-presentacion", "click", alPresentacion); al("btn-presentacion", "click", alPresentacion);
al("btn-exportar", "click", alExportar); al("btn-exportar", "click", alExportar);
+404 -311
View File
File diff suppressed because it is too large Load Diff
+212 -48
View File
@@ -116,12 +116,60 @@ window.WAM = window.WAM || {};
}; };
} }
/* IDs entregados por generarId() que aún pueden no estar en WAM.estado
(p. ej. un clon con respuestas anidadas que se inserta al final): así dos
llamadas seguidas nunca devuelven el mismo ID. Se vacía al sustituir el
estado entero (cargar / resetear / normalizar de un import). */
var entregados = new Set();
WAM.generarId = function () { WAM.generarId = function () {
var conjunto = new Set(); var conjunto = new Set(entregados);
recolectarIds(WAM.estado, conjunto); recolectarIds(WAM.estado, conjunto);
return crearAlocador(conjunto)(); var id = crearAlocador(conjunto)();
entregados.add(id);
return id;
}; };
/* Alocador por lotes: devuelve una función tomar() que genera IDs únicos
frente a WAM.estado, a los ya entregados y a los que ella misma reparte.
`extra` (opcional): objeto/array cuyos id también se reservan. */
WAM.crearAlocador = function (extra) {
var conjunto = new Set(entregados);
recolectarIds(WAM.estado, conjunto);
if (extra) recolectarIds(extra, conjunto);
var tomar = crearAlocador(conjunto);
return function () {
var id = tomar();
entregados.add(id);
return id;
};
};
/* ————————————————— URLs seguras ————————————————— */
/* true si la URL puede ir a un href (tipo "enlace") o a un src ("media").
Enlaces: http(s), mailto, tel o sin esquema (relativa / "www…").
Media: http(s), blob, data:image|video|audio o sin esquema.
Rechaza javascript:, vbscript:, data:text/html, file:, etc. */
WAM.urlSegura = function (u, tipo) {
var s = String(u == null ? "" : u).replace(/[\u0000- \u007f-\u009f]/g, "");
if (!s) return true;
var m = /^([a-z][a-z0-9+.\-]*):/i.exec(s);
if (!m) return true;
var esquema = m[1].toLowerCase();
if (esquema === "http" || esquema === "https") return true;
if (tipo === "media") {
if (esquema === "blob") return true;
return esquema === "data" && /^data:(image|video|audio)\//i.test(s);
}
return esquema === "mailto" || esquema === "tel";
};
function urlLimpia(v, tipo) {
var s = cad(v).trim();
return WAM.urlSegura(s, tipo) ? s : "";
}
/* ————————————————— mensajes ————————————————— */ /* ————————————————— mensajes ————————————————— */
var CAMPOS = { var CAMPOS = {
@@ -159,65 +207,109 @@ window.WAM = window.WAM || {};
/* ————————————————— normalización ————————————————— */ /* ————————————————— normalización ————————————————— */
/* Rellena los campos que faltan por tipo y devuelve un estado válido. /* Profundidad máxima de respuestas encadenadas (defensa ante JSON hostil). */
Usado por cargar() y por editor.importarJSON (import / upgrade). */ var MAX_PROFUNDIDAD = 12;
WAM.normalizar = function (o) { var MAX_ITEMS = 10;
var src = (o && typeof o === "object" && !Array.isArray(o)) ? o : {}; var CLAVES_PROHIBIDAS = ["__proto__", "constructor", "prototype"];
var RE_ID = /^[A-Za-z0-9_-]{1,40}$/;
/* Índice que la reproducción "pulsa" sola (entero ≥ 0) o null. */
function indiceAuto(v) {
if (v === null || v === undefined || v === "" || typeof v === "boolean") return null;
var n = typeof v === "number" ? v : Number(String(v).trim());
if (!isFinite(n) || n < 0 || Math.floor(n) !== n) return null;
return n;
}
function lista(v, max) {
return (Array.isArray(v) ? v : []).slice(0, max || MAX_ITEMS);
}
/* Rellena los campos que faltan por tipo y devuelve un estado válido.
Usado por cargar(), resetear() y editor.importarJSON (import / upgrade).
- Acepta también un array de mensajes suelto como raíz.
- IDs: conserva los válidos y únicos; duplicados o inválidos se reasignan.
No reserva los IDs del WAM.estado vivo (el estado se sustituye entero).
- URLs peligrosas (javascript:, data:text/html…) se vacían.
- Respuestas en forma de cadena → mensaje de texto.
- Conserva campos extra primitivos desconocidos (compatibilidad hacia
delante con campos que añada el editor). */
WAM.normalizar = function (o) {
var src = Array.isArray(o) ? { mensajes: o }
: ((o && typeof o === "object") ? o : {});
entregados = new Set();
var conjunto = new Set(); var conjunto = new Set();
recolectarIds(src, conjunto); recolectarIds(src, conjunto);
recolectarIds(WAM.estado, conjunto); /* evita colisiones con lo vivo */
var tomar = crearAlocador(conjunto); var tomar = crearAlocador(conjunto);
var reclamados = new Set();
function normalizarMensaje(msj, forzarEntrada) { function idDe(v) {
var m = (msj && typeof msj === "object") ? msj : {}; if (typeof v === "number" && isFinite(v)) v = String(v);
if (typeof v === "string" && RE_ID.test(v) && !reclamados.has(v)) {
reclamados.add(v);
return v;
}
var nuevo = tomar();
reclamados.add(nuevo);
return nuevo;
}
function normalizarMensaje(msj, forzarEntrada, profundidad) {
var m;
if (typeof msj === "string") m = { tipo: "text", texto: msj };
else m = (msj && typeof msj === "object" && !Array.isArray(msj)) ? msj : {};
var tipo = TIPOS.indexOf(m.tipo) !== -1 ? m.tipo : "text"; var tipo = TIPOS.indexOf(m.tipo) !== -1 ? m.tipo : "text";
var msg = { var msg = {
id: (typeof m.id === "string" && m.id) ? m.id : tomar(), id: idDe(m.id),
dir: (!forzarEntrada && m.dir === "out") ? "out" : "in", dir: (!forzarEntrada && m.dir === "out") ? "out" : "in",
tipo: tipo, tipo: tipo,
hora: (typeof m.hora === "string" && m.hora) ? m.hora : WAM.horaAhora(), hora: (typeof m.hora === "string" && m.hora.trim()) ? m.hora.trim().slice(0, 12) : WAM.horaAhora(),
estado: (m.estado === "delivered" || m.estado === "read") ? m.estado : "sent" estado: (m.estado === "delivered" || m.estado === "read") ? m.estado : "sent"
}; };
function respuestas(valor) { function respuestas(valor) {
var lista = Array.isArray(valor) ? valor : []; if (profundidad >= MAX_PROFUNDIDAD) return [];
return lista.map(function (r) { return normalizarMensaje(r, true); }); return lista(valor, 20).map(function (r) { return normalizarMensaje(r, true, profundidad + 1); });
} }
function botones(valor) { function botones(valor) {
var lista = Array.isArray(valor) ? valor : []; return lista(valor).map(function (b) {
return lista.map(function (b) { var boton = (b && typeof b === "object") ? b : { texto: typeof b === "string" ? b : "" };
var boton = (b && typeof b === "object") ? b : {};
var out = { texto: cad(boton.texto), respuestas: respuestas(boton.respuestas) }; var out = { texto: cad(boton.texto), respuestas: respuestas(boton.respuestas) };
/* Botón URL de plantilla (research-api §7.2): se conserva si viene */ /* Botón URL de plantilla (research-api §7.2): se conserva si viene */
if (typeof boton.url === "string") out.url = boton.url; if (typeof boton.url === "string") out.url = urlLimpia(boton.url, "enlace");
return out; return out;
}); });
} }
function autoPulsar() {
var n = indiceAuto(m.autoPulsar);
if (n !== null) msg.autoPulsar = n;
}
switch (tipo) { switch (tipo) {
case "text": case "text":
msg.texto = cad(m.texto); msg.texto = cad(m.texto);
break; break;
case "image": case "image":
case "video": case "video":
msg.src = cad(m.src); msg.src = urlLimpia(m.src, "media");
msg.caption = cad(m.caption); msg.caption = cad(m.caption);
break; break;
case "audio": case "audio":
msg.src = cad(m.src); msg.src = urlLimpia(m.src, "media");
msg.voz = m.voz === true; msg.voz = m.voz === true || m.voz === "true";
msg.duracion = cad(m.duracion, "0:00"); msg.duracion = cad(m.duracion, "0:00") || "0:00";
break; break;
case "document": case "document":
msg.src = cad(m.src); msg.src = urlLimpia(m.src, "media");
msg.caption = cad(m.caption); msg.caption = cad(m.caption);
msg.filename = cad(m.filename, "documento.pdf"); msg.filename = cad(m.filename, "documento.pdf") || "documento.pdf";
break; break;
case "sticker": case "sticker":
msg.src = cad(m.src); msg.src = urlLimpia(m.src, "media");
break; break;
case "location": case "location":
msg.latitud = cad(m.latitud); msg.latitud = cad(m.latitud);
@@ -232,32 +324,26 @@ window.WAM = window.WAM || {};
msg.empresa = cad(m.empresa); msg.empresa = cad(m.empresa);
break; break;
case "template": 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": case "buttons":
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : ""; msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
msg.headerTexto = cad(m.headerTexto); msg.headerTexto = cad(m.headerTexto);
msg.headerMedia = cad(m.headerMedia); msg.headerMedia = urlLimpia(m.headerMedia, "media");
msg.body = cad(m.body); msg.body = cad(m.body);
msg.footer = cad(m.footer); msg.footer = cad(m.footer);
msg.botones = botones(m.botones); msg.botones = botones(m.botones);
autoPulsar();
break; break;
case "list": case "list":
msg.header = cad(m.header); msg.header = cad(m.header);
msg.body = cad(m.body); msg.body = cad(m.body);
msg.footer = cad(m.footer); msg.footer = cad(m.footer);
msg.boton = cad(m.boton); msg.boton = cad(m.boton);
msg.secciones = (Array.isArray(m.secciones) ? m.secciones : []).map(function (s) { msg.secciones = lista(m.secciones).map(function (s) {
var seccion = (s && typeof s === "object") ? s : {}; var seccion = (s && typeof s === "object") ? s : {};
return { return {
titulo: cad(seccion.titulo), titulo: cad(seccion.titulo),
filas: (Array.isArray(seccion.filas) ? seccion.filas : []).map(function (f) { filas: lista(seccion.filas).map(function (f) {
var fila = (f && typeof f === "object") ? f : {}; var fila = (f && typeof f === "object") ? f : { titulo: typeof f === "string" ? f : "" };
return { return {
titulo: cad(fila.titulo), titulo: cad(fila.titulo),
descripcion: cad(fila.descripcion), descripcion: cad(fila.descripcion),
@@ -266,28 +352,39 @@ window.WAM = window.WAM || {};
}) })
}; };
}); });
autoPulsar();
break; break;
case "cta_url": case "cta_url":
msg.headerMedia = cad(m.headerMedia); msg.headerMedia = urlLimpia(m.headerMedia, "media");
msg.body = cad(m.body); msg.body = cad(m.body);
msg.footer = cad(m.footer); msg.footer = cad(m.footer);
msg.textoBoton = cad(m.textoBoton); msg.textoBoton = cad(m.textoBoton);
msg.url = cad(m.url); msg.url = urlLimpia(m.url, "enlace");
break; break;
case "carousel": case "carousel":
msg.body = cad(m.body); msg.body = cad(m.body);
msg.cards = (Array.isArray(m.cards) ? m.cards : []).map(function (c) { msg.cards = lista(m.cards).map(function (c) {
var card = (c && typeof c === "object") ? c : {}; var card = (c && typeof c === "object") ? c : {};
return { return {
media: cad(card.media), media: urlLimpia(card.media, "media"),
texto: cad(card.texto), texto: cad(card.texto),
textoBoton: cad(card.textoBoton), textoBoton: cad(card.textoBoton),
url: cad(card.url), url: urlLimpia(card.url, "enlace"),
respuestas: respuestas(card.respuestas) respuestas: respuestas(card.respuestas)
}; };
}); });
autoPulsar();
break; break;
} }
/* Campos extra primitivos (p. ej. headerFilename) se conservan. */
for (var k in m) {
if (!Object.prototype.hasOwnProperty.call(m, k)) continue;
if (Object.prototype.hasOwnProperty.call(msg, k) || k === "autoPulsar") continue;
if (CLAVES_PROHIBIDAS.indexOf(k) !== -1 || k === "cita") continue;
var v = m[k];
if (typeof v === "string" || typeof v === "boolean" || (typeof v === "number" && isFinite(v))) msg[k] = v;
}
return msg; return msg;
} }
@@ -297,18 +394,48 @@ window.WAM = window.WAM || {};
tema: src.tema === "oscuro" ? "oscuro" : "claro", tema: src.tema === "oscuro" ? "oscuro" : "claro",
cuenta: { cuenta: {
nombre: cad(cuentaSrc.nombre, "Café Aurora"), nombre: cad(cuentaSrc.nombre, "Café Aurora"),
avatar: cad(cuentaSrc.avatar), avatar: urlLimpia(cuentaSrc.avatar, "media"),
subtitulo: cad(cuentaSrc.subtitulo, "en línea"), subtitulo: cad(cuentaSrc.subtitulo, "en línea"),
verificado: cuentaSrc.verificado === true verificado: cuentaSrc.verificado === true || cuentaSrc.verificado === "true",
/* B: fecha "Hoy" + aviso de cifrado al inicio del chat (por defecto sí) */
avisoCifrado: !(cuentaSrc.avisoCifrado === false || cuentaSrc.avisoCifrado === "false")
}, },
mensajes: (Array.isArray(src.mensajes) ? src.mensajes : []).map(function (x) { mensajes: lista(src.mensajes, 500).map(function (x) {
return normalizarMensaje(x, false); return normalizarMensaje(x, false, 0);
}) })
}; };
}; };
/* ————————————————— persistencia ————————————————— */ /* ————————————————— persistencia ————————————————— */
/* Aviso de error de guardado: se emite UNA vez hasta el siguiente guardado
correcto, por el hook opcional WAM.onErrorGuardar(texto, error). */
var avisoEmitido = false;
var ultimoOk = true;
function esCuota(e) {
return !!e && (e.name === "QuotaExceededError" || e.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
e.code === 22 || e.code === 1014);
}
function escribir() {
try {
window.localStorage.setItem(WAM.CLAVE, JSON.stringify(WAM.estado));
avisoEmitido = false;
ultimoOk = true;
} catch (e) {
ultimoOk = false;
if (avisoEmitido) return;
avisoEmitido = true;
var texto = esCuota(e)
? "No se pudo guardar: el almacenamiento del navegador está lleno (imágenes muy pesadas). Exporta la demo a JSON para no perder los cambios."
: "No se pudo guardar en este navegador (almacenamiento local bloqueado). Exporta la demo a JSON para no perder los cambios.";
if (typeof WAM.onErrorGuardar === "function") {
try { WAM.onErrorGuardar(texto, e); } catch (e2) { /* hook opcional */ }
}
}
}
WAM.cargar = function () { WAM.cargar = function () {
var bruto = null; var bruto = null;
try { bruto = window.localStorage.getItem(WAM.CLAVE); } catch (e) { bruto = null; } try { bruto = window.localStorage.getItem(WAM.CLAVE); } catch (e) { bruto = null; }
@@ -316,20 +443,57 @@ window.WAM = window.WAM || {};
if (bruto) { if (bruto) {
try { dato = JSON.parse(bruto); } catch (e) { dato = null; } try { dato = JSON.parse(bruto); } catch (e) { dato = null; }
} }
if (!dato) dato = WAM.datosDemo(); if (!dato || typeof dato !== "object") dato = WAM.datosDemo();
WAM.estado = null;
WAM.estado = WAM.normalizar(dato); WAM.estado = WAM.normalizar(dato);
return WAM.estado; return WAM.estado;
}; };
/* Guardado con coalescencia: la primera llamada escribe al instante; las
que llegan en los 250 ms siguientes (p. ej. cada tecla del editor) se
agrupan en una sola escritura final. pagehide/visibilitychange vacían
lo pendiente para no perder nada al recargar o cerrar. */
var VENTANA = 250;
var temporizador = null;
var pendiente = false;
WAM.guardar = function () { WAM.guardar = function () {
try { if (temporizador) { pendiente = true; return; }
window.localStorage.setItem(WAM.CLAVE, JSON.stringify(WAM.estado)); escribir();
} catch (e) { /* sin localStorage disponible (cuota/file://): se ignora */ } temporizador = setTimeout(function fin() {
temporizador = null;
if (pendiente) {
pendiente = false;
escribir();
temporizador = setTimeout(fin, VENTANA);
}
}, VENTANA);
}; };
/* Escritura inmediata de lo pendiente (true si quedó guardado). */
WAM.guardarYa = function () {
if (temporizador) { clearTimeout(temporizador); temporizador = null; }
pendiente = false;
escribir();
return ultimoOk;
};
function vaciarPendiente() { if (pendiente) WAM.guardarYa(); }
if (typeof window !== "undefined" && window.addEventListener) {
window.addEventListener("pagehide", vaciarPendiente);
window.addEventListener("beforeunload", vaciarPendiente);
document.addEventListener("visibilitychange", function () {
if (document.visibilityState === "hidden") vaciarPendiente();
});
}
WAM.resetear = function () { WAM.resetear = function () {
if (temporizador) { clearTimeout(temporizador); temporizador = null; }
pendiente = false;
try { window.localStorage.removeItem(WAM.CLAVE); } catch (e) { /* idem */ } try { window.localStorage.removeItem(WAM.CLAVE); } catch (e) { /* idem */ }
WAM.estado = null; /* los IDs de la demo vuelven a empezar en m1 */
WAM.estado = WAM.normalizar(WAM.datosDemo()); WAM.estado = WAM.normalizar(WAM.datosDemo());
avisoEmitido = false;
}; };
/* ————————————————— demo por defecto: "Café Aurora" ————————————————— */ /* ————————————————— demo por defecto: "Café Aurora" ————————————————— */
+824
View File
@@ -0,0 +1,824 @@
/* =====================================================================
js/ux.js — Capa de experiencia de la app (editor, C). No toca el DOM
al cargar: WAM.initUX() se ejecuta una sola vez desde WAM.renderEditor().
· Historial deshacer / rehacer (WAM.puntoDeshacer / deshacer / rehacer)
· Indicador de guardado en la toolbar (#estado-guardado)
· Confirmación de Restablecer / Importar (WAM.confirmarReemplazo)
· Diálogo «Demos»: escenarios por industria + demos guardadas (wam-demos)
· Atajos de teclado globales y diálogo de ayuda (?)
· Pestañas Editor / Vista previa en ventana estrecha y menú «⋯»
· Enlace vista previa → tarjeta (Alt+clic o doble clic en una burbuja)
· Región sr-only que anuncia los mensajes nuevos del chat
===================================================================== */
(function () {
"use strict";
window.WAM = window.WAM || {};
var LIMITE_HISTORIAL = 30;
var CLAVE_DEMOS = "wam-demos";
var PREFIJO_DEMO = "wam-demo-";
var CLAVE_ACTIVA = "wam-demo-activa";
var CLAVE_VISTA = "wam-ui-vista";
var MQ_ESTRECHA = "(max-width: 900px)";
var pilaDeshacer = [];
var pilaRehacer = [];
var iniciado = false;
/* ------------------------------------------------------------------
Utilidades
------------------------------------------------------------------ */
function porId(id) { return document.getElementById(id); }
function h(etiqueta, attrs, hijos) {
var el = document.createElement(etiqueta);
Object.keys(attrs || {}).forEach(function (k) {
var v = attrs[k];
if (v === null || v === undefined || v === false) return;
if (k === "class") el.className = v;
else if (k === "text") el.textContent = v;
else if (k === "value") el.value = v;
else el.setAttribute(k, v === true ? "" : v);
});
(Array.isArray(hijos) ? hijos : (hijos ? [hijos] : [])).forEach(function (x) {
if (x) el.appendChild(typeof x === "string" ? document.createTextNode(x) : x);
});
return el;
}
function toast(texto, opts) {
if (typeof WAM.toast === "function") return WAM.toast(texto, opts);
return function () {};
}
function lsGet(k) { try { return window.localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { window.localStorage.setItem(k, v); return true; } catch (e) { return false; } }
function lsDel(k) { try { window.localStorage.removeItem(k); } catch (e) { /* nada */ } }
function esEstrecha() {
return !!(window.matchMedia && window.matchMedia(MQ_ESTRECHA).matches);
}
function enCampoTexto(t) {
if (!t || !t.tagName) return false;
if (t.isContentEditable) return true;
if (t.tagName === "TEXTAREA" || t.tagName === "SELECT") return true;
if (t.tagName === "INPUT") {
var tipo = (t.type || "text").toLowerCase();
return ["checkbox", "radio", "button", "submit", "reset", "file", "range", "color"].indexOf(tipo) === -1;
}
return false;
}
function esInteractivo(t) {
if (!t || !t.closest) return false;
return !!t.closest("button, a[href], input, textarea, select, summary, [role='button'], [role='tab'], [tabindex]:not([tabindex='-1'])");
}
function nMensajes() {
return (WAM.estado && Array.isArray(WAM.estado.mensajes)) ? WAM.estado.mensajes.length : 0;
}
// Copia profunda estructural: los strings (dataURL incluidos) se comparten
// por referencia, así 30 instantáneas no multiplican los MB en memoria.
function clonar(v) {
if (Array.isArray(v)) return v.map(clonar);
if (v && typeof v === "object") {
var o = {};
for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) o[k] = clonar(v[k]);
return o;
}
return v;
}
function reRenderTotal() {
if (typeof WAM.setTema === "function") WAM.setTema(WAM.estado.tema);
if (typeof WAM.render === "function") WAM.render();
if (typeof WAM.renderEditor === "function") WAM.renderEditor();
if (typeof WAM.onDemoChange === "function") { try { WAM.onDemoChange(false); } catch (e) { /* opcional */ } }
}
function guardarYa() {
if (typeof WAM.guardarYa === "function") return WAM.guardarYa();
if (typeof WAM.guardar === "function") WAM.guardar();
return true;
}
// Sustituye el estado entero (undo, escenario, demo guardada)
function sustituirEstado(nuevo) {
if (typeof WAM.demoActiva === "function" && WAM.demoActiva() && typeof WAM.detenerDemo === "function") WAM.detenerDemo();
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet();
WAM.estado = nuevo;
guardarYa();
reRenderTotal();
marcarEditado();
}
function normalizado(crudo) {
var previo = WAM.estado;
WAM.estado = null; // los IDs empiezan en m1 (el estado se sustituye entero)
try {
return (typeof WAM.normalizar === "function") ? WAM.normalizar(crudo) : crudo;
} finally {
WAM.estado = previo;
}
}
/* ------------------------------------------------------------------
Historial deshacer / rehacer
------------------------------------------------------------------ */
WAM.puntoDeshacer = function () {
if (!WAM.estado) return;
pilaDeshacer.push(clonar(WAM.estado));
if (pilaDeshacer.length > LIMITE_HISTORIAL) pilaDeshacer.shift();
pilaRehacer = [];
pintarBotonesHistorial();
};
WAM.puedeDeshacer = function () { return pilaDeshacer.length > 0; };
WAM.deshacer = function () {
if (!pilaDeshacer.length) { toast("No hay nada que deshacer"); return false; }
pilaRehacer.push(clonar(WAM.estado));
sustituirEstado(pilaDeshacer.pop());
pintarBotonesHistorial();
toast("Cambio deshecho", { accion: { texto: "Rehacer", fn: WAM.rehacer } });
return true;
};
WAM.rehacer = function () {
if (!pilaRehacer.length) { toast("No hay nada que rehacer"); return false; }
pilaDeshacer.push(clonar(WAM.estado));
sustituirEstado(pilaRehacer.pop());
pintarBotonesHistorial();
toast("Cambio rehecho");
return true;
};
function pintarBotonesHistorial() {
var d = porId("btn-deshacer"), r = porId("btn-rehacer");
if (d) d.disabled = !pilaDeshacer.length;
if (r) r.disabled = !pilaRehacer.length;
}
/* ------------------------------------------------------------------
Indicador de guardado (#estado-guardado)
------------------------------------------------------------------ */
var ultimoGuardado = 0;
var tComprobar = 0;
var errorGuardado = "";
function pintarGuardado(estadoTxt, clase, titulo) {
var el = porId("estado-guardado");
if (!el) return;
el.setAttribute("data-estado", clase);
var txt = el.querySelector(".tb-guardado-txt");
if (txt) txt.textContent = estadoTxt;
el.title = titulo || estadoTxt;
pintarRelativo();
}
function formatoTamano(bytes) {
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + " KB";
return (bytes / (1024 * 1024)).toFixed(1).replace(".", ",") + " MB";
}
function pintarRelativo() {
var el = porId("estado-guardado");
var rel = el && el.querySelector(".tb-guardado-rel");
if (!rel) return;
if (el.getAttribute("data-estado") !== "ok" || !ultimoGuardado) { rel.textContent = ""; return; }
var s = Math.max(0, Math.round((Date.now() - ultimoGuardado) / 1000));
rel.textContent = s < 5 ? "· ahora" : (s < 60 ? "· hace " + s + " s" : "· hace " + Math.round(s / 60) + " min");
}
function comprobarGuardado() {
tComprobar = 0;
var ok = guardarYa();
if (ok === false) {
pintarGuardado("No se pudo guardar", "error", errorGuardado || "No se pudo guardar en este navegador: exporta el JSON.");
return;
}
errorGuardado = "";
ultimoGuardado = Date.now();
var bruto = lsGet(WAM.CLAVE || "wam-mockup-v1");
var bytes = bruto ? bruto.length * 2 : 0; // UTF-16 en memoria del navegador
var extra = bytes > 3 * 1024 * 1024 ? " · " + formatoTamano(bytes) : "";
pintarGuardado("Guardado" + extra, "ok", "Guardado en este navegador" + (bytes ? " (" + formatoTamano(bytes) + ")" : "") +
(bytes > 4 * 1024 * 1024 ? ". Te acercas al límite (~5 MB): usa URLs en vez de subir archivos pesados." : ""));
}
// El editor la llama tras cada mutación: «Guardando…» y comprobación diferida
function marcarEditado() {
if (errorGuardado) return; // mantener el aviso en rojo hasta que vuelva a funcionar
pintarGuardado("Guardando…", "pendiente");
clearTimeout(tComprobar);
tComprobar = setTimeout(comprobarGuardado, 700);
}
WAM.marcarEditado = marcarEditado;
function alErrorGuardar(texto) {
errorGuardado = texto || "No se pudo guardar";
pintarGuardado("No se pudo guardar", "error", errorGuardado);
toast(errorGuardado, {
tipo: "error", ms: 12000,
accion: { texto: "Exportar JSON", fn: function () { if (typeof WAM.exportarJSON === "function") WAM.exportarJSON(); } }
});
// Reintento: al siguiente guardado correcto el indicador vuelve a verde
setTimeout(function () { errorGuardado = ""; }, 4000);
}
/* ------------------------------------------------------------------
Diálogos (<dialog> nativo; reserva con window.confirm)
------------------------------------------------------------------ */
var soportaDialog = typeof window.HTMLDialogElement === "function";
function crearDialogo(id, titulo, clase) {
var viejo = porId(id);
if (viejo) viejo.remove();
var cerrar = h("button", { type: "button", class: "wam-dlg-cerrar", "aria-label": "Cerrar", text: "×" });
var cuerpo = h("div", { class: "wam-dlg-cuerpo" });
var pie = h("div", { class: "wam-dlg-pie" });
var dlg = h("dialog", { id: id, class: "wam-dlg" + (clase ? " " + clase : ""), "aria-labelledby": id + "-titulo" }, [
h("div", { class: "wam-dlg-cab" }, [h("h2", { id: id + "-titulo", class: "wam-dlg-titulo", text: titulo }), cerrar]),
cuerpo,
pie
]);
cerrar.addEventListener("click", function () { dlg.close(); });
// Clic en el fondo (fuera de la caja) cierra
dlg.addEventListener("click", function (e) {
if (e.target !== dlg) return;
var r = dlg.getBoundingClientRect();
var dentro = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
if (!dentro) dlg.close();
});
var foco = null;
dlg.addEventListener("close", function () {
setTimeout(function () { dlg.remove(); }, 0);
if (foco && foco.isConnected && typeof foco.focus === "function") foco.focus();
});
document.body.appendChild(dlg);
return {
dlg: dlg, cuerpo: cuerpo, pie: pie,
abrir: function () { foco = document.activeElement; dlg.showModal(); }
};
}
function boton(texto, clase, fn) {
var b = h("button", { type: "button", class: "wam-dlg-btn " + (clase || ""), text: texto });
if (fn) b.addEventListener("click", fn);
return b;
}
/* Confirmación de acciones que sustituyen la demo entera.
tipo: "reset" | "importar". ejecutar(): la acción real. */
WAM.confirmarReemplazo = function (tipo, ejecutar) {
if (typeof ejecutar !== "function") return;
var esReset = tipo === "reset";
var hacer = function () {
if (esReset) {
WAM.puntoDeshacer();
lsDel(CLAVE_ACTIVA);
ejecutar();
marcarEditado();
toast("Demo de ejemplo restablecida", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
} else {
ejecutar(); // el punto de deshacer lo toma importarJSON al aplicar el archivo
}
};
if (!nMensajes()) { hacer(); return; }
var nombre = (WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "sin nombre";
var resumen = "«" + nombre + "», " + nMensajes() + " mensaje" + (nMensajes() === 1 ? "" : "s");
if (!soportaDialog) {
if (window.confirm(esReset ? "¿Restablecer la demo de ejemplo? Se sustituirá " + resumen + "."
: "¿Importar un JSON? Se sustituirá " + resumen + ".")) hacer();
return;
}
var d = crearDialogo("dlg-confirmar", esReset ? "¿Restablecer la demo de ejemplo?" : "¿Importar una demo desde JSON?", "wam-dlg-chico");
d.cuerpo.appendChild(h("p", { text: (esReset
? "La demo actual (" + resumen + ") se sustituirá por la demo de ejemplo «Café Aurora»."
: "El archivo que elijas sustituirá la demo actual (" + resumen + ").") }));
d.cuerpo.appendChild(h("p", { class: "wam-dlg-nota", text: "Puedes exportarla antes. Mientras no recargues la página, también puedes deshacer con Ctrl+Z." }));
var bExp = boton("Exportar antes", "", function () {
if (typeof WAM.exportarJSON === "function") WAM.exportarJSON();
bExp.textContent = "Exportada ✓";
bExp.disabled = true;
});
var bOk = boton(esReset ? "Restablecer" : "Elegir archivo…", esReset ? "wam-dlg-peligro" : "wam-dlg-primario", function () {
d.dlg.close();
hacer();
});
d.pie.appendChild(boton("Cancelar", "", function () { d.dlg.close(); }));
d.pie.appendChild(bExp);
d.pie.appendChild(bOk);
d.abrir();
bOk.focus();
};
/* ------------------------------------------------------------------
Demos: escenarios + demos guardadas en localStorage
------------------------------------------------------------------ */
function indiceDemos() {
var bruto = lsGet(CLAVE_DEMOS);
try {
var arr = JSON.parse(bruto || "[]");
return Array.isArray(arr) ? arr.filter(function (x) { return x && typeof x.id === "string"; }) : [];
} catch (e) { return []; }
}
function guardarIndice(arr) { return lsSet(CLAVE_DEMOS, JSON.stringify(arr)); }
function nuevoIdDemo() { return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
function demoActiva() {
var id = lsGet(CLAVE_ACTIVA);
if (!id) return null;
var idx = indiceDemos();
for (var i = 0; i < idx.length; i++) if (idx[i].id === id) return idx[i];
return null;
}
WAM.nombreDemoActiva = function () {
var d = demoActiva();
return d ? d.nombre : "";
};
function guardarDemoComo(nombre, id) {
guardarYa();
var idx = indiceDemos();
var ahora = new Date().toISOString();
var nuevoId = id || nuevoIdDemo();
if (!lsSet(PREFIJO_DEMO + nuevoId, JSON.stringify(WAM.estado))) {
toast("No se pudo guardar la demo: almacenamiento lleno. Exporta a JSON o borra demos guardadas.", { tipo: "error", ms: 9000 });
return null;
}
var entrada = { id: nuevoId, nombre: nombre || "Demo sin nombre", fecha: ahora, n: nMensajes() };
var pos = -1;
for (var i = 0; i < idx.length; i++) if (idx[i].id === nuevoId) pos = i;
if (pos >= 0) idx[pos] = entrada; else idx.unshift(entrada);
guardarIndice(idx);
lsSet(CLAVE_ACTIVA, nuevoId);
return entrada;
}
function cargarDemo(id) {
var bruto = lsGet(PREFIJO_DEMO + id);
var datos = null;
try { datos = JSON.parse(bruto || "null"); } catch (e) { datos = null; }
if (!datos) { toast("No se encontró esa demo guardada", { tipo: "error" }); return false; }
WAM.puntoDeshacer();
sustituirEstado(normalizado(datos));
lsSet(CLAVE_ACTIVA, id);
return true;
}
function cargarEscenario(esc) {
var crudo = WAM.datosEscenario ? WAM.datosEscenario(esc.id) : null;
if (!crudo) return;
var habiaAlgo = nMensajes() > 0;
if (habiaAlgo) WAM.puntoDeshacer();
sustituirEstado(normalizado(crudo));
lsDel(CLAVE_ACTIVA);
toast("Escenario «" + esc.nombre + "» cargado", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
}
function fechaCorta(iso) {
var d = new Date(iso);
if (isNaN(d.getTime())) return "";
try {
return d.toLocaleString("es", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
} catch (e) { return d.toISOString().slice(0, 16).replace("T", " "); }
}
WAM.abrirDemos = function (seccion) {
if (!soportaDialog) { toast("Tu navegador no admite diálogos nativos", { tipo: "error" }); return; }
var d = crearDialogo("dlg-demos", "Demos", "wam-dlg-grande");
// --- Escenarios ---
var gridEsc = h("div", { class: "wam-esc-grid" });
(WAM.ESCENARIOS || []).forEach(function (esc) {
var n = 0;
try { n = (esc.datos().mensajes || []).length; } catch (e) { n = 0; }
var b = h("button", { type: "button", class: "wam-esc", "data-esc": esc.id }, [
h("span", { class: "wam-esc-nombre", text: esc.nombre }),
h("span", { class: "wam-esc-desc", text: esc.descripcion }),
h("span", { class: "wam-esc-meta", text: n ? n + " mensajes" : "Vacía" })
]);
b.addEventListener("click", function () { d.dlg.close(); cargarEscenario(esc); });
gridEsc.appendChild(b);
});
var bAurora = h("button", { type: "button", class: "wam-esc" }, [
h("span", { class: "wam-esc-nombre", text: "Catálogo · Café Aurora" }),
h("span", { class: "wam-esc-desc", text: "Los 13 tipos de mensaje de la Cloud API. Útil como referencia." }),
h("span", { class: "wam-esc-meta", text: "Demo de ejemplo" })
]);
bAurora.addEventListener("click", function () {
d.dlg.close();
if (typeof WAM.datosDemo !== "function") return;
var habiaAlgo = nMensajes() > 0;
if (habiaAlgo) WAM.puntoDeshacer();
sustituirEstado(normalizado(WAM.datosDemo()));
lsDel(CLAVE_ACTIVA);
toast("Demo «Café Aurora» cargada", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
});
gridEsc.appendChild(bAurora);
var secEsc = h("section", { class: "wam-dlg-sec", "aria-labelledby": "dlg-sec-esc" }, [
h("h3", { id: "dlg-sec-esc", class: "wam-dlg-subtitulo", text: "Empezar desde un escenario" }),
h("p", { class: "wam-dlg-nota", text: "Sustituye la demo actual (puedes deshacer). Los textos son de ejemplo y ficticios: revísalos y adáptalos antes de enseñarlos a un cliente." }),
gridEsc
]);
// --- Demos guardadas ---
var lista = h("ul", { class: "wam-demos-lista" });
var activa = demoActiva();
var inputNombre = h("input", {
type: "text", class: "wam-dlg-input", id: "dlg-demo-nombre", maxlength: "60",
value: activa ? activa.nombre : ((WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "Mi demo"),
"aria-label": "Nombre de la demo"
});
var bGuardar = boton(activa ? "Actualizar" : "Guardar la actual", "wam-dlg-primario", function () {
var nombre = String(inputNombre.value || "").trim() || "Mi demo";
var misma = activa && activa.nombre === nombre;
var e = guardarDemoComo(nombre, misma ? activa.id : null);
if (e) { toast("Demo «" + e.nombre + "» guardada"); pintarLista(); activa = demoActiva(); }
});
inputNombre.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); bGuardar.click(); } });
inputNombre.addEventListener("input", function () {
bGuardar.textContent = (activa && activa.nombre === String(inputNombre.value).trim()) ? "Actualizar" : "Guardar como nueva";
});
function pintarLista() {
while (lista.firstChild) lista.removeChild(lista.firstChild);
var idx = indiceDemos();
var actId = lsGet(CLAVE_ACTIVA);
if (!idx.length) {
lista.appendChild(h("li", { class: "wam-demos-vacio", text: "Aún no has guardado ninguna demo. Guarda la actual para tener varias (una por cliente) sin exportar e importar." }));
return;
}
idx.forEach(function (it) {
var nombreEl = h("span", { class: "wam-demo-nombre", text: it.nombre });
var li = h("li", { class: "wam-demo" + (it.id === actId ? " wam-demo-activa" : "") }, [
h("div", { class: "wam-demo-info" }, [
nombreEl,
h("span", { class: "wam-demo-meta", text: (it.n || 0) + " mensajes · " + fechaCorta(it.fecha) + (it.id === actId ? " · en uso" : "") })
])
]);
var acc = h("div", { class: "wam-demo-acc" });
acc.appendChild(boton("Abrir", "wam-dlg-mini", function () {
d.dlg.close();
if (cargarDemo(it.id)) toast("Demo «" + it.nombre + "» abierta", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
}));
acc.appendChild(boton("Renombrar", "wam-dlg-mini", function () {
var inp = h("input", { type: "text", class: "wam-dlg-input", value: it.nombre, maxlength: "60", "aria-label": "Nuevo nombre" });
nombreEl.replaceWith(inp);
inp.focus(); inp.select();
var hecho = false;
var fin = function (guardar) {
if (hecho) return; hecho = true;
var v = String(inp.value || "").trim();
if (guardar && v) {
var arr = indiceDemos();
arr.forEach(function (x) { if (x.id === it.id) x.nombre = v; });
guardarIndice(arr);
}
pintarLista();
};
inp.addEventListener("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); fin(true); }
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); fin(false); }
});
inp.addEventListener("blur", function () { fin(true); });
}));
acc.appendChild(boton("Duplicar", "wam-dlg-mini", function () {
var bruto = lsGet(PREFIJO_DEMO + it.id);
var nid = nuevoIdDemo();
if (!bruto || !lsSet(PREFIJO_DEMO + nid, bruto)) { toast("No se pudo duplicar (almacenamiento lleno)", { tipo: "error" }); return; }
var arr = indiceDemos();
arr.unshift({ id: nid, nombre: it.nombre + " (copia)", fecha: new Date().toISOString(), n: it.n || 0 });
guardarIndice(arr);
pintarLista();
}));
var bBorrar = boton("Borrar", "wam-dlg-mini wam-dlg-peligro", function () {
if (bBorrar.getAttribute("data-confirmar") !== "1") {
bBorrar.setAttribute("data-confirmar", "1");
bBorrar.textContent = "¿Seguro?";
setTimeout(function () {
if (bBorrar.isConnected) { bBorrar.removeAttribute("data-confirmar"); bBorrar.textContent = "Borrar"; }
}, 3500);
return;
}
lsDel(PREFIJO_DEMO + it.id);
guardarIndice(indiceDemos().filter(function (x) { return x.id !== it.id; }));
if (lsGet(CLAVE_ACTIVA) === it.id) lsDel(CLAVE_ACTIVA);
activa = demoActiva();
pintarLista();
});
acc.appendChild(bBorrar);
li.appendChild(acc);
lista.appendChild(li);
});
}
pintarLista();
var secGuardadas = h("section", { class: "wam-dlg-sec", "aria-labelledby": "dlg-sec-guardadas" }, [
h("h3", { id: "dlg-sec-guardadas", class: "wam-dlg-subtitulo", text: "Mis demos guardadas" }),
h("div", { class: "wam-dlg-fila" }, [inputNombre, bGuardar]),
lista,
h("p", { class: "wam-dlg-nota", text: "Se guardan solo en este navegador. Para compartir una demo o hacer copia de seguridad, usa Exportar." })
]);
d.cuerpo.appendChild(seccion === "guardadas" ? secGuardadas : secEsc);
d.cuerpo.appendChild(seccion === "guardadas" ? secEsc : secGuardadas);
d.pie.appendChild(boton("Cerrar", "", function () { d.dlg.close(); }));
d.abrir();
var primero = d.cuerpo.querySelector(".wam-esc, .wam-dlg-input");
if (primero) primero.focus();
};
/* ------------------------------------------------------------------
Atajos de teclado
------------------------------------------------------------------ */
var ATAJOS = [
["P", "Modo presentación"],
["Espacio", "Reproducir / detener la demo"],
["T", "Tema claro / oscuro"],
["F", "Pantalla completa"],
["N", "Demos y escenarios"],
["Esc", "Cerrar la lista del chat o salir de la presentación"],
["Ctrl + S", "Exportar JSON"],
["Ctrl + Z", "Deshacer (fuera de un campo de texto)"],
["Ctrl + Mayús + Z", "Rehacer"],
["Alt + ↑ / ↓", "Mover el mensaje o elemento con el foco"],
["Alt + clic en una burbuja", "Abrir su tarjeta en el editor (también doble clic)"],
["?", "Esta ayuda"]
];
WAM.abrirAtajos = function () {
if (!soportaDialog) return;
var d = crearDialogo("dlg-atajos", "Atajos de teclado", "wam-dlg-chico");
var tabla = h("dl", { class: "wam-atajos" });
ATAJOS.forEach(function (a) {
tabla.appendChild(h("dt", {}, a[0].split(" + ").map(function (k, i, arr) {
var frag = document.createDocumentFragment();
frag.appendChild(h("kbd", { text: k }));
if (i < arr.length - 1) frag.appendChild(document.createTextNode(" + "));
return frag;
})));
tabla.appendChild(h("dd", { text: a[1] }));
});
d.cuerpo.appendChild(tabla);
d.cuerpo.appendChild(h("p", { class: "wam-dlg-nota", text: "Los atajos de una letra no actúan mientras escribes en un campo." }));
d.pie.appendChild(boton("Cerrar", "wam-dlg-primario", function () { d.dlg.close(); }));
d.abrir();
};
function clicEn(id) { var b = porId(id); if (b && !b.disabled) b.click(); }
function alternarPantallaCompleta() {
var doc = document;
try {
if (doc.fullscreenElement) { var p = doc.exitFullscreen(); if (p && p.catch) p.catch(function () {}); }
else if (doc.documentElement.requestFullscreen) {
var q = doc.documentElement.requestFullscreen();
if (q && q.catch) q.catch(function () { toast("El navegador no permitió la pantalla completa", { tipo: "error" }); });
}
} catch (e) { /* sin soporte */ }
}
function sheetAbierto() {
var s = porId("sheet");
return !!(s && !s.hidden && s.offsetParent !== null);
}
function alTecla(e) {
if (e.defaultPrevented || e.isComposing) return;
var k = e.key;
var mod = e.ctrlKey || e.metaKey;
var hayDialogo = !!document.querySelector("dialog[open]");
if (mod && !e.altKey && (k === "s" || k === "S")) {
e.preventDefault();
if (!hayDialogo && typeof WAM.exportarJSON === "function") WAM.exportarJSON();
return;
}
if (hayDialogo) return; // el <dialog> gestiona Esc y el foco
if (mod && !e.altKey && (k === "z" || k === "Z") && !enCampoTexto(e.target)) {
e.preventDefault();
if (e.shiftKey) WAM.rehacer(); else WAM.deshacer();
return;
}
if (mod && !e.altKey && (k === "y" || k === "Y") && !enCampoTexto(e.target)) {
e.preventDefault();
WAM.rehacer();
return;
}
if (k === "Escape") {
if (cerrarMenu()) return;
if (sheetAbierto() && typeof WAM.cerrarSheet === "function") { WAM.cerrarSheet(); return; }
if (document.body.classList.contains("presentacion") && !enCampoTexto(e.target)) clicEn("btn-presentacion");
return;
}
if (mod || e.altKey || enCampoTexto(e.target)) return;
switch (k) {
case "p": case "P": e.preventDefault(); clicEn("btn-presentacion"); break;
case "t": case "T": e.preventDefault(); clicEn("btn-tema"); break;
case "f": case "F": e.preventDefault(); alternarPantallaCompleta(); break;
case "n": case "N": e.preventDefault(); WAM.abrirDemos(); break;
case "?": e.preventDefault(); WAM.abrirAtajos(); break;
case " ":
if (esInteractivo(e.target)) return; // Espacio activa el botón con foco
e.preventDefault();
clicEn("btn-reproducir");
break;
default: break;
}
}
/* ------------------------------------------------------------------
Menú «⋯» de la toolbar (ventana estrecha) y pestañas Editor / Vista
------------------------------------------------------------------ */
function cerrarMenu() {
var tb = porId("toolbar");
var mas = porId("btn-mas");
if (!tb || !tb.classList.contains("tb-menu-abierto")) return false;
tb.classList.remove("tb-menu-abierto");
if (mas) mas.setAttribute("aria-expanded", "false");
return true;
}
function enlazarMenu() {
var tb = porId("toolbar"), mas = porId("btn-mas"), menu = porId("tb-menu");
if (!tb || !mas || !menu) return;
mas.addEventListener("click", function (e) {
e.stopPropagation();
var abierto = tb.classList.toggle("tb-menu-abierto");
mas.setAttribute("aria-expanded", abierto ? "true" : "false");
if (abierto) { var b = menu.querySelector("button:not([disabled])"); if (b) b.focus(); }
});
menu.addEventListener("click", function (e) {
if (e.target.closest && e.target.closest("button")) cerrarMenu();
});
document.addEventListener("click", function (e) {
if (!tb.classList.contains("tb-menu-abierto")) return;
if (menu.contains(e.target) || mas.contains(e.target)) return;
cerrarMenu();
});
}
var ultimoIdEditado = null;
WAM.recordarEnfoque = function (id) { ultimoIdEditado = id || null; };
WAM.setVistaMovil = function (vista) {
var previa = vista === "vista";
document.body.classList.toggle("vista-movil", previa);
var te = porId("tab-editor"), tv = porId("tab-vista");
if (te) { te.setAttribute("aria-selected", previa ? "false" : "true"); te.tabIndex = previa ? -1 : 0; }
if (tv) { tv.setAttribute("aria-selected", previa ? "true" : "false"); tv.tabIndex = previa ? 0 : -1; }
lsSet(CLAVE_VISTA, previa ? "vista" : "editor");
if (previa && ultimoIdEditado && typeof WAM.enfocarEnChat === "function") {
var id = ultimoIdEditado;
requestAnimationFrame(function () { requestAnimationFrame(function () { WAM.enfocarEnChat(id); }); });
}
};
function enlazarPestanas() {
var te = porId("tab-editor"), tv = porId("tab-vista");
if (te) te.addEventListener("click", function () { WAM.setVistaMovil("editor"); });
if (tv) tv.addEventListener("click", function () { WAM.setVistaMovil("vista"); });
[te, tv].forEach(function (t) {
if (!t) return;
t.addEventListener("keydown", function (e) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
var otra = t === te ? tv : te;
if (otra) { otra.click(); otra.focus(); }
});
});
var ver = porId("btn-ver-telefono");
if (ver) ver.addEventListener("click", function () { WAM.setVistaMovil("vista"); });
var guardada = lsGet(CLAVE_VISTA);
WAM.setVistaMovil(guardada === "editor" ? "editor" : "vista");
}
// Altura real de la toolbar → variable CSS para el cálculo de escala
function observarToolbar() {
var tb = porId("toolbar");
if (!tb) return;
var fijar = function () {
document.documentElement.style.setProperty("--wam-toolbar-alto", Math.ceil(tb.getBoundingClientRect().height) + "px");
};
fijar();
if (typeof ResizeObserver === "function") new ResizeObserver(fijar).observe(tb);
else window.addEventListener("resize", fijar);
}
/* ------------------------------------------------------------------
Vista previa → tarjeta del editor
------------------------------------------------------------------ */
function editorOculto() {
return document.body.classList.contains("presentacion");
}
function abrirDesdeChat(id) {
if (!id || editorOculto() || typeof WAM.abrirTarjeta !== "function") return false;
if (esEstrecha()) WAM.setVistaMovil("editor");
return WAM.abrirTarjeta(id);
}
function enlazarChat() {
var pantalla = porId("pantalla"), cuerpo = porId("chat-cuerpo");
if (pantalla) {
// Captura: Alt+clic no dispara el flujo del botón (lo gestiona main.js)
pantalla.addEventListener("click", function (e) {
if (!e.altKey) return;
var fila = e.target.closest && e.target.closest("#chat-cuerpo .wa-row[data-id]");
if (!fila || editorOculto()) return;
e.preventDefault();
e.stopPropagation();
abrirDesdeChat(fila.getAttribute("data-id"));
}, true);
}
if (cuerpo) {
cuerpo.addEventListener("dblclick", function (e) {
if (!e.target.closest || e.target.closest("[data-flujo], a, button, video, audio, input")) return;
var fila = e.target.closest(".wa-row[data-id]");
if (!fila || editorOculto()) return;
if (window.getSelection) { try { window.getSelection().removeAllRanges(); } catch (err) { /* nada */ } }
abrirDesdeChat(fila.getAttribute("data-id"));
});
// Las altas y re-render parciales no deben releer todo el chat
cuerpo.removeAttribute("aria-live");
}
}
/* ------------------------------------------------------------------
Anuncios para lectores de pantalla (mensajes nuevos del chat)
------------------------------------------------------------------ */
function anunciar(msg) {
var reg = porId("wam-anuncios");
if (!reg || !msg) return;
var nombre = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "el negocio";
var txt = msg.texto || msg.body || msg.caption || msg.filename || msg.nombre || "";
txt = String(txt).replace(/\s+/g, " ").slice(0, 140);
reg.textContent = (msg.dir === "out" ? "Tú: " : "Nuevo mensaje de " + nombre + ": ") + (txt || "(" + (msg.tipo || "mensaje") + ")");
}
function envolverAnuncios() {
if (!porId("wam-anuncios")) {
document.body.appendChild(h("div", { id: "wam-anuncios", class: "wam-sr", "aria-live": "polite", "aria-atomic": "true" }));
}
var orig = WAM.anadirMensajeDOM;
if (typeof orig !== "function" || orig.__wamAnuncio) return;
var envuelta = function (msg) {
var r = orig.apply(this, arguments);
try { anunciar(msg); } catch (e) { /* nunca rompe el flujo */ }
return r;
};
envuelta.__wamAnuncio = true;
WAM.anadirMensajeDOM = envuelta;
}
/* ------------------------------------------------------------------
Estado de los botones de la toolbar
------------------------------------------------------------------ */
function sincronizarTema() {
var b = porId("btn-tema");
if (b) b.setAttribute("aria-pressed", document.body.getAttribute("data-tema") === "oscuro" ? "true" : "false");
}
/* ------------------------------------------------------------------
Inicialización (una sola vez)
------------------------------------------------------------------ */
WAM.initUX = function () {
if (iniciado) return;
iniciado = true;
// Sustituye al aviso genérico: indicador rojo en la toolbar + aviso con «Exportar JSON»
WAM.onErrorGuardar = function (texto) { alErrorGuardar(texto); };
document.addEventListener("keydown", alTecla);
var bd = porId("btn-deshacer"), br = porId("btn-rehacer"), bdemos = porId("btn-demos"), bat = porId("btn-atajos");
if (bd) bd.addEventListener("click", function () { WAM.deshacer(); });
if (br) br.addEventListener("click", function () { WAM.rehacer(); });
if (bdemos) bdemos.addEventListener("click", function () { WAM.abrirDemos(); });
if (bat) bat.addEventListener("click", function () { WAM.abrirAtajos(); });
pintarBotonesHistorial();
enlazarMenu();
enlazarPestanas();
observarToolbar();
enlazarChat();
envolverAnuncios();
sincronizarTema();
if (typeof MutationObserver === "function") {
new MutationObserver(sincronizarTema).observe(document.body, { attributes: true, attributeFilter: ["data-tema"] });
}
// Estado inicial del indicador y refresco del «hace N s»
ultimoGuardado = Date.now();
pintarGuardado("Guardado", "ok", "Guardado en este navegador");
setInterval(pintarRelativo, 5000);
};
})();
+62
View File
@@ -0,0 +1,62 @@
# Servidor del sitio estático. Se copia a /etc/nginx/conf.d/default.conf.
# TLS lo termina Traefik (Coolify); aquí solo HTTP en el 80.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
charset utf-8;
server_tokens off;
# Compresión
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_types text/plain text/css text/xml application/javascript
application/json application/xml image/svg+xml;
# Cabeceras básicas de seguridad
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
# Healthcheck (Docker HEALTHCHECK / Coolify)
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# HTML: siempre revalidar.
location = /index.html {
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
}
# CSS/JS con nombre fijo (sin hash): revalidar con ETag en cada carga.
# No usar "immutable": Cloudflare serviría la versión vieja tras un deploy.
location ~* \.(?:css|js)$ {
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404;
}
# Imágenes (docs/capturas): caché de 1 día, sin immutable.
location ~* \.(?:png|jpe?g|gif|webp|avif|svg|ico)$ {
add_header Cache-Control "public, max-age=86400" always;
add_header X-Content-Type-Options "nosniff" always;
access_log off;
try_files $uri =404;
}
location / {
try_files $uri $uri/ =404;
}
}
+107
View File
@@ -0,0 +1,107 @@
// Arranque, render de los 13 tipos de la demo, consola limpia y file://.
import { test, expect, irA, estado, filasChat, filaChat, tarjetas, URL_FILE, TIPOS } from "./fixtures.mjs";
test.describe("Carga inicial", () => {
test("la demo carga sin errores de consola ni red externa", async ({ app }) => {
await expect(app).toHaveTitle(/WhatsApp Cloud API/);
await expect(app.locator("#chat-nombre")).toHaveText("Café Aurora");
await expect(app.locator("#chat-verificado")).toBeVisible();
await expect(app.locator("#chat-subtitulo")).toHaveText("en línea");
// «Hoy» + aviso de cifrado
await expect(app.locator("#chat-cuerpo > li.wa-sistema")).toHaveCount(2);
await expect(app.locator(".wa-fecha")).toHaveText("Hoy");
await expect(app.locator(".wa-aviso")).toContainText("servicio seguro de Meta");
// El fixture comprueba al final que no hubo errores ni peticiones externas
});
test("la demo por defecto recorre los 13 tipos, uno por burbuja", async ({ app }) => {
const e = await estado(app);
const tipos = new Set(e.mensajes.map((m) => m.tipo));
for (const t of TIPOS) expect(tipos, `tipo ${t}`).toContain(t);
await expect(filasChat(app)).toHaveCount(e.mensajes.length);
await expect(tarjetas(app)).toHaveCount(e.mensajes.length);
const porTipo = {};
for (const m of e.mensajes) porTipo[m.tipo] ??= m; // el primero de cada tipo
const fila = (t) => filaChat(app, porTipo[t].id);
await expect(fila("text").locator(".wa-texto")).toContainText("Bienvenido a Café Aurora");
await expect(fila("image").locator("img.wa-media")).toBeVisible();
await expect(fila("image").locator(".wa-caption")).toContainText("Nuevo horario");
await expect(fila("video").locator(".wa-play-badge")).toBeVisible();
await expect(fila("audio").locator(".audio-dur")).toHaveText("0:12");
await expect(fila("audio").locator(".audio-mic")).toBeVisible(); // nota de voz
await expect(fila("document").locator(".doc-nombre")).toHaveText("carta-cafe-aurora.pdf");
await expect(fila("document").locator(".doc-sub")).toContainText("PDF", { ignoreCase: true });
await expect(fila("sticker").locator(".sticker-img")).toBeVisible();
await expect(fila("sticker").locator(".bubble")).toHaveCount(0); // sin burbuja
await expect(fila("location").locator(".loc-nombre")).toHaveText("Café Aurora");
await expect(fila("location").locator(".loc-dir")).toContainText("Calle Mayor");
await expect(fila("contact").locator(".contact-nombre")).toHaveText("Luna Mendoza");
await expect(fila("contact").locator(".contact-accion")).toHaveText(["Mensaje", "Guardar contacto"]);
await expect(fila("template").locator(".tpl-media")).toBeVisible();
await expect(fila("template").locator(".qr-btn")).toHaveCount(2);
await expect(fila("template").locator(".template-footer")).toHaveText("Café Aurora · Reservas");
await expect(fila("buttons").locator("button.qr-btn")).toHaveCount(3);
await expect(fila("list").locator("button.qr-btn")).toHaveText("Ver opciones");
await expect(fila("cta_url").locator("a.qr-btn")).toHaveAttribute("href", "https://cafeaurora.example/tienda");
await expect(fila("cta_url").locator("a.qr-btn")).toHaveAttribute("target", "_blank");
await expect(fila("carousel").locator(".carousel .card")).toHaveCount(3);
});
test("solo los mensajes salientes llevan ticks; la cola va en el último del grupo", async ({ app }) => {
const e = await estado(app);
for (const m of e.mensajes) {
const ticks = filaChat(app, m.id).locator(":scope > * .meta .tick");
if (m.dir === "out") await expect(ticks.first()).toBeVisible();
else await expect(ticks).toHaveCount(0);
}
const out = e.mensajes.find((m) => m.dir === "out" && m.estado === "read");
await expect(filaChat(app, out.id).locator(".tick-read")).toHaveCount(1);
// m1 (in) y m2 (out) son grupos de uno → ambos .grupo.ultimo
await expect(filaChat(app, "m1")).toHaveClass(/grupo/);
await expect(filaChat(app, "m1")).toHaveClass(/ultimo/);
await expect(filaChat(app, "m3")).toHaveClass(/grupo/);
await expect(filaChat(app, "m3")).not.toHaveClass(/ultimo/);
});
test("el carrusel se desplaza en horizontal", async ({ app }) => {
const e = await estado(app);
const id = e.mensajes.find((m) => m.tipo === "carousel").id;
const car = filaChat(app, id).locator(".carousel");
await car.scrollIntoViewIfNeeded();
const { ancho, visible } = await car.evaluate((el) => ({ ancho: el.scrollWidth, visible: el.clientWidth }));
expect(ancho).toBeGreaterThan(visible);
await car.evaluate((el) => { el.scrollLeft = 200; });
expect(await car.evaluate((el) => el.scrollLeft)).toBeGreaterThan(0);
});
test("el marco del iPhone tiene isla, status bar e indicador de inicio", async ({ app }) => {
await expect(app.locator("#iphone")).toBeVisible();
await expect(app.locator("#isla")).toBeVisible();
await expect(app.locator("#statusbar .sb-hora")).toHaveText("9:41");
await expect(app.locator("#home-indicator")).toBeVisible();
const caja = await app.locator("#iphone").boundingBox();
// proporción 393×852 (con tolerancia por el escalado)
expect(caja.height / caja.width).toBeGreaterThan(2.0);
expect(caja.height / caja.width).toBeLessThan(2.3);
});
});
test.describe("Apertura por file://", () => {
test("index.html funciona abierto como archivo local", async ({ page }) => {
await irA(page, URL_FILE);
expect(page.url()).toMatch(/^file:\/\//);
await expect(page.locator("#chat-nombre")).toHaveText("Café Aurora");
const n = (await estado(page)).mensajes.length;
await expect(filasChat(page)).toHaveCount(n);
await expect(tarjetas(page)).toHaveCount(n);
// interacción básica: composer y persistencia
await page.locator("#composer-input").fill("Hola desde file://");
await page.locator("#composer-input").press("Enter");
await expect(page.locator("#chat-cuerpo .wa-row.out").last()).toContainText("Hola desde file://");
await page.locator("#btn-tema").click();
await expect(page.locator("body")).toHaveAttribute("data-tema", "oscuro");
await page.reload();
await expect(page.locator("body")).toHaveAttribute("data-tema", "oscuro");
});
});
+110
View File
@@ -0,0 +1,110 @@
// «Reproducir demo»: revelado paso a paso, detener, velocidad y autoPulsar.
import { test, expect, estado, filasChat, cargarEstado, rapido } from "./fixtures.mjs";
const btn = (page) => page.locator("#btn-reproducir");
const GUION_CORTO = {
version: 1, tema: "claro",
cuenta: { nombre: "Demo corta", verificado: false },
mensajes: [
{ id: "m1", dir: "in", tipo: "text", hora: "10:00", texto: "Hola" },
{ id: "m2", dir: "out", tipo: "text", hora: "10:01", texto: "Buenas", estado: "read" },
{
id: "m3", dir: "in", tipo: "buttons", hora: "10:02", body: "¿Qué quieres?", autoPulsar: 1,
botones: [
{ texto: "Precios", respuestas: [] },
{ texto: "Horario", respuestas: [{ id: "m4", dir: "in", tipo: "text", hora: "10:02", texto: "Abrimos a las 9" }] },
],
},
],
};
test.describe("Reproducir demo", () => {
test("vacía el chat, revela los mensajes uno a uno y termina sola", async ({ app }) => {
await cargarEstado(app, GUION_CORTO);
await rapido(app);
// al arrancar el chat se vacía al instante (solo quedan los avisos de sistema)
const filasTrasClic = await app.evaluate(() => {
document.getElementById("btn-reproducir").click();
return document.querySelectorAll("#chat-cuerpo > li.wa-row").length;
});
expect(filasTrasClic).toBe(0);
await expect(btn(app)).toHaveAttribute("aria-pressed", "true");
await expect(btn(app)).toContainText("Detener");
// «escribiendo…» antes del primer IN
await expect(app.locator("#chat-escribiendo")).toBeVisible();
await expect(filasChat(app)).toHaveCount(1, { timeout: 3000 });
await expect(filasChat(app).nth(0)).toContainText("Hola");
await expect(filasChat(app)).toHaveCount(3, { timeout: 5000 });
// autoPulsar: pulsa sola «Horario» y llega su respuesta
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"]')).toContainText("Horario", { timeout: 5000 });
await expect(app.locator('#chat-cuerpo li.in[data-id^="s"]')).toContainText("Abrimos a las 9", { timeout: 6000 });
await expect.poll(() => app.evaluate(() => WAM.demoActiva()), { timeout: 10000 }).toBe(false);
await expect(btn(app)).toContainText("Reproducir demo");
await expect(btn(app)).toHaveAttribute("aria-pressed", "false");
});
test("pulsar de nuevo la detiene y vuelve al guion completo", async ({ app }) => {
const n = (await estado(app)).mensajes.length;
await btn(app).click();
await expect.poll(() => filasChat(app).count(), { timeout: 4000 }).toBeGreaterThan(0);
await app.waitForTimeout(400); // evita el antirrebote de doble clic
await btn(app).click();
await expect(btn(app)).toContainText("Reproducir demo");
await expect(filasChat(app)).toHaveCount(n);
expect(await app.evaluate(() => WAM.demoActiva())).toBe(false);
// nada sigue apareciendo después de detener
await app.waitForTimeout(1500);
await expect(filasChat(app)).toHaveCount(n);
});
test("un doble clic no arranca y detiene en el mismo gesto", async ({ app }) => {
await btn(app).dblclick();
expect(await app.evaluate(() => WAM.demoActiva())).toBe(true);
await app.waitForTimeout(400);
await btn(app).click();
});
test("la barra espaciadora (fuera de campos) alterna la demo", async ({ app }) => {
await app.locator("#iphone").click({ position: { x: 5, y: 400 } });
await app.keyboard.press("Space");
await expect.poll(() => app.evaluate(() => WAM.demoActiva())).toBe(true);
await app.waitForTimeout(400);
await app.keyboard.press("Space");
await expect.poll(() => app.evaluate(() => WAM.demoActiva())).toBe(false);
});
test("la velocidad se elige en el selector y persiste", async ({ app }) => {
await app.locator("#sel-velocidad").selectOption("2");
expect(await app.evaluate(() => WAM.getVelocidad())).toBe(2);
await app.reload();
await expect(app.locator("#sel-velocidad")).toHaveValue("2");
await app.locator("#sel-velocidad").selectOption("0.5");
expect(await app.evaluate(() => WAM.getVelocidad())).toBe(0.5);
});
test("a 2x la demo es más rápida que a 0,5x", async ({ app }) => {
await cargarEstado(app, { ...GUION_CORTO, mensajes: GUION_CORTO.mensajes.slice(0, 2) });
const medir = async (v) => {
await app.evaluate((x) => WAM.setVelocidad(x), v);
const t0 = Date.now();
await btn(app).click();
await expect.poll(() => app.evaluate(() => WAM.demoActiva()), { timeout: 15000, intervals: [50] }).toBe(false);
await app.waitForTimeout(400);
return Date.now() - t0;
};
const lenta = await medir(0.5);
const rapida = await medir(2);
expect(rapida).toBeLessThan(lenta);
});
test("editar el guion durante la demo la cancela sin errores", async ({ app }) => {
await btn(app).click();
await expect.poll(() => filasChat(app).count(), { timeout: 4000 }).toBeGreaterThan(0);
await app.locator('#nuevo-tipo [data-tipo="text"]').click();
await expect.poll(() => app.evaluate(() => WAM.demoActiva())).toBe(false);
const n = (await estado(app)).mensajes.length;
await expect(filasChat(app)).toHaveCount(n);
await expect(btn(app)).toContainText("Reproducir demo");
});
});
+351
View File
@@ -0,0 +1,351 @@
// Campos del editor: contadores, barra de formato, variables, subida de archivos
// (input y arrastrar-soltar), cuenta, sub-editores y validación.
import {
test, expect, estado, vaciar, anadirTipo, campo, filaChat, tarjeta, abrirTarjeta, irA, PNG_1PX,
} from "./fixtures.mjs";
const contadorDe = (page, ruta) =>
page.locator(".ed-campo", { has: page.locator(`[data-ruta="${ruta}"]`) }).first().locator(".ed-contador");
/** Selecciona [ini, fin) en un input/textarea. */
async function seleccionar(loc, ini, fin) {
await loc.evaluate((el, [a, b]) => { el.focus(); el.setSelectionRange(a, b); }, [ini, fin]);
}
/** Suelta un archivo sobre un elemento (drag & drop del sistema). */
async function soltarArchivo(page, destino, { nombre, tipo, base64 }) {
const dt = await page.evaluateHandle(({ nombre, tipo, base64 }) => {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const d = new DataTransfer();
d.items.add(new File([bytes], nombre, { type: tipo }));
return d;
}, { nombre, tipo, base64 });
await destino.dispatchEvent("dragenter", { dataTransfer: dt });
await destino.dispatchEvent("dragover", { dataTransfer: dt });
await destino.dispatchEvent("drop", { dataTransfer: dt });
}
test.describe("Contadores de límites", () => {
test("n / límite, ámbar al 90 % y rojo al pasarse sin cortar la escritura", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "buttons");
const ruta = "mensajes.0.botones.0.texto";
const cont = contadorDe(app, ruta);
await campo(app, ruta).fill("Hola");
await expect(cont).toHaveText("4 / 20");
await expect(cont).not.toHaveClass(/ed-contador-(alerta|over)/);
await campo(app, ruta).fill("x".repeat(18));
await expect(cont).toHaveText("18 / 20");
await expect(cont).toHaveClass(/ed-contador-alerta/);
await campo(app, ruta).fill("x".repeat(23));
await expect(cont).toHaveText("23 / 20");
await expect(cont).toHaveClass(/ed-contador-over/);
await expect(campo(app, ruta)).toHaveValue("x".repeat(23));
// la tarjeta avisa de que la Cloud API lo rechazaría
await expect(tarjeta(app, 0).locator(".ed-chip-aviso")).toBeVisible();
await expect(tarjeta(app, 0).locator(".ed-validacion")).toContainText("rechazaría");
await expect(tarjeta(app, 0).locator(".ed-validacion")).toContainText("supera 20");
await expect(app.locator("#ed-avisos-global")).toBeVisible();
});
test("el cuerpo de texto cuenta hasta 4096", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
await campo(app, "mensajes.0.texto").fill("abc");
await expect(contadorDe(app, "mensajes.0.texto")).toHaveText("3 / 4096");
});
});
test.describe("Barra de formato y variables", () => {
for (const [boton, marca, etiqueta] of [
["ed-fmt-negrita", "*", "strong"], ["ed-fmt-cursiva", "_", "em"],
["ed-fmt-tachado", "~", "del"], ["ed-fmt-mono", "`", "code"],
]) {
test(`${boton} envuelve la selección con ${marca}`, async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "text");
const ta = campo(app, "mensajes.0.texto");
await ta.fill("Hola mundo feliz");
await seleccionar(ta, 5, 10);
await tarjeta(app, 0).locator(`.ed-fmtbar .${boton}`).click();
await expect(ta).toHaveValue(`Hola ${marca}mundo${marca} feliz`);
// el cursor mantiene la palabra seleccionada
const sel = await ta.evaluate((el) => [el.selectionStart, el.selectionEnd]);
expect(sel).toEqual([6, 11]);
await expect(filaChat(app, id).locator(`.wa-texto ${etiqueta}`)).toHaveText("mundo");
});
}
test("sin selección inserta el par de marcas con el cursor en medio", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
const ta = campo(app, "mensajes.0.texto");
await ta.fill("ab");
await seleccionar(ta, 1, 1);
await tarjeta(app, 0).locator(".ed-fmtbar .ed-fmt-negrita").click();
await expect(ta).toHaveValue("a**b");
expect(await ta.evaluate((el) => [el.selectionStart, el.selectionEnd])).toEqual([2, 2]);
});
test("la barra de formato existe en plantilla y lista", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "template");
await anadirTipo(app, "list");
await expect(tarjeta(app, 0).locator(".ed-fmtbar").first()).toBeVisible();
await expect(tarjeta(app, 1).locator(".ed-fmtbar").first()).toBeVisible();
});
test("«+ Añadir variable» inserta {{n}} siguiente en el cursor", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "template");
const body = campo(app, "mensajes.0.body");
await body.fill("Hola {{1}}, tu pedido");
await seleccionar(body, 21, 21);
const btnVar = app.locator(".ed-campo", { has: app.locator('[data-ruta="mensajes.0.body"]') }).locator('[data-acc="var"]');
await btnVar.click();
await expect(body).toHaveValue("Hola {{1}}, tu pedido{{2}}");
await btnVar.click();
await expect(body).toHaveValue("Hola {{1}}, tu pedido{{2}}{{3}}");
await expect(filaChat(app, id).locator(".tpl-body")).toHaveText("Hola {{1}}, tu pedido{{2}}{{3}}");
});
});
test.describe("Subida de archivos", () => {
test("imagen por archivo: dataURL en el chat y persiste tras recargar", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "image");
const t = tarjeta(app, 0);
await t.locator('input[type="file"]').first().setInputFiles({ name: "foto.png", mimeType: "image/png", buffer: PNG_1PX });
await expect.poll(async () => (await estado(app)).mensajes[0].src).toMatch(/^data:image\/png;base64,/);
await expect(filaChat(app, id).locator("img.wa-media")).toHaveAttribute("src", /^data:image\/png;base64,/);
await expect(t.locator(".ed-media-vista").first()).toBeVisible(); // miniatura en el editor
await app.reload();
await expect(filaChat(app, id).locator("img.wa-media")).toHaveAttribute("src", /^data:image\/png;base64,/);
});
test("clic en la zona de soltado abre el selector de archivos", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "sticker");
const [chooser] = await Promise.all([
app.waitForEvent("filechooser"),
tarjeta(app, 0).locator('[data-acc="drop-clic"]').first().click(),
]);
await chooser.setFiles({ name: "sticker.png", mimeType: "image/png", buffer: PNG_1PX });
await expect.poll(async () => (await estado(app)).mensajes[0].src).toMatch(/^data:image\/png/);
});
test("arrastrar y soltar un archivo en la zona lo carga", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "carousel");
const zona = tarjeta(app, 0).locator('.ed-dropzona[data-media-ruta="mensajes.0.cards.1.media"]');
await zona.scrollIntoViewIfNeeded();
await soltarArchivo(app, zona, { nombre: "card.png", tipo: "image/png", base64: PNG_1PX.toString("base64") });
await expect.poll(async () => (await estado(app)).mensajes[0].cards[1].media).toMatch(/^data:image\/png/);
await expect(filaChat(app, id).locator(".card").nth(1).locator("img.card-media")).toHaveAttribute("src", /^data:image\/png/);
});
test("soltar un archivo fuera de una zona no navega ni cambia nada", async ({ app }) => {
const antes = await estado(app);
const url = app.url();
await soltarArchivo(app, app.locator("#lista-mensajes"), { nombre: "x.png", tipo: "image/png", base64: PNG_1PX.toString("base64") });
expect(app.url()).toBe(url);
expect((await estado(app)).mensajes.length).toBe(antes.mensajes.length);
});
test("documento: subir archivo mantiene el nombre con extensión", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "document");
await campo(app, "mensajes.0.filename").fill("contrato.docx");
await expect(filaChat(app, id).locator(".doc-nombre")).toHaveText("contrato.docx");
await expect(filaChat(app, id).locator(".doc-sub")).toContainText(/docx/i);
});
});
test.describe("Cuenta", () => {
test("nombre, subtítulo y verificado se reflejan en la cabecera del chat", async ({ app }) => {
await app.locator("#bloque-cuenta > summary").click();
await app.locator("#cuenta-nombre").fill("Óptica Luz");
await expect(app.locator("#chat-nombre")).toHaveText("Óptica Luz");
await expect(app.locator("#cuenta-resumen")).toContainText("Óptica Luz");
await app.locator("#cuenta-subtitulo").fill("responde en minutos");
await expect(app.locator("#chat-subtitulo")).toHaveText("responde en minutos");
await expect(app.locator("#chat-verificado")).toBeVisible();
await app.locator("#cuenta-verificado").uncheck({ force: true });
await expect(app.locator("#chat-verificado")).toBeHidden();
expect((await estado(app)).cuenta).toMatchObject({ nombre: "Óptica Luz", subtitulo: "responde en minutos", verificado: false });
});
test("avatar por archivo, por URL y quitar", async ({ app }) => {
await app.locator("#bloque-cuenta > summary").click();
await app.locator("#cuenta-avatar-archivo").setInputFiles({ name: "logo.png", mimeType: "image/png", buffer: PNG_1PX });
await expect(app.locator("#chat-avatar")).toHaveAttribute("src", /^data:image\/png/);
await expect(app.locator("#cuenta-avatar-vista")).toHaveAttribute("src", /^data:image\/png/);
await app.locator('#editor-cuenta [data-acc="avatar-quitar"]').click();
await expect(app.locator("#chat-avatar")).toHaveAttribute("src", /^data:image\/svg\+xml/);
expect((await estado(app)).cuenta.avatar).toBe("");
const dataUrl = "data:image/png;base64," + PNG_1PX.toString("base64");
const url = app.locator("#cuenta-avatar-url");
if (!(await url.isVisible())) await app.locator('#editor-cuenta label.ed-radio:has(input[value="url"])').click();
await url.fill(dataUrl);
await expect(app.locator("#chat-avatar")).toHaveAttribute("src", dataUrl);
});
});
test.describe("Sub-editores de botones, secciones, filas, tarjetas y respuestas", () => {
test("botones rápidos: añadir hasta 3 (máximo de la API) y borrar", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "buttons");
const add = tarjeta(app, 0).locator('[data-acc="add-boton"]');
await add.click();
await add.click();
await expect(filaChat(app, id).locator("button.qr-btn")).toHaveCount(3);
if (await add.isEnabled()) await add.click();
expect((await estado(app)).mensajes[0].botones).toHaveLength(3);
await tarjeta(app, 0).locator('[data-acc="del-boton"]').nth(1).click();
await expect(filaChat(app, id).locator("button.qr-btn")).toHaveCount(2);
});
test("plantilla con >3 botones: 2 visibles + «Ver todas las opciones»", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "template");
const add = tarjeta(app, 0).locator('[data-acc="add-boton"]').first();
for (let k = 0; k < 3; k++) await add.click();
expect((await estado(app)).mensajes[0].botones).toHaveLength(4);
const fila = filaChat(app, id);
await expect(fila.locator(".qr-btn:visible")).toHaveCount(3);
await expect(fila.locator("[data-ver-todas]")).toHaveText(/Ver todas las opciones/);
await fila.locator("[data-ver-todas]").click();
await expect(app.locator("#sheet .sheet-titulo")).toHaveText("Todas las opciones");
await expect(app.locator("#sheet [data-opcion]")).toHaveCount(4);
});
test("plantilla: botón con URL se pinta como enlace", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "template");
await campo(app, "mensajes.0.botones.0.url").fill("https://example.org/pago");
const a = filaChat(app, id).locator("a.qr-btn");
await expect(a).toHaveAttribute("href", "https://example.org/pago");
await expect(filaChat(app, id).locator("button.qr-btn[data-btn]")).toHaveCount(0);
});
test("lista: añadir sección y fila", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "list");
await tarjeta(app, 0).locator('[data-acc="add-fila"]').first().click();
await tarjeta(app, 0).locator('[data-acc="add-sec"]').click();
const e = await estado(app);
expect(e.mensajes[0].secciones).toHaveLength(2);
expect(e.mensajes[0].secciones[0].filas).toHaveLength(2);
await campo(app, "mensajes.0.secciones.1.titulo").fill("Extras");
await campo(app, "mensajes.0.secciones.1.filas.0.titulo").fill("Envío exprés");
await filaChat(app, e.mensajes[0].id).locator("button.qr-btn").click();
await expect(app.locator("#sheet .sheet-seccion").nth(1)).toHaveText("Extras");
await expect(app.locator("#sheet .sheet-fila-titulo").last()).toHaveText("Envío exprés");
});
test("carrusel: añadir y borrar tarjetas (2–5)", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "carousel");
const add = tarjeta(app, 0).locator('[data-acc="add-card"]');
await add.click();
await expect(filaChat(app, id).locator(".card")).toHaveCount(3);
await tarjeta(app, 0).locator('[data-acc="del-card"]').first().click();
await expect(filaChat(app, id).locator(".card")).toHaveCount(2);
// no baja de 2
await tarjeta(app, 0).locator('[data-acc="del-card"]').first().click();
expect((await estado(app)).mensajes[0].cards.length).toBeGreaterThanOrEqual(2);
});
test("respuestas encadenadas: añadir, editar, cambiar de tipo y borrar", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "buttons");
const t = tarjeta(app, 0);
await expect(t.locator(".ed-chip-flujo")).toHaveClass(/ed-chip-flujo-incompleto/);
await t.locator('[data-acc="add-resp"][data-ruta="mensajes.0.botones.0.respuestas"]').click();
const ta = campo(app, "mensajes.0.botones.0.respuestas.0.texto");
await expect(ta).toBeFocused();
await ta.fill("Respuesta encadenada");
await expect(t.locator(".ed-chip-flujo")).not.toHaveClass(/ed-chip-flujo-incompleto/);
let r = (await estado(app)).mensajes[0].botones[0].respuestas;
expect(r).toHaveLength(1);
expect(r[0]).toMatchObject({ dir: "in", tipo: "text", texto: "Respuesta encadenada" });
await t.locator('select[data-rol="tipo-resp"]').first().selectOption("image");
await expect.poll(async () => (await estado(app)).mensajes[0].botones[0].respuestas[0].tipo).toBe("image");
r = (await estado(app)).mensajes[0].botones[0].respuestas;
expect(r).toHaveLength(1);
expect(r[0]).toMatchObject({ dir: "in", tipo: "image", src: "" });
await expect(t.locator('[data-ruta="mensajes.0.botones.0.respuestas.0.caption"]')).toBeVisible();
await t.locator('[data-acc="del-resp"]').first().click();
r = (await estado(app)).mensajes[0].botones[0].respuestas;
expect(r).toHaveLength(0);
});
test("autoPulsar se elige en el editor y se guarda como índice", async ({ app }) => {
const e = await estado(app);
const i = e.mensajes.findIndex((m) => m.tipo === "buttons");
await abrirTarjeta(app, i);
const sel = campo(app, `mensajes.${i}.autoPulsar`);
const valor = await sel.locator("option").nth(2).getAttribute("value");
await sel.selectOption(valor);
await expect.poll(async () => (await estado(app)).mensajes[i].autoPulsar).toBe(Number(valor));
await app.reload();
expect((await estado(app)).mensajes[i].autoPulsar).toBe(Number(valor));
// «vacío» quita la pulsación automática
await abrirTarjeta(app, i);
await campo(app, `mensajes.${i}.autoPulsar`).selectOption("");
await expect.poll(async () => "autoPulsar" in (await estado(app)).mensajes[i]).toBe(false);
});
});
test.describe("Validación", () => {
test("ubicación con latitud fuera de rango y contacto sin nombre muestran avisos", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "location");
await campo(app, "mensajes.0.latitud").fill("123");
await expect(tarjeta(app, 0).locator(".ed-validacion")).toContainText("Latitud no válida");
await anadirTipo(app, "contact");
await campo(app, "mensajes.1.nombre").fill("");
await expect(tarjeta(app, 1).locator(".ed-validacion")).toContainText("necesita un nombre");
await expect(app.locator("#ed-avisos-global")).toBeVisible();
await app.locator("#ed-avisos-global").click();
await expect(tarjeta(app, 0)).toHaveClass(/ed-abierta/);
});
test("la demo de ejemplo no tiene avisos de validación", async ({ app }) => {
await expect(app.locator("#lista-mensajes .ed-chip-aviso")).toHaveCount(0);
await expect(app.locator("#ed-avisos-global")).toBeHidden();
});
});
test.describe("Persistencia", () => {
test("F5 conserva todo el estado editado", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
await campo(app, "mensajes.0.texto").fill("Persistente");
await anadirTipo(app, "cta_url");
const antes = await estado(app);
await app.reload();
await expect(app.locator("#lista-mensajes > .ed-tarjeta")).toHaveCount(2);
expect(await estado(app)).toEqual(antes);
await expect(filaChat(app, antes.mensajes[0].id)).toContainText("Persistente");
});
test("el indicador de guardado pasa por «Guardando…» y vuelve a «Guardado»", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
await campo(app, "mensajes.0.texto").fill("x");
await expect(app.locator("#estado-guardado")).toHaveAttribute("data-estado", "ok", { timeout: 4000 });
await expect(app.locator("#estado-guardado .tb-guardado-txt")).toContainText("Guardado");
});
test("un localStorage corrupto no rompe el arranque: carga la demo", async ({ page }) => {
await page.addInitScript(() => {
if (!sessionStorage.getItem("sembrado")) {
localStorage.setItem("wam-mockup-v1", "{esto no es json");
sessionStorage.setItem("sembrado", "1");
}
});
await irA(page, "/index.html");
await expect(page.locator("#chat-nombre")).toHaveText("Café Aurora");
});
});
+159
View File
@@ -0,0 +1,159 @@
// Operaciones sobre la lista de mensajes: reordenar (flechas, Alt+↑/↓ y drag & drop),
// duplicar, borrar con deshacer, insertar debajo, plegar y horas en secuencia.
import {
test, expect, estado, tarjeta, arrastrar, accion, tarjetas, filasChat, abrirTarjeta, anadirTipo, vaciar, cargarEstado,
} from "./fixtures.mjs";
const ids = async (page) => (await estado(page)).mensajes.map((m) => m.id);
const idsChat = (page) => filasChat(page).evaluateAll((els) => els.map((e) => e.dataset.id));
test.describe("Lista de mensajes", () => {
test("↑/↓ reordena el guion, las tarjetas y el chat", async ({ app }) => {
const antes = await ids(app);
await accion(app, 0, "mover", 1);
const tras = await ids(app);
expect(tras.slice(0, 2)).toEqual([antes[1], antes[0]]);
expect(await idsChat(app)).toEqual(tras);
await expect(tarjeta(app, 0)).toHaveAttribute("data-id", antes[1]);
// la primera no puede subir y la última no puede bajar
await expect(tarjeta(app, 0).locator('[data-acc="mover"][data-delta="-1"]')).toBeDisabled();
const n = antes.length;
await expect(tarjeta(app, n - 1).locator('[data-acc="mover"][data-delta="1"]')).toBeDisabled();
await accion(app, 1, "mover", -1);
expect(await ids(app)).toEqual(antes);
});
test("Alt+↓ mueve el mensaje con el foco", async ({ app }) => {
const antes = await ids(app);
await tarjeta(app, 0).locator(".ed-cab-principal").focus();
await app.keyboard.press("Alt+ArrowDown");
await expect.poll(() => ids(app)).toEqual([antes[1], antes[0], ...antes.slice(2)]);
});
test("drag & drop por el asa reordena los mensajes", async ({ app }) => {
const antes = await ids(app);
const asa = tarjeta(app, 0).locator(":scope > .ed-tarjeta-cab .ed-asa");
const destino = tarjeta(app, 3).locator(":scope > .ed-tarjeta-cab");
// soltar en la mitad inferior de la 4.ª tarjeta → queda en la posición 3 (0-based)
await arrastrar(app, asa, destino, { abajo: true });
const tras = await ids(app);
expect(tras).toEqual([antes[1], antes[2], antes[3], antes[0], ...antes.slice(4)]);
expect(await idsChat(app)).toEqual(tras);
});
test("drag & drop reordena los botones dentro de un mensaje", async ({ app }) => {
const e = await estado(app);
const i = e.mensajes.findIndex((m) => m.tipo === "buttons");
const textos = e.mensajes[i].botones.map((b) => b.texto);
const t = await abrirTarjeta(app, i);
const items = t.locator(`[data-drag-ruta="mensajes.${i}.botones"]`);
await expect(items).toHaveCount(3);
await arrastrar(app, items.nth(2).locator(".ed-asa").first(), items.nth(0));
await expect.poll(async () => (await estado(app)).mensajes[i].botones.map((b) => b.texto))
.toEqual([textos[2], textos[0], textos[1]]);
const id = e.mensajes[i].id;
await expect(app.locator(`#chat-cuerpo li[data-id="${id}"] .qr-btn .btn-label`)).toHaveText([textos[2], textos[0], textos[1]]);
});
test("duplicar crea un clon justo debajo con IDs nuevos (también en respuestas)", async ({ app }) => {
const e = await estado(app);
const i = e.mensajes.findIndex((m) => m.tipo === "template");
const n = e.mensajes.length;
await accion(app, i, "dup");
await expect(tarjetas(app)).toHaveCount(n + 1);
const e2 = await estado(app);
const orig = e2.mensajes[i], clon = e2.mensajes[i + 1];
expect(clon.id).not.toBe(orig.id);
expect(clon.body).toBe(orig.body);
expect(clon.botones[0].respuestas[0].id).not.toBe(orig.botones[0].respuestas[0].id);
// todos los IDs (incluidos los anidados) son únicos
const todos = [];
const recorrer = (m) => {
todos.push(m.id);
for (const b of m.botones || []) (b.respuestas || []).forEach(recorrer);
for (const s of m.secciones || []) for (const f of s.filas || []) (f.respuestas || []).forEach(recorrer);
for (const c of m.cards || []) (c.respuestas || []).forEach(recorrer);
(m.respuestas || []).forEach(recorrer);
};
e2.mensajes.forEach(recorrer);
expect(new Set(todos).size).toBe(todos.length);
await expect(filasChat(app)).toHaveCount(n + 1);
});
test("borrar quita el mensaje sin confirm() y «Deshacer» del aviso lo recupera", async ({ app }) => {
let dialogos = 0;
app.on("dialog", (d) => { dialogos++; d.dismiss(); });
const antes = await ids(app);
await accion(app, 2, "borrar");
expect(await ids(app)).toEqual(antes.filter((_, k) => k !== 2));
await expect(filasChat(app)).toHaveCount(antes.length - 1);
const toast = app.locator("#ed-toasts .ed-toast", { hasText: "Mensaje borrado" });
await expect(toast).toBeVisible();
await toast.locator(".ed-toast-accion").click();
await expect.poll(() => ids(app)).toEqual(antes);
await expect(filasChat(app)).toHaveCount(antes.length);
expect(dialogos).toBe(0);
});
test("«+» de una tarjeta hace que el siguiente alta se inserte debajo", async ({ app }) => {
const antes = await ids(app);
await accion(app, 1, "insertar-debajo");
await expect(app.locator("#ed-destino-alta")).toContainText("debajo del n.º 2");
const id = await anadirTipo(app, "text");
const tras = await ids(app);
expect(tras.indexOf(id)).toBe(2);
expect(tras.filter((x) => x !== id)).toEqual(antes);
expect(await idsChat(app)).toEqual(tras);
});
test("con una tarjeta abierta el alta va debajo; «Al final» lo restablece", async ({ app }) => {
await abrirTarjeta(app, 0);
await expect(app.locator("#ed-destino-alta")).toContainText("debajo del n.º 1");
await app.locator('#ed-destino-alta [data-acc="destino-final"]').click();
await expect(app.locator("#ed-destino-alta")).toHaveText("Se añade al final");
const id = await anadirTipo(app, "text");
const tras = await ids(app);
expect(tras[tras.length - 1]).toBe(id);
});
test("«+ Otro» añade otra vez el último tipo usado", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "location");
await expect(app.locator("#btn-anadir")).toContainText("Ubicación");
await expect(app.locator("#nuevo-tipo-valor")).toHaveValue("location");
await app.locator("#btn-anadir").click();
await expect.poll(async () => (await estado(app)).mensajes.map((m) => m.tipo)).toEqual(["location", "location"]);
});
test("«Plegar todo» cierra todas las tarjetas abiertas", async ({ app }) => {
await abrirTarjeta(app, 0);
await abrirTarjeta(app, 1);
await expect(app.locator("#lista-mensajes > .ed-tarjeta.ed-abierta")).toHaveCount(2);
await app.locator('[data-acc="plegar-todo"]').click();
await expect(app.locator("#lista-mensajes > .ed-tarjeta.ed-abierta")).toHaveCount(0);
});
test("«Horas en secuencia» reparte +1 min desde el primer mensaje", async ({ app }) => {
await cargarEstado(app, {
version: 1, tema: "claro", cuenta: { nombre: "Horas" },
mensajes: [
{ id: "m1", dir: "in", tipo: "text", hora: "08:59", texto: "a" },
{ id: "m2", dir: "out", tipo: "text", hora: "11:11", texto: "b" },
{ id: "m3", dir: "in", tipo: "text", hora: "07:00", texto: "c" },
],
});
await app.locator('[data-acc="horas-secuencia"]').click();
await expect.poll(async () => (await estado(app)).mensajes.map((m) => m.hora)).toEqual(["08:59", "09:00", "09:01"]);
await expect(app.locator('#chat-cuerpo li[data-id="m3"] .meta')).toContainText("09:01");
});
test("abrir una tarjeta resalta su burbuja; doble clic en la burbuja abre su tarjeta", async ({ app }) => {
await abrirTarjeta(app, 4);
const id = await tarjeta(app, 4).getAttribute("data-id");
await expect(app.locator(`#chat-cuerpo li[data-id="${id}"]`)).toHaveClass(/ed-resaltado/);
await app.locator('[data-acc="plegar-todo"]').click();
const id2 = await tarjeta(app, 0).getAttribute("data-id");
await app.locator(`#chat-cuerpo li[data-id="${id2}"] .bubble`).dblclick();
await expect(tarjeta(app, 0)).toHaveClass(/ed-abierta/);
});
});
+178
View File
@@ -0,0 +1,178 @@
// Fixtures y utilidades compartidas por la suite e2e.
//
// - `page` se extiende para registrar errores/warnings de consola, errores de
// página y peticiones a red externa; al terminar cada test se exige que no
// haya ninguno (criterio §5.1 y §5.10 de la SPEC). Un test puede tolerar
// mensajes concretos con `consola.permitir(/regex/)`.
// - `app` navega a index.html y espera a que el chat y el editor estén listos.
import { test as base, expect } from "@playwright/test";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
export const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
export const URL_FILE = pathToFileURL(path.join(RAIZ, "index.html")).href;
export const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), "archivos");
/** PNG 1×1 válido (para subidas y drops). */
export const PNG_1PX = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
export const TIPOS = [
"text", "image", "video", "audio", "document", "sticker", "location",
"contact", "template", "buttons", "list", "cta_url", "carousel",
];
export const test = base.extend({
consola: async ({}, use) => {
const registro = {
mensajes: [],
externas: [],
permitidos: [],
permitir(re) { this.permitidos.push(re); },
};
await use(registro);
},
page: async ({ page, consola }, use, testInfo) => {
page.on("console", (m) => {
if (m.type() === "error" || m.type() === "warning") consola.mensajes.push(`[${m.type()}] ${m.text()}`);
});
page.on("pageerror", (e) => consola.mensajes.push(`[pageerror] ${e.message}`));
// Ninguna dependencia de red obligatoria: toda petición externa se registra y se aborta.
await page.route(/^(https?|wss?):\/\/(?!127\.0\.0\.1|localhost)/, (ruta) => {
consola.externas.push(ruta.request().url());
return ruta.abort();
});
await use(page);
if (testInfo.status === testInfo.expectedStatus) {
const reales = consola.mensajes.filter((t) => !consola.permitidos.some((re) => re.test(t)));
expect(reales, "errores/warnings de consola").toEqual([]);
expect(consola.externas, "peticiones a red externa").toEqual([]);
}
},
app: async ({ page }, use) => {
await irA(page, "/index.html");
await use(page);
},
});
export { expect };
/** Navega y espera al arranque completo (chat + editor pintados). */
export async function irA(page, url) {
await page.goto(url);
await esperarListo(page);
}
export async function esperarListo(page) {
await page.waitForFunction(() =>
window.WAM && WAM.estado && document.querySelector("#chat-header #chat-nombre") &&
document.querySelector("#nuevo-tipo [data-tipo]")
);
}
/** Copia profunda del estado vivo. */
export function estado(page) {
return page.evaluate(() => JSON.parse(JSON.stringify(WAM.estado)));
}
/** Estado persistido en localStorage (wam-mockup-v1). */
export function estadoGuardado(page) {
return page.evaluate(() => {
if (typeof WAM.guardarYa === "function") WAM.guardarYa();
return JSON.parse(localStorage.getItem("wam-mockup-v1") || "null");
});
}
/** Deja la demo sin mensajes (como el escenario «en blanco») y repinta todo. */
export async function vaciar(page) {
await page.evaluate(() => {
WAM.estado.mensajes = [];
WAM.guardarYa();
WAM.render();
WAM.renderEditor();
});
await expect(page.locator("#lista-mensajes > .ed-tarjeta")).toHaveCount(0);
}
/** Sustituye el estado entero (normalizado) y repinta todo. */
export async function cargarEstado(page, datos) {
await page.evaluate((d) => {
WAM.estado = WAM.normalizar(d);
WAM.guardarYa();
WAM.setTema(WAM.estado.tema);
WAM.render();
WAM.renderEditor();
}, datos);
}
export const tarjetas = (page) => page.locator("#lista-mensajes > .ed-tarjeta");
export const tarjeta = (page, i) => page.locator(`#lista-mensajes > .ed-tarjeta[data-idx="${i}"]`);
export const filaChat = (page, id) => page.locator(`#chat-cuerpo > li.wa-row[data-id="${id}"]`);
export const filasChat = (page) => page.locator("#chat-cuerpo > li.wa-row");
export const campo = (page, ruta) => page.locator(`#lista-mensajes [data-ruta="${ruta}"]`).first();
/** Despliega la tarjeta i (si está plegada) y la devuelve. */
export async function abrirTarjeta(page, i) {
const t = tarjeta(page, i);
const cab = t.locator(":scope > .ed-tarjeta-cab .ed-cab-principal");
if ((await cab.getAttribute("aria-expanded")) !== "true") await cab.click();
await expect(cab).toHaveAttribute("aria-expanded", "true");
return t;
}
/** Añade un mensaje desde la paleta y devuelve su id. */
export async function anadirTipo(page, tipo) {
const antes = (await estado(page)).mensajes.map((m) => m.id);
await page.locator(`#nuevo-tipo [data-acc="anadir-tipo"][data-tipo="${tipo}"]`).click();
const handle = await page.waitForFunction((prev) => {
const nuevo = WAM.estado.mensajes.find((m) => !prev.includes(m.id));
return nuevo ? nuevo.id : null;
}, antes);
return handle.jsonValue();
}
/** Pone un campo de media en modo URL (los vacíos arrancan en «Subir archivo»). */
export async function modoUrl(page, rutaMedia) {
const inp = campo(page, rutaMedia);
if (await inp.isVisible()) return inp;
const media = page.locator(".ed-media", { has: page.locator(`[data-ruta="${rutaMedia}"]`) }).first();
await media.locator('label.ed-radio:has(input[value="url"])').click();
await expect(inp).toBeVisible();
return inp;
}
/** Acelera los flujos del teléfono (velocidad 2x). */
export async function rapido(page) {
await page.evaluate(() => WAM.setVelocidad(2));
}
/**
* Arrastra con el ratón (HTML5 drag & drop nativo) el asa `asa` hasta `destino`.
* `abajo` = soltar en la mitad inferior del destino (queda detrás de él).
* Coloca antes el destino arriba del todo para que ambos queden a la vista.
*/
export async function arrastrar(page, asa, destino, { abajo = false } = {}) {
await destino.evaluate((el) => el.scrollIntoView({ block: "start" }));
const a = await asa.boundingBox();
const d = await destino.boundingBox();
const vh = page.viewportSize().height;
if (!a || !d || a.y < 0 || a.y + a.height > vh) throw new Error("el asa y el destino no caben a la vez en pantalla");
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(a.x + a.width / 2 + 4, a.y + a.height / 2 + 4, { steps: 3 });
const y = abajo ? d.y + d.height - 4 : d.y + 4;
await page.mouse.move(d.x + Math.min(40, d.width / 2), y, { steps: 8 });
await page.mouse.up();
}
/** Pulsa una acción de la cabecera de la tarjeta i (las flechas se revelan al pasar el ratón). */
export async function accion(page, i, acc, delta) {
const cab = tarjeta(page, i).locator(":scope > .ed-tarjeta-cab");
await cab.hover();
const sel = `[data-acc="${acc}"]` + (delta === undefined ? "" : `[data-delta="${delta}"]`);
await cab.locator(sel).click();
}
+222
View File
@@ -0,0 +1,222 @@
// Interactividad del teléfono: quick replies, plantilla, lista + hoja, carrusel,
// «Ver todas las opciones», respuestas encadenadas editadas, composer y ticks.
import { test, expect, estado, filaChat, filasChat, vaciar, anadirTipo, campo, tarjeta, rapido } from "./fixtures.mjs";
const salientesSesion = (page) => page.locator('#chat-cuerpo > li.wa-row.out[data-id^="s"]');
const entrantesSesion = (page) => page.locator('#chat-cuerpo > li.wa-row.in[data-id^="s"]');
async function idDe(page, tipo) {
return (await estado(page)).mensajes.find((m) => m.tipo === tipo).id;
}
test.describe("Flujos al pulsar botones", () => {
test("quick reply: burbuja OUT, «escribiendo…» y respuestas encadenadas", async ({ app }) => {
const id = await idDe(app, "buttons");
const boton = filaChat(app, id).locator("button.qr-btn", { hasText: "Promociones" });
await boton.scrollIntoViewIfNeeded();
await boton.click();
// burbuja saliente inmediata con el texto del botón y la cita del mensaje
await expect(salientesSesion(app).last()).toContainText("Promociones");
await expect(salientesSesion(app).last().locator(".wa-cita")).toBeVisible();
await expect(boton).toHaveClass(/usado/);
// ticks sent → delivered → read
await expect(salientesSesion(app).last().locator(".tick-read")).toBeVisible({ timeout: 4000 });
// escribiendo… y llegada de las 2 respuestas
await expect(app.locator("#chat-escribiendo")).toBeVisible({ timeout: 4000 });
await expect(app.locator("#chat-subtitulo")).toHaveText("escribiendo…");
await expect(entrantesSesion(app)).toHaveCount(2, { timeout: 8000 });
await expect(entrantesSesion(app).nth(0)).toContainText("cold brew");
await expect(entrantesSesion(app).nth(1)).toContainText("croissant");
await expect(app.locator("#chat-escribiendo")).toBeHidden();
await expect(app.locator("#chat-subtitulo")).toHaveText("en línea");
// scroll al fondo
const cuerpo = app.locator("#chat-cuerpo");
await expect.poll(() => cuerpo.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(8);
// un botón usado no se puede volver a pulsar
await boton.click({ force: true });
await app.waitForTimeout(300);
await expect(salientesSesion(app)).toHaveCount(1);
// la sesión no toca el guion ni localStorage
const guardado = await app.evaluate(() => JSON.parse(localStorage.getItem("wam-mockup-v1")));
expect((guardado?.mensajes || []).some((m) => String(m.id).startsWith("s"))).toBe(false);
});
test("plantilla: el botón «Confirmar» dispara su respuesta", async ({ app }) => {
await rapido(app);
const id = await idDe(app, "template");
await filaChat(app, id).locator("button.qr-btn", { hasText: "Confirmar" }).click();
await expect(salientesSesion(app).last()).toContainText("Confirmar");
await expect(entrantesSesion(app).last()).toContainText("Reserva confirmada", { timeout: 6000 });
});
test("lista: el botón abre la hoja; una fila dispara su flujo", async ({ app }) => {
await rapido(app);
const id = await idDe(app, "list");
await filaChat(app, id).locator("button.qr-btn").click();
const hoja = app.locator("#sheet");
await expect(hoja).toBeVisible();
await expect(hoja.locator(".sheet-titulo")).toHaveText("Ver opciones");
await expect(hoja.locator(".sheet-seccion")).toHaveText(["Bebidas", "Postres"]);
await expect(hoja.locator(".sheet-fila")).toHaveCount(5);
await expect(hoja.locator(".sheet-fila").nth(1).locator(".sheet-fila-desc")).toHaveText("Suave, con leche de avena");
await hoja.locator(".sheet-fila", { hasText: "Latte de avena" }).click();
await expect(hoja).toHaveCount(0);
await expect(salientesSesion(app).last()).toContainText("Latte de avena");
await expect(entrantesSesion(app).last()).toContainText("Latte de avena anotado", { timeout: 6000 });
});
test("lista: la hoja se cierra con la X, con el fondo y con Escape", async ({ app }) => {
const id = await idDe(app, "list");
const abrir = () => filaChat(app, id).locator("button.qr-btn").click();
await abrir();
await app.locator("#sheet .sheet-cerrar").click();
await expect(app.locator("#sheet")).toHaveCount(0);
await abrir();
await app.locator("#sheet .sheet-fondo").click({ position: { x: 20, y: 20 } });
await expect(app.locator("#sheet")).toHaveCount(0);
await abrir();
await app.locator("#sheet .sheet-fila").first().focus();
await app.keyboard.press("Escape");
await expect(app.locator("#sheet")).toHaveCount(0);
await expect(salientesSesion(app)).toHaveCount(0);
});
test("carrusel: el botón de una card dispara su flujo", async ({ app }) => {
await rapido(app);
const id = await idDe(app, "carousel");
const btn = filaChat(app, id).locator(".card").nth(1).locator("button.qr-btn");
await btn.scrollIntoViewIfNeeded();
await btn.click();
await expect(salientesSesion(app).last()).toContainText("Pedir");
await expect(entrantesSesion(app).last()).toContainText("Cold brew de 1 L", { timeout: 6000 });
});
test("«Ver todas las opciones» reenvía la opción al botón oculto", async ({ app }) => {
await rapido(app);
await vaciar(app);
const id = await anadirTipo(app, "template");
const add = tarjeta(app, 0).locator('[data-acc="add-boton"]').first();
for (let k = 0; k < 3; k++) await add.click();
await campo(app, "mensajes.0.botones.3.texto").fill("Cuarta opción");
await filaChat(app, id).locator("[data-ver-todas]").click();
await app.locator('#sheet [data-opcion="3"]').click();
await expect(app.locator("#sheet")).toHaveCount(0);
await expect(salientesSesion(app).last()).toContainText("Cuarta opción");
await expect(filaChat(app, id).locator('[data-btn="3"]')).toHaveClass(/usado/);
});
test("respuestas editadas en el editor llegan al pulsar (incluidas anidadas)", async ({ app }) => {
await rapido(app);
await vaciar(app);
const id = await anadirTipo(app, "buttons");
await campo(app, "mensajes.0.botones.0.texto").fill("Ver menú");
await tarjeta(app, 0).locator('[data-acc="add-resp"][data-ruta="mensajes.0.botones.0.respuestas"]').click();
await campo(app, "mensajes.0.botones.0.respuestas.0.texto").fill("Aquí tienes el menú");
// segunda respuesta: una lista (respuesta interactiva encadenada)
await tarjeta(app, 0).locator('[data-acc="add-resp"][data-ruta="mensajes.0.botones.0.respuestas"]').click();
await tarjeta(app, 0).locator('select[data-rol="tipo-resp"]').nth(1).selectOption("buttons");
await expect.poll(async () => (await estado(app)).mensajes[0].botones[0].respuestas[1].tipo).toBe("buttons");
await filaChat(app, id).locator("button.qr-btn", { hasText: "Ver menú" }).click();
await expect(entrantesSesion(app)).toHaveCount(2, { timeout: 6000 });
await expect(entrantesSesion(app).nth(0)).toContainText("Aquí tienes el menú");
// la respuesta de tipo «buttons» es pulsable a su vez
const anidado = entrantesSesion(app).nth(1).locator("button.qr-btn").first();
await expect(anidado).toBeVisible();
await anidado.click();
await expect(salientesSesion(app)).toHaveCount(2);
});
test("botón sin respuestas: solo sale la burbuja con su texto", async ({ app }) => {
await rapido(app);
await vaciar(app);
const id = await anadirTipo(app, "buttons");
await filaChat(app, id).locator("button.qr-btn").click();
await expect(salientesSesion(app)).toHaveCount(1);
await app.waitForTimeout(1500);
await expect(entrantesSesion(app)).toHaveCount(0);
});
test("enlaces cta_url abren en pestaña nueva y no disparan flujo", async ({ app, context }) => {
const id = await idDe(app, "cta_url");
const a = filaChat(app, id).locator("a.qr-btn");
await expect(a).toHaveAttribute("rel", /noopener/);
// la navegación externa está bloqueada por la suite; basta con que no haya flujo
const [popup] = await Promise.all([context.waitForEvent("page").catch(() => null), a.click()]);
if (popup) await popup.close();
await expect(salientesSesion(app)).toHaveCount(0);
});
test("Enter en un botón del chat lo activa desde el teclado", async ({ app }) => {
await rapido(app);
const id = await idDe(app, "buttons");
const b = filaChat(app, id).locator("button.qr-btn").first();
await b.focus();
await app.keyboard.press("Enter");
await expect(salientesSesion(app).last()).toContainText("Horarios");
});
});
test.describe("Composer", () => {
test("Enter envía un mensaje libre con hora actual y ticks automáticos", async ({ app }) => {
const input = app.locator("#composer-input");
await input.fill("¿Tienen terraza?");
await input.press("Enter");
const burbuja = salientesSesion(app).last();
await expect(burbuja).toContainText("¿Tienen terraza?");
const hora = await app.evaluate(() => WAM.horaAhora());
await expect(burbuja.locator(".meta")).toContainText(hora.slice(0, 3));
await expect(burbuja.locator(".tick")).toBeVisible();
await expect(burbuja.locator(".tick-read")).toBeVisible({ timeout: 4000 });
await expect(input).toHaveValue("");
await expect(input).toBeFocused();
});
test("el botón enviar también envía y un texto vacío no envía nada", async ({ app }) => {
await app.locator("#composer-input").fill(" ");
await app.locator("#composer-enviar").click();
await expect(salientesSesion(app)).toHaveCount(0);
await app.locator("#composer-input").fill("Hola");
await app.locator("#composer-enviar").click();
await expect(salientesSesion(app)).toHaveCount(1);
});
test("el texto del composer se pinta como texto, sin inyectar HTML", async ({ app }) => {
await app.locator("#composer-input").fill('<img src=x onerror="window.__xss=1">');
await app.locator("#composer-input").press("Enter");
await expect(salientesSesion(app).last()).toContainText("<img src=x");
expect(await app.evaluate(() => window.__xss)).toBeUndefined();
});
test("«Reiniciar chat» descarta la sesión y vuelve al guion", async ({ app }) => {
const n = (await estado(app)).mensajes.length;
await app.locator("#composer-input").fill("temporal");
await app.locator("#composer-input").press("Enter");
await expect(filasChat(app)).toHaveCount(n + 1);
await app.locator("#btn-reiniciar").click();
await expect(filasChat(app)).toHaveCount(n);
await expect(salientesSesion(app)).toHaveCount(0);
});
test("editar el guion descarta la sesión del teléfono", async ({ app }) => {
const n = (await estado(app)).mensajes.length;
await app.locator("#composer-input").fill("temporal");
await app.locator("#composer-input").press("Enter");
await expect(filasChat(app)).toHaveCount(n + 1);
await app.locator('#nuevo-tipo [data-tipo="text"]').click();
await expect(filasChat(app)).toHaveCount(n + 1); // +1 del nuevo mensaje, sin la sesión
await expect(salientesSesion(app)).toHaveCount(0);
});
test("WAM.sesionAGuion() convierte la sesión en mensajes del guion", async ({ app }) => {
const n = (await estado(app)).mensajes.length;
await app.locator("#composer-input").fill("para el guion");
await app.locator("#composer-input").press("Enter");
const añadidos = await app.evaluate(() => WAM.sesionAGuion());
expect(añadidos).toBe(1);
const e = await estado(app);
expect(e.mensajes).toHaveLength(n + 1);
expect(e.mensajes[n]).toMatchObject({ dir: "out", tipo: "text", texto: "para el guion" });
expect(e.mensajes[n].id).toMatch(/^m/);
await expect(app.locator("#lista-mensajes > .ed-tarjeta")).toHaveCount(n + 1);
});
});
+133
View File
@@ -0,0 +1,133 @@
// Responsive: ventanas estrechas de escritorio (pestañas Editor / Vista previa,
// menú «⋯») y un iPhone real (proyecto «iphone», specs @movil).
import { test, expect, estado, filasChat, filaChat, anadirTipo, irA } from "./fixtures.mjs";
async function sinScrollHorizontal(page) {
const { sw, cw } = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }));
expect(sw, "scroll horizontal de la página").toBeLessThanOrEqual(cw + 1);
}
async function dentroDeVentana(page, loc) {
const caja = await loc.boundingBox();
const vw = page.viewportSize().width;
expect(caja.x).toBeGreaterThanOrEqual(-1);
expect(caja.x + caja.width).toBeLessThanOrEqual(vw + 1);
}
test.describe("Escritorio ancho", () => {
test("dos columnas: editor a la izquierda y teléfono a la derecha, sin pestañas", async ({ app }) => {
await expect(app.locator("#tabs-movil")).toBeHidden();
const ed = await app.locator("#panel-editor").boundingBox();
const mv = await app.locator("#panel-movil").boundingBox();
expect(ed.x + ed.width).toBeLessThanOrEqual(mv.x + 1);
await expect(app.locator("#btn-exportar")).toBeVisible();
await sinScrollHorizontal(app);
});
});
for (const ancho of [1024, 900, 700]) {
test.describe(`Ventana de ${ancho}px`, () => {
test.use({ viewport: { width: ancho, height: 800 } });
test("sin scroll horizontal y el iPhone cabe entero", async ({ app }) => {
await sinScrollHorizontal(app);
if (ancho <= 900) await app.locator("#tab-vista").click();
await dentroDeVentana(app, app.locator("#iphone"));
await expect(app.locator("#composer-input")).toBeVisible();
});
if (ancho <= 900) {
test("pestañas Editor / Vista previa y menú «⋯»", async ({ app }) => {
await expect(app.locator("#tabs-movil")).toBeVisible();
await app.locator("#tab-editor").click();
await expect(app.locator("#tab-editor")).toHaveAttribute("aria-selected", "true");
await expect(app.locator("#panel-editor")).toBeVisible();
await expect(app.locator("#panel-movil")).toBeHidden();
const id = await anadirTipo(app, "text");
await app.locator("#btn-ver-telefono").click();
await expect(app.locator("#tab-vista")).toHaveAttribute("aria-selected", "true");
await expect(app.locator("#panel-movil")).toBeVisible();
await expect(filaChat(app, id)).toBeVisible();
// la pestaña elegida se recuerda
await app.locator("#tab-editor").click();
await app.reload();
await expect(app.locator("#tab-editor")).toHaveAttribute("aria-selected", "true");
// menú «⋯» con las acciones secundarias
await app.locator("#btn-mas").click();
await expect(app.locator("#btn-mas")).toHaveAttribute("aria-expanded", "true");
await expect(app.locator("#btn-exportar")).toBeVisible();
await app.keyboard.press("Escape");
await expect(app.locator("#btn-mas")).toHaveAttribute("aria-expanded", "false");
});
}
});
}
test.describe("iPhone @movil", () => {
test("carga sin scroll horizontal con la vista previa por defecto @movil", async ({ app }) => {
await expect(app.locator("#tabs-movil")).toBeVisible();
await expect(app.locator("#tab-vista")).toHaveAttribute("aria-selected", "true");
await sinScrollHorizontal(app);
await dentroDeVentana(app, app.locator("#iphone"));
const n = (await estado(app)).mensajes.length;
await expect(filasChat(app)).toHaveCount(n);
});
test("tocar un quick reply y una fila de la lista funciona con el dedo @movil", async ({ app }) => {
await app.evaluate(() => WAM.setVelocidad(2));
const e = await estado(app);
const btns = e.mensajes.find((m) => m.tipo === "buttons");
const b = filaChat(app, btns.id).locator("button.qr-btn").first();
await b.scrollIntoViewIfNeeded();
await b.tap();
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"]').last()).toContainText("Horarios");
await expect(app.locator('#chat-cuerpo li.in[data-id^="s"]').last()).toContainText("lunes a viernes", { timeout: 6000 });
const lista = e.mensajes.find((m) => m.tipo === "list");
const bl = filaChat(app, lista.id).locator("button.qr-btn");
await bl.scrollIntoViewIfNeeded();
await bl.tap();
await expect(app.locator("#sheet")).toBeVisible();
await dentroDeVentana(app, app.locator("#sheet .sheet-panel"));
await app.locator("#sheet .sheet-fila").first().tap();
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"]').last()).toContainText("Espresso double");
});
test("composer desde el teclado del móvil @movil", async ({ app }) => {
await app.locator("#composer-input").tap();
await app.locator("#composer-input").fill("Hola desde el iPhone");
await app.locator("#composer-enviar").tap();
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"]').last()).toContainText("Hola desde el iPhone");
});
test("editar en la pestaña Editor y ver el resultado en Vista previa @movil", async ({ app }) => {
await app.locator("#tab-editor").tap();
await expect(app.locator("#panel-editor")).toBeVisible();
await sinScrollHorizontal(app);
const id = await anadirTipo(app, "location");
await app.locator('#lista-mensajes [data-ruta$=".nombre"]').last().fill("Local móvil");
await app.locator("#tab-vista").tap();
await expect(filaChat(app, id).locator(".loc-nombre")).toHaveText("Local móvil");
});
test("menú «⋯», tema y presentación en el móvil @movil", async ({ app }) => {
await app.locator("#btn-tema").tap();
await expect(app.locator("body")).toHaveAttribute("data-tema", "oscuro");
await app.locator("#btn-mas").tap();
await expect(app.locator("#btn-demos")).toBeVisible();
await app.locator("#btn-demos").tap();
await expect(app.locator("#dlg-demos")).toBeVisible();
await dentroDeVentana(app, app.locator("#dlg-demos"));
await app.locator("#dlg-demos .wam-dlg-cerrar").tap();
await app.locator("#btn-presentacion").tap();
await expect(app.locator("body")).toHaveClass(/presentacion/);
await dentroDeVentana(app, app.locator("#iphone"));
await app.locator("#btn-presentacion").tap();
await expect(app.locator("body")).not.toHaveClass(/presentacion/);
});
test("por file:// también funciona en el móvil @movil", async ({ page }) => {
const { URL_FILE } = await import("./fixtures.mjs");
await irA(page, URL_FILE);
await expect(page.locator("#chat-nombre")).toHaveText("Café Aurora");
});
});
+219
View File
@@ -0,0 +1,219 @@
// Los 13 tipos: alta desde la paleta, edición en el formulario y reflejo en el chat.
import {
test, expect, estado, vaciar, anadirTipo, campo, filaChat, tarjetas, modoUrl, PNG_1PX, TIPOS,
} from "./fixtures.mjs";
const DATA_PNG = "data:image/png;base64," + PNG_1PX.toString("base64");
/**
* Por tipo: selector que debe existir en la burbuja recién creada, ediciones
* [ruta relativa, valor] y comprobaciones sobre la fila del chat tras editar.
*/
const CASOS = {
text: {
base: ".bubble .wa-texto",
editar: [["texto", "Pedido *listo* para _recoger_"]],
comprobar: async (fila) => {
await expect(fila.locator(".wa-texto strong")).toHaveText("listo");
await expect(fila.locator(".wa-texto em")).toHaveText("recoger");
},
},
image: {
base: "img.wa-media",
url: "src",
editar: [["src", DATA_PNG], ["caption", "Foto del local"]],
comprobar: async (fila) => {
await expect(fila.locator("img.wa-media")).toHaveAttribute("src", DATA_PNG);
await expect(fila.locator(".wa-caption")).toContainText("Foto del local");
},
},
video: {
base: ".wa-media-wrap .wa-play-badge",
editar: [["caption", "Tour virtual"]],
comprobar: async (fila) => {
await expect(fila.locator(".wa-caption")).toContainText("Tour virtual");
},
},
audio: {
base: ".wa-audio .audio-wave",
editar: [["duracion", "1:05"]],
comprobar: async (fila) => {
await expect(fila.locator(".audio-dur")).toHaveText("1:05");
await expect(fila.locator(".audio-mic")).toBeVisible();
},
},
document: {
base: ".wa-doc .doc-panel",
editar: [["filename", "informe-trimestral.xlsx"], ["caption", "Resultados"]],
comprobar: async (fila) => {
await expect(fila.locator(".doc-nombre")).toHaveText("informe-trimestral.xlsx");
await expect(fila.locator(".doc-sub")).toContainText(/xlsx/i);
await expect(fila.locator(".wa-caption")).toContainText("Resultados");
},
},
sticker: {
base: ".sticker-box .sticker-img",
url: "src",
editar: [["src", DATA_PNG]],
comprobar: async (fila) => {
await expect(fila.locator(".sticker-img")).toHaveAttribute("src", DATA_PNG);
},
},
location: {
base: ".wa-loc .loc-mapa",
editar: [["nombre", "Sucursal Norte"], ["direccion", "Av. Reforma 500"]],
comprobar: async (fila) => {
await expect(fila.locator(".loc-nombre")).toHaveText("Sucursal Norte");
await expect(fila.locator(".loc-dir")).toHaveText("Av. Reforma 500");
},
},
contact: {
base: ".wa-contact .contact-nombre",
editar: [["nombre", "Ana Torres"], ["telefono", "+52 55 1234 5678"]],
comprobar: async (fila) => {
await expect(fila.locator(".contact-nombre")).toHaveText("Ana Torres");
},
},
template: {
base: ".bubble.tpl .tpl-body",
editar: [["body", "Tu cita es mañana"], ["footer", "Clínica Demo"], ["botones.0.texto", "Confirmo"]],
comprobar: async (fila) => {
await expect(fila.locator(".tpl-body")).toHaveText("Tu cita es mañana");
await expect(fila.locator(".template-footer")).toHaveText("Clínica Demo");
await expect(fila.locator(".qr-btn .btn-label")).toHaveText(["Confirmo"]);
},
},
buttons: {
base: ".bubble.tpl button.qr-btn",
editar: [["body", "¿Te ayudo?"], ["botones.0.texto", "Sí, por favor"]],
comprobar: async (fila) => {
await expect(fila.locator(".tpl-body")).toHaveText("¿Te ayudo?");
await expect(fila.locator("button.qr-btn .btn-label")).toHaveText(["Sí, por favor"]);
},
},
list: {
base: ".bubble.tpl button.qr-btn[data-flujo]",
editar: [["header", "Catálogo"], ["body", "Elige tu plan"], ["boton", "Ver planes"]],
comprobar: async (fila) => {
await expect(fila.locator(".tpl-header-txt")).toHaveText("Catálogo");
await expect(fila.locator(".tpl-body")).toHaveText("Elige tu plan");
await expect(fila.locator("button.qr-btn")).toHaveText("Ver planes");
},
},
cta_url: {
base: ".bubble.tpl .qr-btn",
editar: [["textoBoton", "Ir a la web"], ["url", "https://example.org/promo"], ["body", "Promo de temporada"]],
comprobar: async (fila) => {
const a = fila.locator("a.qr-btn");
await expect(a).toHaveAttribute("href", "https://example.org/promo");
await expect(a).toHaveAttribute("target", "_blank");
await expect(a).toContainText("Ir a la web");
await expect(fila.locator(".tpl-body")).toHaveText("Promo de temporada");
},
},
carousel: {
base: ".carousel .card",
editar: [["body", "Novedades"], ["cards.0.texto", "Plan Básico"], ["cards.1.textoBoton", "Lo quiero"]],
comprobar: async (fila) => {
await expect(fila.locator(".card")).toHaveCount(2);
await expect(fila.locator(".wa-stack > .bubble .wa-texto")).toContainText("Novedades");
await expect(fila.locator(".card").nth(0).locator(".card-body")).toHaveText("Plan Básico");
await expect(fila.locator(".card").nth(1).locator(".btn-label")).toHaveText("Lo quiero");
},
},
};
test.describe("Los 13 tipos de mensaje", () => {
test("la paleta ofrece exactamente los 13 tipos", async ({ app }) => {
const tipos = await app.locator('#nuevo-tipo [data-acc="anadir-tipo"]').evaluateAll((els) => els.map((e) => e.dataset.tipo));
expect([...tipos].sort()).toEqual([...TIPOS].sort());
});
for (const tipo of TIPOS) {
test(`${tipo}: añadir, editar y ver el reflejo en el chat`, async ({ app }) => {
const caso = CASOS[tipo];
await vaciar(app);
const id = await anadirTipo(app, tipo);
// Tarjeta abierta y burbuja en el chat
const t = app.locator(`#lista-mensajes > .ed-tarjeta[data-id="${id}"]`);
await expect(tarjetas(app)).toHaveCount(1);
await expect(t).toHaveClass(/ed-abierta/);
const fila = filaChat(app, id);
await expect(fila).toBeVisible();
await expect(fila.locator(caso.base).first()).toBeVisible();
// Alta: el foco va a un campo de la tarjeta nueva
await expect.poll(() => app.evaluate((i) => {
const a = document.activeElement;
return !!(a && a.closest(`.ed-tarjeta[data-id="${i}"]`));
}, id)).toBe(true);
// Edición
for (const [rel, valor] of caso.editar) {
const ruta = `mensajes.0.${rel}`;
const inp = caso.url === rel ? await modoUrl(app, ruta) : campo(app, ruta);
await inp.fill(valor);
}
await caso.comprobar(fila);
// El estado vivo y el persistido reflejan la edición
const e = await estado(app);
for (const [rel, valor] of caso.editar) {
const v = rel.split(".").reduce((o, k) => (o == null ? o : o[k]), e.mensajes[0]);
expect(v, rel).toBe(valor);
}
const guardado = await app.evaluate(() => { WAM.guardarYa(); return JSON.parse(localStorage.getItem("wam-mockup-v1")); });
expect(guardado.mensajes[0].tipo).toBe(tipo);
});
}
test("cambiar la dirección a «Salida» pinta ticks y el selector de estado", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "text");
const fila = filaChat(app, id);
await expect(fila).toHaveClass(/\bin\b/);
await app.locator('#lista-mensajes label.ed-seg-op:has(input[data-rol="dir"][value="out"])').click();
await expect(fila).toHaveClass(/\bout\b/);
await expect(fila.locator(".tick")).toHaveCount(1);
const sel = campo(app, "mensajes.0.estado");
await sel.selectOption("read");
await expect(filaChat(app, id).locator(".tick-read")).toBeVisible();
await sel.selectOption("delivered");
await expect(filaChat(app, id).locator(".tick")).toBeVisible();
await expect(filaChat(app, id).locator(".tick-read")).toHaveCount(0);
expect((await estado(app)).mensajes[0]).toMatchObject({ dir: "out", estado: "delivered" });
});
test("editar la hora se refleja en la burbuja", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "text");
await campo(app, "mensajes.0.hora").fill("09:41");
await expect(filaChat(app, id).locator(".meta")).toContainText("09:41");
});
test("audio: desactivar «nota de voz» cambia a audio con auriculares", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "audio");
await campo(app, "mensajes.0.voz").uncheck();
await expect(filaChat(app, id).locator(".audio-musica")).toBeVisible();
await expect(filaChat(app, id).locator(".audio-mic")).toHaveCount(0);
expect((await estado(app)).mensajes[0].voz).toBe(false);
});
test("plantilla: cabecera opcional de texto, imagen y documento", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "template");
const fila = filaChat(app, id);
await app.locator("#lista-mensajes label.ed-switch", { hasText: "Mostrar cabecera" }).click();
await expect(app.locator('#lista-mensajes input[data-rol="header-toggle"]')).toBeChecked();
const tipoCab = (v) => app.locator(`#lista-mensajes [data-acc="header-tipo"][data-valor="${v}"]`);
await tipoCab("texto").click();
await campo(app, "mensajes.0.headerTexto").fill("Aviso importante");
await expect(fila.locator(".tpl-header-txt")).toHaveText("Aviso importante");
await tipoCab("imagen").click();
await expect(fila.locator("img.tpl-media")).toBeVisible();
await tipoCab("documento").click();
await expect(fila.locator(".tpl-header-doc .doc-nombre")).toBeVisible();
expect((await estado(app)).mensajes[0].headerTipo).toBe("documento");
});
});
+315
View File
@@ -0,0 +1,315 @@
// Barra superior: tema, presentación, exportar/importar, restablecer, deshacer/rehacer,
// atajos de teclado, demos guardadas y escenarios por industria.
import fs from "node:fs";
import {
test, expect, estado, vaciar, anadirTipo, campo, filasChat, tarjetas, cargarEstado,
} from "./fixtures.mjs";
async function exportar(page, disparar) {
const [descarga] = await Promise.all([page.waitForEvent("download"), disparar()]);
const ruta = await descarga.path();
return { nombre: descarga.suggestedFilename(), json: JSON.parse(fs.readFileSync(ruta, "utf8")) };
}
async function importar(page, datos, nombre = "demo.json") {
const buffer = Buffer.from(typeof datos === "string" ? datos : JSON.stringify(datos));
await page.locator("#btn-importar").click();
const confirmar = page.locator("#dlg-confirmar");
const [chooser] = await Promise.all([
page.waitForEvent("filechooser"),
(async () => {
if (await confirmar.isVisible().catch(() => false)) {
await confirmar.getByRole("button", { name: "Elegir archivo…" }).click();
}
})(),
]);
await chooser.setFiles({ name: nombre, mimeType: "application/json", buffer });
}
test.describe("Tema", () => {
test("alterna claro/oscuro en editor y chat y persiste", async ({ app }) => {
const b = app.locator("#btn-tema");
await b.click();
await expect(app.locator("body")).toHaveAttribute("data-tema", "oscuro");
await expect(app.locator("#pantalla")).toHaveClass(/oscuro/);
await expect(b).toHaveAttribute("aria-pressed", "true");
const fondo = (sel) => app.locator(sel).evaluate((el) => getComputedStyle(el).backgroundColor);
const oscuroEditor = await fondo("body");
await app.reload();
await expect(app.locator("body")).toHaveAttribute("data-tema", "oscuro");
expect((await estado(app)).tema).toBe("oscuro");
await app.locator("#btn-tema").click();
await expect(app.locator("body")).toHaveAttribute("data-tema", "claro");
await expect(app.locator("#pantalla")).not.toHaveClass(/oscuro/);
expect(await fondo("body")).not.toBe(oscuroEditor);
});
test("la tecla T alterna el tema (y no lo hace escribiendo en un campo)", async ({ app }) => {
await app.locator("#chat-header").click();
await app.keyboard.press("t");
await expect(app.locator("body")).toHaveAttribute("data-tema", "oscuro");
await app.locator("#composer-input").fill("");
await app.locator("#composer-input").press("t");
await expect(app.locator("body")).toHaveAttribute("data-tema", "oscuro");
await expect(app.locator("#composer-input")).toHaveValue("t");
});
});
test.describe("Modo presentación", () => {
test("oculta el editor y centra el iPhone; Esc sale", async ({ app }) => {
await app.locator("#btn-presentacion").click();
await expect(app.locator("body")).toHaveClass(/presentacion/);
await expect(app.locator("#btn-presentacion")).toHaveAttribute("aria-pressed", "true");
await expect(app.locator("#panel-editor")).toBeHidden();
const vw = app.viewportSize().width;
const caja = await app.locator("#iphone").boundingBox();
expect(Math.abs(caja.x + caja.width / 2 - vw / 2)).toBeLessThan(40);
// el iPhone cabe entero en la ventana
expect(caja.y).toBeGreaterThanOrEqual(0);
expect(caja.y + caja.height).toBeLessThanOrEqual(app.viewportSize().height + 1);
// el chat sigue funcionando en presentación
await app.locator("#composer-input").fill("en vivo");
await app.locator("#composer-input").press("Enter");
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"]')).toContainText("en vivo");
await app.locator("#chat-header").click();
await app.keyboard.press("Escape");
await expect(app.locator("body")).not.toHaveClass(/presentacion/);
await expect(app.locator("#panel-editor")).toBeVisible();
});
test("la tecla P entra y sale", async ({ app }) => {
await app.locator("#chat-header").click();
await app.keyboard.press("p");
await expect(app.locator("body")).toHaveClass(/presentacion/);
await app.keyboard.press("p");
await expect(app.locator("body")).not.toHaveClass(/presentacion/);
});
});
test.describe("Exportar e importar", () => {
test("exporta <slug>.json con el estado completo", async ({ app }) => {
const { nombre, json } = await exportar(app, () => app.locator("#btn-exportar").click());
expect(nombre).toBe("cafe-aurora.json");
expect(json).toEqual(await estado(app));
expect(json.mensajes.find((m) => m.tipo === "list").secciones[0].filas[0].respuestas).toHaveLength(1);
});
test("sin nombre de cuenta exporta wam-demo.json; Ctrl+S también exporta", async ({ app }) => {
await app.evaluate(() => { WAM.estado.cuenta.nombre = ""; WAM.guardarYa(); });
await app.locator("#chat-header").click();
const { nombre } = await exportar(app, () => app.keyboard.press("Control+s"));
expect(nombre).toBe("wam-demo.json");
});
test("importar el mismo archivo restaura un estado idéntico", async ({ app }) => {
const { json } = await exportar(app, () => app.locator("#btn-exportar").click());
await vaciar(app);
await anadirTipo(app, "text");
await importar(app, json);
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(json.mensajes.length);
expect(await estado(app)).toEqual(json);
await expect(filasChat(app)).toHaveCount(json.mensajes.length);
await expect(tarjetas(app)).toHaveCount(json.mensajes.length);
await app.reload();
expect(await estado(app)).toEqual(json);
});
test("importar normaliza: array de mensajes, IDs duplicados, URLs peligrosas", async ({ app }) => {
await importar(app, [
{ id: "x", dir: "in", tipo: "text", texto: "uno" },
{ id: "x", dir: "out", tipo: "cta_url", body: "b", textoBoton: "Ir", url: "javascript:alert(1)" },
{ dir: "in", tipo: "desconocido", texto: "tres" },
]);
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(3);
const e = await estado(app);
const idsUnicos = new Set(e.mensajes.map((m) => m.id));
expect(idsUnicos.size).toBe(3);
expect(e.mensajes[1].url).toBe("");
await expect(app.locator(`#chat-cuerpo li[data-id="${e.mensajes[1].id}"] a[href^="javascript"]`)).toHaveCount(0);
});
test("un JSON inválido avisa y no cambia la demo", async ({ app, consola }) => {
consola.permitir(/JSON|Unexpected token|SyntaxError/i);
const antes = await estado(app);
await importar(app, "{ no es json", "roto.json");
await expect(app.locator("#ed-toasts .ed-toast-error, #wam-aviso").first()).toBeVisible();
expect(await estado(app)).toEqual(antes);
});
test("cancelar el diálogo de importar no abre el selector", async ({ app }) => {
let abierto = false;
app.on("filechooser", () => { abierto = true; });
await app.locator("#btn-importar").click();
await app.locator("#dlg-confirmar").getByRole("button", { name: "Cancelar" }).click();
await expect(app.locator("#dlg-confirmar")).toHaveCount(0);
await app.waitForTimeout(300);
expect(abierto).toBe(false);
});
});
test.describe("Restablecer, deshacer y rehacer", () => {
test("restablecer pide confirmación, restaura la demo y se puede deshacer", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
await campo(app, "mensajes.0.texto").fill("Mi cambio");
const mio = await estado(app);
await app.locator("#btn-reset").click();
const dlg = app.locator("#dlg-confirmar");
await expect(dlg).toBeVisible();
await expect(dlg).toContainText("1 mensaje");
await dlg.getByRole("button", { name: "Restablecer" }).click();
await expect(app.locator("#chat-nombre")).toHaveText("Café Aurora");
const e = await estado(app);
expect(e.mensajes).toHaveLength(14);
expect(e.mensajes[0].id).toBe("m1");
const toast = app.locator("#ed-toasts .ed-toast", { hasText: "restablecida" });
await toast.locator(".ed-toast-accion").click();
await expect.poll(async () => (await estado(app)).mensajes).toEqual(mio.mensajes);
});
test("«Exportar antes» del diálogo descarga la demo actual", async ({ app }) => {
await app.locator("#btn-reset").click();
const { nombre } = await exportar(app, () => app.locator("#dlg-confirmar").getByRole("button", { name: "Exportar antes" }).click());
expect(nombre).toBe("cafe-aurora.json");
await expect(app.locator("#dlg-confirmar").getByRole("button", { name: "Exportada ✓" })).toBeDisabled();
await app.locator("#dlg-confirmar").getByRole("button", { name: "Cancelar" }).click();
});
test("sin mensajes restablece sin preguntar", async ({ app }) => {
await vaciar(app);
await app.locator("#btn-reset").click();
await expect(app.locator("#dlg-confirmar")).toHaveCount(0);
await expect(tarjetas(app)).toHaveCount(14);
});
test("botones y atajos de deshacer / rehacer", async ({ app }) => {
await expect(app.locator("#btn-deshacer")).toBeDisabled();
const n = (await estado(app)).mensajes.length;
await anadirTipo(app, "contact");
await expect(app.locator("#btn-deshacer")).toBeEnabled();
await app.locator("#btn-deshacer").click();
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(n);
await expect(app.locator("#btn-rehacer")).toBeEnabled();
await app.locator("#btn-rehacer").click();
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(n + 1);
await app.locator("#chat-header").click();
await app.keyboard.press("Control+z");
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(n);
await app.keyboard.press("Control+Shift+z");
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(n + 1);
await expect(tarjetas(app)).toHaveCount(n + 1);
await expect(filasChat(app)).toHaveCount(n + 1);
});
test("deshacer una ráfaga de escritura vuelve al texto previo", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
const ta = campo(app, "mensajes.0.texto");
const original = await ta.inputValue();
await ta.fill("uno");
await ta.pressSequentially(" dos");
await app.locator("#btn-deshacer").click();
await expect.poll(async () => (await estado(app)).mensajes[0]?.texto).toBe(original);
});
});
test.describe("Atajos y demos", () => {
test("«?» y el botón de atajos abren la ayuda", async ({ app }) => {
await app.locator("#btn-atajos").click();
const dlg = app.locator("#dlg-atajos");
await expect(dlg).toBeVisible();
await expect(dlg).toContainText("Modo presentación");
await dlg.locator(".wam-dlg-cerrar").click();
await expect(dlg).toHaveCount(0);
await app.locator("#chat-header").click();
await app.keyboard.press("?");
await expect(app.locator("#dlg-atajos")).toBeVisible();
await app.keyboard.press("Escape");
await expect(app.locator("#dlg-atajos")).toHaveCount(0);
});
test("escenarios por industria: cargar uno y deshacer", async ({ app }) => {
const escenarios = await app.evaluate(() => WAM.ESCENARIOS.map((e) => ({ id: e.id, nombre: e.nombre })));
expect(escenarios.length).toBeGreaterThanOrEqual(7);
await app.locator("#btn-demos").click();
const dlg = app.locator("#dlg-demos");
await expect(dlg).toBeVisible();
await expect(dlg.locator(".wam-esc")).toHaveCount(escenarios.length + 1);
const elegido = escenarios.find((e) => e.id !== "blanco" && !/blanco/i.test(e.nombre)) || escenarios[1];
const datos = await app.evaluate((id) => WAM.normalizar(WAM.datosEscenario(id)), elegido.id);
await dlg.locator(`.wam-esc[data-esc="${elegido.id}"]`).click();
await expect(dlg).toHaveCount(0);
await expect(app.locator("#chat-nombre")).toHaveText(datos.cuenta.nombre || "Chat");
await expect(filasChat(app)).toHaveCount(datos.mensajes.length);
await app.locator("#ed-toasts .ed-toast", { hasText: elegido.nombre }).locator(".ed-toast-accion").click();
await expect(app.locator("#chat-nombre")).toHaveText("Café Aurora");
});
test("todos los escenarios cargan sin avisos de validación ni errores", async ({ app }) => {
const ids = await app.evaluate(() => WAM.ESCENARIOS.map((e) => e.id));
for (const id of ids) {
await app.locator("#btn-demos").click();
await app.locator(`#dlg-demos .wam-esc[data-esc="${id}"]`).click();
const e = await estado(app);
await expect(filasChat(app)).toHaveCount(e.mensajes.length);
await expect(tarjetas(app)).toHaveCount(e.mensajes.length);
await expect(app.locator("#lista-mensajes .ed-chip-aviso"), `escenario ${id}`).toHaveCount(0);
}
});
test("demos guardadas: guardar, abrir, renombrar, duplicar y borrar", async ({ app }) => {
// 1) guardar la actual con nombre
await app.locator("#btn-demos").click();
let dlg = app.locator("#dlg-demos");
await dlg.locator("#dlg-demo-nombre").fill("Cliente Aurora");
await dlg.getByRole("button", { name: /Guardar/ }).click();
await expect(dlg.locator(".wam-demo")).toHaveCount(1);
await expect(dlg.locator(".wam-demo-nombre")).toHaveText("Cliente Aurora");
await expect(dlg.locator(".wam-demo")).toHaveClass(/wam-demo-activa/);
// el export usa el nombre de la demo activa
await dlg.locator(".wam-dlg-pie").getByRole("button", { name: "Cerrar" }).click();
const { nombre } = await exportar(app, () => app.locator("#btn-exportar").click());
expect(nombre).toBe("cliente-aurora.json");
// 2) cambiar la demo y volver a abrir la guardada
await vaciar(app);
await app.locator("#btn-demos").click();
dlg = app.locator("#dlg-demos");
await dlg.locator(".wam-demo").getByRole("button", { name: "Abrir" }).click();
await expect(tarjetas(app)).toHaveCount(14);
// 3) renombrar, duplicar y borrar
await app.locator("#btn-demos").click();
dlg = app.locator("#dlg-demos");
await dlg.locator(".wam-demo").getByRole("button", { name: "Renombrar" }).click();
const inp = dlg.locator(".wam-demo input.wam-dlg-input");
await inp.fill("Aurora v2");
await inp.press("Enter");
await expect(dlg.locator(".wam-demo-nombre")).toHaveText("Aurora v2");
await dlg.locator(".wam-demo").getByRole("button", { name: "Duplicar" }).click();
await expect(dlg.locator(".wam-demo")).toHaveCount(2);
await expect(dlg.locator(".wam-demo-nombre").first()).toHaveText("Aurora v2 (copia)");
const borrar = dlg.locator(".wam-demo").first().getByRole("button", { name: "Borrar" });
await borrar.click();
await expect(dlg.locator(".wam-demo").first().getByRole("button", { name: "¿Seguro?" })).toBeVisible();
await dlg.locator(".wam-demo").first().getByRole("button", { name: "¿Seguro?" }).click();
await expect(dlg.locator(".wam-demo")).toHaveCount(1);
// persiste en localStorage
const indice = await app.evaluate(() => JSON.parse(localStorage.getItem("wam-demos")));
expect(indice.map((d) => d.nombre)).toEqual(["Aurora v2"]);
});
test("la tecla N abre las demos; F pide pantalla completa sin errores", async ({ app }) => {
await app.locator("#chat-header").click();
await app.keyboard.press("n");
await expect(app.locator("#dlg-demos")).toBeVisible();
await app.keyboard.press("Escape");
await expect(app.locator("#dlg-demos")).toHaveCount(0);
await app.keyboard.press("f");
await app.waitForTimeout(300);
if (await app.evaluate(() => !!document.fullscreenElement)) await app.evaluate(() => document.exitFullscreen());
});
test("cargar un estado con avisoCifrado:false oculta los avisos del sistema", async ({ app }) => {
await cargarEstado(app, { version: 1, tema: "claro", cuenta: { nombre: "X", avisoCifrado: false }, mensajes: [] });
await expect(app.locator("#chat-cuerpo > li.wa-sistema")).toHaveCount(0);
});
});
+60
View File
@@ -0,0 +1,60 @@
{
"name": "wam-mockup-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wam-mockup-tests",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "1.63.0"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "wam-mockup-tests",
"private": true,
"version": "1.0.0",
"description": "Suite e2e (Playwright) del WhatsApp Cloud API Mockup",
"type": "module",
"scripts": {
"test": "playwright test",
"test:chromium": "playwright test --project=chromium",
"test:webkit": "playwright test --project=webkit",
"test:movil": "playwright test --project=iphone",
"serve": "node serve.mjs",
"report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "1.63.0"
}
}
+51
View File
@@ -0,0 +1,51 @@
// Configuración Playwright de la suite e2e del WhatsApp Cloud API Mockup.
import { defineConfig, devices } from "@playwright/test";
const PUERTO = Number(process.env.WAM_PORT || 4173);
const BASE = `http://127.0.0.1:${PUERTO}`;
export default defineConfig({
testDir: "./e2e",
outputDir: "./test-results",
timeout: 45_000,
expect: { timeout: 7_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
// WebKit en Windows se vuelve inestable con muchos workers en paralelo
workers: process.env.CI ? 2 : Number(process.env.WAM_WORKERS || 6),
reporter: [["list"], ["html", { outputFolder: "playwright-report", open: "never" }]],
use: {
baseURL: BASE,
locale: "es-ES",
timezoneId: "America/Mexico_City",
trace: "retain-on-failure",
screenshot: "only-on-failure",
acceptDownloads: true,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } },
grepInvert: /@movil/,
},
{
name: "webkit",
use: { ...devices["Desktop Safari"], viewport: { width: 1440, height: 900 } },
grepInvert: /@movil/,
},
{
// Proyecto móvil (WebKit, táctil): solo las specs etiquetadas @movil
name: "iphone",
use: { ...devices["iPhone 15"] },
grep: /@movil/,
},
],
webServer: {
command: `node serve.mjs ${PUERTO}`,
url: `${BASE}/index.html`,
reuseExistingServer: !process.env.CI,
timeout: 20_000,
stdout: "ignore",
},
});
+65
View File
@@ -0,0 +1,65 @@
// Servidor estático mínimo (sin dependencias) para la suite e2e.
// Sirve la raíz del proyecto (carpeta padre de tests/) por HTTP.
// Uso: node serve.mjs [puerto] (defecto: env PORT o 4173)
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PUERTO = Number(process.argv[2] || process.env.PORT || 4173);
const TIPOS = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".ico": "image/x-icon",
".mp4": "video/mp4",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".pdf": "application/pdf",
".txt": "text/plain; charset=utf-8",
".md": "text/markdown; charset=utf-8",
};
const servidor = http.createServer((req, res) => {
let ruta;
try {
ruta = decodeURIComponent(new URL(req.url, "http://x").pathname);
} catch {
res.writeHead(400).end("Petición inválida");
return;
}
if (ruta.endsWith("/")) ruta += "index.html";
const archivo = path.resolve(RAIZ, "." + ruta);
// Evita salir de la raíz (path traversal)
if (archivo !== RAIZ && !archivo.startsWith(RAIZ + path.sep)) {
res.writeHead(403).end("Prohibido");
return;
}
fs.stat(archivo, (err, st) => {
if (err || !st.isFile()) {
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("No encontrado");
return;
}
res.writeHead(200, {
"Content-Type": TIPOS[path.extname(archivo).toLowerCase()] || "application/octet-stream",
"Content-Length": st.size,
"Cache-Control": "no-store",
});
if (req.method === "HEAD") return res.end();
fs.createReadStream(archivo).pipe(res);
});
});
servidor.listen(PUERTO, "127.0.0.1", () => {
console.log(`WAM mockup servido en http://127.0.0.1:${PUERTO}/ (raíz: ${RAIZ})`);
});