WIP: mejoras de fidelidad, editor, lógica y suite e2e (checkpoint tras reinicio)
This commit is contained in:
@@ -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*
|
||||
@@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
@@ -3,3 +3,7 @@ test-results/
|
||||
playwright-report/
|
||||
.zcode/
|
||||
*.log
|
||||
docs/referencias/*.png
|
||||
docs/referencias/*.jpg
|
||||
docs/referencias/*.webp
|
||||
.tmp-audit/
|
||||
|
||||
+30
@@ -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
|
||||
@@ -16,7 +16,8 @@ Bases: `docs/research-api.md` (payloads y límites) y `docs/research-ui.md` (col
|
||||
"nombre": "Café Aurora",
|
||||
"avatar": "",
|
||||
"subtitulo": "en línea",
|
||||
"verificado": true
|
||||
"verificado": true,
|
||||
"avisoCifrado": true
|
||||
},
|
||||
"mensajes": [ ]
|
||||
}
|
||||
@@ -51,44 +52,122 @@ Convenciones: `src`/`avatar`/`headerMedia`/`media` = dataURL o URL remota; `""`
|
||||
de `WAM.svgPlaceholder(texto)`. Botones con `url` no vacía se renderizan como `<a target="_blank">`
|
||||
(cta_url, template URL, card de carousel); sin `url` → flujo interactivo con `respuestas`.
|
||||
`WAM.normalizar(o)` rellena campos faltantes por tipo y devuelve el estado válido (import/upgrade).
|
||||
Campo opcional `autoPulsar` (entero ≥ 0) en `template`/`buttons`/`list`/`carousel` (también en respuestas
|
||||
anidadas): índice del botón, de la fila aplanada de la lista o de la card que «Reproducir demo» pulsa sola
|
||||
~1 s después de revelar el mensaje. `normalizar` lo omite si no es válido.
|
||||
|
||||
**Guion frente a sesión.** `estado.mensajes` es el guion (persistente). Lo que se pulsa o escribe en el
|
||||
teléfono es la sesión: `WAM.sesion` (array en memoria, IDs `s1`, `s2`…), pintada con `anadirMensajeDOM`
|
||||
sin tocar el estado ni localStorage. Todo `WAM.render()` vuelve al guion: vacía la sesión, cierra la hoja y
|
||||
cancela demo y flujos (interactivity.js envuelve `WAM.render`).
|
||||
|
||||
## 2. Contratos de módulos (namespace único `WAM`) e IDs DOM compartidos
|
||||
|
||||
### js/store.js (dueño D) — estado, persistencia, demo
|
||||
- `WAM.CLAVE = "wam-mockup-v1"` · `WAM.estado` (objeto vivo)
|
||||
- `WAM.datosDemo()` → estado nuevo de la demo (§6) · `WAM.cargar()` → estado (LS o demo; normaliza)
|
||||
- `WAM.guardar()` → void (JSON a localStorage) · `WAM.resetear()` → void (borra LS, estado = demo)
|
||||
- `WAM.normalizar(o)` → estado · `WAM.generarId()` → string · `WAM.nuevoMensaje(tipo)` → Mensaje vacío
|
||||
- `WAM.guardar()` → void (JSON a localStorage; la 1.ª llamada escribe al instante y las de los 250 ms
|
||||
siguientes se agrupan; se vacía en `pagehide`) · `WAM.guardarYa()` → bool (escritura inmediata)
|
||||
- Error de guardado (cuota llena / LS bloqueado): llama UNA vez al hook opcional `WAM.onErrorGuardar(texto, error)`
|
||||
(main.js lo pinta con `WAM.avisar`) hasta el siguiente guardado correcto.
|
||||
- `WAM.resetear()` → void (borra LS, estado = demo con IDs desde `m1`)
|
||||
- `WAM.normalizar(o)` → estado (acepta un array de mensajes como raíz; IDs duplicados o inválidos se
|
||||
reasignan; vacía URLs con esquema no permitido; respuestas string → text; conserva campos extra primitivos)
|
||||
- `WAM.generarId()` → string (nunca repite un ID ya entregado aunque aún no esté en el estado)
|
||||
- `WAM.crearAlocador(extra?)` → `function tomar()` que reparte IDs únicos frente al estado, a los ya
|
||||
entregados y a los de `extra` (para clonar mensajes con respuestas anidadas)
|
||||
- `WAM.urlSegura(url, "enlace"|"media")` → bool (enlace: http(s)/mailto/tel/sin esquema; media: http(s)/blob/
|
||||
data:image|video|audio/sin esquema)
|
||||
- `WAM.nuevoMensaje(tipo)` → Mensaje vacío
|
||||
- `WAM.horaAhora()` → "HH:MM" · `WAM.svgPlaceholder(texto)` → dataURI `data:image/svg+xml`
|
||||
|
||||
### js/render.js (dueño B) — pinta el chat, cero listeners de flujo
|
||||
- `WAM.render()` → void: `renderHeader()` + repinta TODOS los mensajes de `estado.mensajes` en `#chat-cuerpo`
|
||||
- `WAM.render()` → void: `cerrarSheet()` + `renderHeader()` + (si `cuenta.avisoCifrado !== false`) dos
|
||||
`li.wa-sistema` (pastilla "Hoy" y aviso de cifrado de negocio) + TODOS los mensajes de `estado.mensajes`
|
||||
- `WAM.renderHeader()` → void: avatar/nombre/subtítulo/verificado en `#chat-header`
|
||||
- `WAM.renderMensaje(msg, i)` → Node `li.wa-row` con todo el contenido del tipo (incluye `data-*`, § abajo)
|
||||
- `WAM.anadirMensajeDOM(msg)` → void: append + `scrollAbajo()` (lo usa la interactividad, sin re-render total)
|
||||
- `WAM.renderMensaje(msg, i)` → Node `li.wa-row` (+ `.grupo` si cambia el emisor, `.ultimo` si es el último
|
||||
del grupo: solo ese lleva la cola de la burbuja, abajo) con todo el contenido del tipo y sus `data-*`
|
||||
- `WAM.anadirMensajeDOM(msg)` → void: append + `scrollAbajo()`; recalcula `.grupo`/`.ultimo` contra la
|
||||
última fila del DOM y reinserta los `li.wa-sistema` si faltan
|
||||
- `WAM.setTema(tema)` → void: `body[data-tema]` + clase `oscuro` en `#pantalla`
|
||||
- `WAM.setEscribiendo(visible)` → void: muestra/oculta `#chat-escribiendo`; subtítulo "escribiendo…" en verde
|
||||
- `WAM.scrollAbajo()` → void · `WAM.abrirSheet(i)` / `WAM.cerrarSheet()` → void (sheet de lista en `#pantalla`)
|
||||
- `WAM.tick(estado)` → string SVG inline (✓ gris / ✓✓ gris / ✓✓ celeste `#53bdeb`)
|
||||
- `WAM.scrollAbajo()` → void · `WAM.abrirSheet(i | Mensaje)` / `WAM.cerrarSheet()` → void (hoja de lista en
|
||||
`#pantalla`: título = `boton`, filas `button.sheet-fila[data-sec][data-fila]`, botón `.sheet-cerrar`)
|
||||
- `WAM.abrirOpciones(i | Mensaje, li?)` → void: hoja "Todas las opciones" de un mensaje con >3 botones
|
||||
(la abre el propio B al pulsar `[data-ver-todas]`). Sus filas de respuesta llevan `data-opcion="<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
|
||||
- `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.anadirMensaje(tipo)` → void · `WAM.moverMensaje(i, delta)` → void · `WAM.duplicarMensaje(i)` → void
|
||||
- `WAM.borrarMensaje(i)` → void · `WAM.setCampo(ruta, valor)` → void (ruta `"mensajes.3.body"`; muta + guardar + `WAM.render()`)
|
||||
- `WAM.aDataUrl(archivo)` → Promise<dataURL> (FileReader) · `WAM.exportarJSON()` → void (Blob + `<a download>`)
|
||||
- `WAM.importarJSON(archivo)` → Promise<void> (parse + `normalizar` + guardar + re-render total)
|
||||
- `WAM.anadirMensaje(tipo, indice?)` → void: inserta en `indice` (defecto: al final) con texto de ejemplo y hora =
|
||||
anterior + 1 min; abre su tarjeta y enfoca su primer campo · `WAM.moverMensaje(i, delta)` · `WAM.duplicarMensaje(i)`
|
||||
(ids del clon y de sus respuestas con un solo alocador `WAM.crearAlocador`)
|
||||
- `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
|
||||
- `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),
|
||||
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.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)
|
||||
- `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
|
||||
- `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()`;
|
||||
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`)
|
||||
@@ -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 |
|
||||
| `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` |
|
||||
| `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) |
|
||||
| `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 |
|
||||
@@ -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`
|
||||
(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`;
|
||||
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*`;
|
||||
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`).
|
||||
|
||||
## 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/whatsapp.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/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/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() -->
|
||||
```
|
||||
|
||||
@@ -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"`,
|
||||
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
|
||||
17 px, cola arriba, meta flotante abajo-derecha, media full-bleed, template header→body→footer→botones,
|
||||
tarjeta apilada de quick replies con hairlines, sheet de lista, waveform de audio, tarjeta de contacto,
|
||||
pin de mapa, scroll-snap del carousel), ticks SVG, indicador "escribiendo…", claro/oscuro. Sin listeners
|
||||
de flujo; solo el cierre de `#sheet-fondo` (elemento suyo) y `data-*`.
|
||||
**B — chat:** `css/whatsapp.css` + `js/render.js`. Render de los 13 tipos con el aspecto de WhatsApp iOS
|
||||
según `docs/referencias/README.md` (burbuja 17 px con radio 18 y cola abajo solo en la última del grupo,
|
||||
media inset 4 px, template/buttons/list/cta_url en UNA burbuja con separador y botones con icono verde,
|
||||
hoja inferior iOS, carrusel sin puntos, nota de voz con avatar, documento en panel gris, contacto con
|
||||
"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
|
||||
`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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
|
||||
+423
-44
@@ -1,6 +1,7 @@
|
||||
/* =====================================================================
|
||||
WhatsApp Cloud API Mockup — css/base.css (Módulo A · shell)
|
||||
Reset, variables --wam-*, layout grid de #app y barra superior (.toolbar).
|
||||
WhatsApp Cloud API Mockup — css/base.css (shell)
|
||||
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).
|
||||
Sin dependencias externas.
|
||||
===================================================================== */
|
||||
@@ -11,7 +12,7 @@ html, body { margin: 0; padding: 0; height: 100%; }
|
||||
body { overflow: hidden; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
img { max-width: 100%; }
|
||||
h1, h2, p { margin: 0; }
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
ul, ol { margin: 0; padding: 0; }
|
||||
[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-alt: #f3f6f8;
|
||||
--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-acento: #00a884;
|
||||
--wam-acento: #00a884; /* bordes, iconos, estados */
|
||||
--wam-acento-hover: #008069;
|
||||
--wam-primario: #008069; /* fondo de botones con texto blanco (≈ 5:1) */
|
||||
--wam-primario-hover: #006e5a;
|
||||
--wam-acento-contraste: #ffffff;
|
||||
--wam-peligro: #c94a3d;
|
||||
--wam-peligro: #b93a2e;
|
||||
--wam-ok: #1a8a5a;
|
||||
--wam-sombra: 0 10px 30px rgba(11, 20, 26, .14);
|
||||
--wam-radio: 12px;
|
||||
--wam-toolbar-alto: 57px; /* lo actualiza js/ux.js con la altura real */
|
||||
--wam-tabs-alto: 58px;
|
||||
}
|
||||
|
||||
body[data-tema="oscuro"] {
|
||||
@@ -38,12 +44,15 @@ body[data-tema="oscuro"] {
|
||||
--wam-panel: #111b21;
|
||||
--wam-panel-alt: #1c272f;
|
||||
--wam-texto: #e9edef;
|
||||
--wam-texto-suave: #8696a0;
|
||||
--wam-texto-suave: #9aa9b2;
|
||||
--wam-borde: rgba(233, 237, 239, .15);
|
||||
--wam-acento: #00a884;
|
||||
--wam-acento-hover: #008069;
|
||||
--wam-acento-hover: #06cf9c;
|
||||
--wam-primario: #008069;
|
||||
--wam-primario-hover: #00a884;
|
||||
--wam-acento-contraste: #ffffff;
|
||||
--wam-peligro: #e57164;
|
||||
--wam-peligro: #f08a7e;
|
||||
--wam-ok: #3fcf8e;
|
||||
--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; }
|
||||
|
||||
/* 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 ----------------------------- */
|
||||
#app {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
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-areas:
|
||||
"toolbar toolbar"
|
||||
@@ -74,6 +94,8 @@ body {
|
||||
|
||||
/* --- Barra superior de la app (.toolbar) ------------------------------- */
|
||||
.toolbar {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -103,14 +125,44 @@ body {
|
||||
overflow: hidden;
|
||||
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; }
|
||||
.tb-separador { width: 1px; height: 22px; background: var(--wam-borde); margin: 0 2px; }
|
||||
/* Indicador de guardado */
|
||||
.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 {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 34px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--wam-borde);
|
||||
border-radius: 10px;
|
||||
@@ -119,16 +171,29 @@ body {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
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:hover { background: var(--wam-panel-alt); border-color: var(--wam-texto-suave); }
|
||||
.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-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 {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -139,40 +204,30 @@ body {
|
||||
.wam-bloque:last-child { border-bottom: 0; }
|
||||
.wam-bloque-titulo {
|
||||
margin-bottom: 12px;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--wam-texto-suave);
|
||||
}
|
||||
.wam-alta { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.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-alta { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.wam-btn-anadir {
|
||||
flex: none;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--wam-acento);
|
||||
color: var(--wam-acento-contraste);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--wam-borde);
|
||||
border-radius: 9px;
|
||||
background: var(--wam-panel);
|
||||
color: var(--wam-texto);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
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 #app {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -181,6 +236,13 @@ body.presentacion #app {
|
||||
"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 ----------------------------------------------------------- */
|
||||
.wam-noscript {
|
||||
position: fixed;
|
||||
@@ -196,15 +258,332 @@ body.presentacion #app {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --- Ajustes de ventana estrecha --------------------------------------- */
|
||||
@media (max-width: 860px) {
|
||||
.toolbar { flex-wrap: wrap; }
|
||||
.tb-btn { padding: 7px 9px; }
|
||||
.tb-btn span { display: none; } /* solo iconos cuando escasea el ancho */
|
||||
/* --- Diálogos (<dialog> nativo: confirmar, demos, atajos) --------------- */
|
||||
.wam-dlg {
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: min(86vh, 760px);
|
||||
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 ------------------------------------ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#chat-cuerpo { scroll-behavior: auto; }
|
||||
.tb-btn, .wam-btn-anadir { transition: none; }
|
||||
.tb-btn, .wam-btn-anadir, .wam-esc { transition: none; }
|
||||
}
|
||||
|
||||
+357
-12
@@ -12,7 +12,7 @@
|
||||
--ed-panel: #ffffff;
|
||||
--ed-fondo-suave: #f0f4f3;
|
||||
--ed-texto: #111b21;
|
||||
--ed-texto-suave: #667781;
|
||||
--ed-texto-suave: #4e5f69;
|
||||
--ed-borde: #d9e2e3;
|
||||
--ed-acento: var(--wam-acento, #00a884);
|
||||
--ed-acento-hondo: #008069;
|
||||
@@ -105,7 +105,8 @@ body[data-tema="oscuro"] #panel-editor {
|
||||
font-size: 12px;
|
||||
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 {
|
||||
width: 100%;
|
||||
@@ -173,15 +174,15 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
}
|
||||
|
||||
.ed-btn-primario {
|
||||
background: var(--ed-acento);
|
||||
border-color: var(--ed-acento);
|
||||
background: var(--wam-primario, #008069);
|
||||
border-color: var(--wam-primario, #008069);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ed-btn-primario:hover {
|
||||
background: var(--ed-acento-hondo);
|
||||
border-color: var(--ed-acento-hondo);
|
||||
background: var(--wam-primario-hover, #006e5a);
|
||||
border-color: var(--wam-primario-hover, #006e5a);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -304,9 +305,9 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
.ed-dir {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
border-radius: 999px;
|
||||
}
|
||||
@@ -322,9 +323,9 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
.ed-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
color: var(--wam-texto, var(--ed-texto-suave));
|
||||
border: 1px solid var(--wam-borde, var(--ed-borde));
|
||||
@@ -382,7 +383,7 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
}
|
||||
.ed-sub-titulo {
|
||||
flex: 1;
|
||||
font-size: 10.5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
@@ -552,7 +553,7 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
/* ---------- Contadores de caracteres ---------- */
|
||||
.ed-contador {
|
||||
justify-self: end;
|
||||
font-size: 10.5px;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--wam-texto-suave, var(--ed-texto-suave));
|
||||
transition: color 0.12s ease;
|
||||
@@ -782,3 +783,347 @@ textarea.ed-input { resize: vertical; min-height: 46px; }
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -30,15 +30,19 @@
|
||||
));
|
||||
}
|
||||
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(
|
||||
1,
|
||||
(100vh - 150px) / 852px,
|
||||
(100vh - 96px) / 852px,
|
||||
(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) ---------------------------- */
|
||||
#panel-movil {
|
||||
min-width: 0;
|
||||
@@ -160,7 +164,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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-weight: 600;
|
||||
letter-spacing: .02em;
|
||||
@@ -177,14 +181,14 @@
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 139px;
|
||||
width: 134px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: #fff; /* en oscuro (research-ui §7) */
|
||||
background: #fff; /* en oscuro */
|
||||
z-index: 45;
|
||||
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) */
|
||||
#chat-header { flex: none; }
|
||||
@@ -215,9 +219,8 @@
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
#composer .attach { color: var(--wa-text-meta, #667781); }
|
||||
#composer .enviar { color: var(--wa-accent, #00a884); }
|
||||
#composer .field svg { flex: none; opacity: .55; color: var(--wa-text-meta, #667781); }
|
||||
#composer .attach { color: var(--wa-chrome-text, #000); }
|
||||
#composer .field svg { flex: none; }
|
||||
#composer-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
+536
-347
File diff suppressed because one or more lines are too long
@@ -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].
|
||||
+96
-23
@@ -12,6 +12,7 @@
|
||||
<link rel="stylesheet" href="css/iphone.css">
|
||||
<link rel="stylesheet" href="css/whatsapp.css">
|
||||
<link rel="stylesheet" href="css/editor.css">
|
||||
<link rel="stylesheet" href="css/interactividad.css">
|
||||
</head>
|
||||
<body data-tema="claro">
|
||||
|
||||
@@ -27,65 +28,120 @@
|
||||
<h1>WhatsApp Cloud API</h1>
|
||||
<span class="toolbar-subtitulo">Mockup visual · demo estática</span>
|
||||
</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 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>
|
||||
<span>Tema</span>
|
||||
</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>
|
||||
<span>Presentación</span>
|
||||
</button>
|
||||
<span class="tb-separador" aria-hidden="true"></span>
|
||||
<button id="btn-exportar" class="tb-btn" type="button" title="Exportar el estado a JSON">
|
||||
<span class="tb-separador tb-sep-sec" aria-hidden="true"></span>
|
||||
<!-- «⋯»: en ventana estrecha agrupa las acciones secundarias -->
|
||||
<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">
|
||||
<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>
|
||||
<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 un estado JSON">
|
||||
<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 (borra localStorage)">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<!-- ============ Panel editor (columna izquierda · contenido: módulo C) ============ -->
|
||||
<aside id="panel-editor" aria-label="Editor del mockup">
|
||||
<section class="wam-bloque">
|
||||
<h2 class="wam-bloque-titulo">Cuenta</h2>
|
||||
<!-- Cuenta: se edita una vez por demo; plegada si ya hay mensajes -->
|
||||
<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 -->
|
||||
<div id="editor-cuenta"></div>
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<section class="wam-bloque">
|
||||
<h2 class="wam-bloque-titulo">Mensajes</h2>
|
||||
<section class="wam-bloque ed-bloque-mensajes" aria-labelledby="titulo-mensajes">
|
||||
<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">
|
||||
<!-- Paleta de tarjetas de los 13 tipos; la rellena js/editor.js (C) en runtime.
|
||||
El tipo elegido queda en #nuevo-tipo-valor (hidden) y en data-valor de la paleta. -->
|
||||
<div id="nuevo-tipo" class="ed-paleta" role="radiogroup" aria-label="Tipo de mensaje a añadir"></div>
|
||||
<!-- Paleta de los 13 tipos (la rellena js/editor.js): un clic AÑADE el mensaje.
|
||||
El último tipo usado queda en #nuevo-tipo-valor (hidden) y en data-valor. -->
|
||||
<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">
|
||||
<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>
|
||||
<!-- Una tarjeta colapsable por mensaje la pinta js/editor.js (C) -->
|
||||
<div id="lista-mensajes"></div>
|
||||
</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>
|
||||
|
||||
<!-- ============ Panel móvil (columna derecha · shell A) ============ -->
|
||||
<section id="panel-movil" aria-label="Vista previa en un iPhone">
|
||||
<div id="barra-movil">
|
||||
<span class="barra-titulo">Vista previa</span>
|
||||
<div class="barra-acciones">
|
||||
<select id="sel-velocidad" title="Velocidad de la reproducción" aria-label="Velocidad de la reproducción">
|
||||
<option value="0.5">0,5x</option>
|
||||
<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 class="iphone-escena">
|
||||
<div id="iphone" class="iphone">
|
||||
@@ -150,18 +206,21 @@
|
||||
|
||||
<!-- Composer (estructura A · lógica D) -->
|
||||
<form id="composer" class="compose" autocomplete="off">
|
||||
<button type="button" class="attach" aria-label="Adjuntar">
|
||||
<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>
|
||||
<button type="button" class="attach" aria-label="Adjuntar" tabindex="-1">
|
||||
<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>
|
||||
<div class="field">
|
||||
<input id="composer-input" type="text" placeholder="Mensaje" aria-label="Mensaje" autocomplete="off" enterkeyhint="send">
|
||||
<svg class="mic" viewBox="0 0 24 24" width="21" height="21" fill="currentColor" aria-hidden="true">
|
||||
<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>
|
||||
<input id="composer-input" type="text" placeholder=" " aria-label="Mensaje" autocomplete="off" enterkeyhint="send">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
</form>
|
||||
|
||||
@@ -180,6 +239,18 @@
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<noscript><p class="wam-noscript">Esta demo necesita JavaScript habilitado.</p></noscript>
|
||||
@@ -190,6 +261,8 @@
|
||||
<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/interactivity.js"></script> <!-- D: flujos/typing/demo; NO toca DOM al cargar -->
|
||||
<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>
|
||||
|
||||
+1238
-155
File diff suppressed because it is too large
Load Diff
@@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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;
|
||||
};
|
||||
})();
|
||||
+553
-107
@@ -1,7 +1,15 @@
|
||||
/* WAM · interactivity.js — dueño D (núcleo).
|
||||
Flujos de clic (quick replies, filas de lista, botones de plantilla y de card),
|
||||
composer libre, indicador "escribiendo…", ticks automáticos y modo
|
||||
"reproducir demo" paso a paso. No toca el DOM al cargar. */
|
||||
"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 || {};
|
||||
(function () {
|
||||
"use strict";
|
||||
@@ -11,42 +19,127 @@ window.WAM = window.WAM || {};
|
||||
"text", "image", "video", "audio", "document", "sticker", "location",
|
||||
"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
|
||||
y la reproducción de la demo en curso. */
|
||||
var cancelaciones = [];
|
||||
var demoAndando = false;
|
||||
function nada() { /* noop */ }
|
||||
|
||||
function quitar(fn) {
|
||||
var i = cancelaciones.indexOf(fn);
|
||||
if (i !== -1) cancelaciones.splice(i, 1);
|
||||
/* Mensajes de la sesión en curso (efímeros). */
|
||||
WAM.sesion = [];
|
||||
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() {
|
||||
var copia = cancelaciones.slice();
|
||||
cancelaciones = [];
|
||||
for (var i = 0; i < copia.length; i++) copia[i]();
|
||||
cancelarGrupo("demo");
|
||||
cancelarGrupo("flujo");
|
||||
escritores.clear();
|
||||
pintarEscribiendo();
|
||||
}
|
||||
|
||||
function escribiendo(visible) {
|
||||
if (typeof WAM.setEscribiendo === "function") WAM.setEscribiendo(visible);
|
||||
/* ————————————————— indicador "escribiendo…" (contador de referencias) ————————————————— */
|
||||
|
||||
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. */
|
||||
WAM.espera = function (ms) {
|
||||
return new Promise(function (resolver, rechazar) {
|
||||
var id = setTimeout(function () {
|
||||
quitar(cancelar);
|
||||
resolver();
|
||||
}, ms);
|
||||
function cancelar() {
|
||||
clearTimeout(id);
|
||||
quitar(cancelar);
|
||||
rechazar(CANCELADO);
|
||||
function empezarEscribir() {
|
||||
var ficha = {};
|
||||
escritores.add(ficha);
|
||||
pintarEscribiendo();
|
||||
return ficha;
|
||||
}
|
||||
cancelaciones.push(cancelar);
|
||||
});
|
||||
};
|
||||
|
||||
function dejarEscribir(ficha) {
|
||||
if (escritores.delete(ficha)) pintarEscribiendo();
|
||||
}
|
||||
|
||||
async function escribirDurante(ctx, ms) {
|
||||
var ficha = empezarEscribir();
|
||||
try { await ctx.esperar(ms); } finally { dejarEscribir(ficha); }
|
||||
}
|
||||
|
||||
/* ————————————————— estado de la demo ————————————————— */
|
||||
|
||||
var demoAndando = false;
|
||||
|
||||
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) {
|
||||
WAM.estado.mensajes.push(msg);
|
||||
WAM.guardar();
|
||||
if (typeof WAM.anadirMensajeDOM === "function") WAM.anadirMensajeDOM(msg);
|
||||
function cuerpoChat() { return document.getElementById("chat-cuerpo"); }
|
||||
|
||||
function selectorId(id) {
|
||||
return '.wa-row[data-id="' + String(id).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"]';
|
||||
}
|
||||
|
||||
function mensajeOut(texto) {
|
||||
return {
|
||||
id: WAM.generarId(),
|
||||
function filaDe(msg) {
|
||||
var cuerpo = cuerpoChat();
|
||||
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",
|
||||
tipo: "text",
|
||||
hora: WAM.horaAhora(),
|
||||
estado: "sent",
|
||||
texto: String(texto == null ? "" : texto)
|
||||
};
|
||||
if (cita) msg.cita = cita;
|
||||
return msg;
|
||||
}
|
||||
|
||||
/* 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 (TIPOS_VALIDOS.indexOf(copia.tipo) === -1) copia.tipo = "text";
|
||||
copia.id = WAM.generarId();
|
||||
copia.id = idSesion();
|
||||
copia.dir = "in";
|
||||
copia.hora = WAM.horaAhora();
|
||||
copia.estado = "sent";
|
||||
delete copia.cita;
|
||||
return copia;
|
||||
}
|
||||
|
||||
/* Ticks automáticos sent → delivered → read sobre la burbuja OUT indicada.
|
||||
Se localiza por data-id del msg (no "la última fila") para que dos envíos
|
||||
solapados avancen cada uno su propio tick. */
|
||||
/* ————————————————— cita de la respuesta (quoted reply) ————————————————— */
|
||||
|
||||
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) {
|
||||
var cuerpo = document.getElementById("chat-cuerpo");
|
||||
if (!cuerpo || typeof WAM.tick !== "function") return;
|
||||
if (!msg || typeof msg.id !== "string" || !msg.id) return;
|
||||
var seguro = msg.id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
var fila = cuerpo.querySelector('.wa-row[data-id="' + seguro + '"]');
|
||||
if (typeof WAM.tick !== "function") return;
|
||||
var fila = filaDe(msg);
|
||||
if (!fila || !fila.classList.contains("out")) return;
|
||||
var tick = fila.querySelector(".tick");
|
||||
if (tick && tick.parentNode) {
|
||||
/* WAM.tick devuelve un <svg> completo: se REEMPLAZA el nodo para no
|
||||
anidar un svg dentro de otro (se recortaría el segundo tick). */
|
||||
/* WAM.tick devuelve un <svg> completo: se REEMPLAZA el nodo. */
|
||||
var aux = document.createElement("span");
|
||||
aux.innerHTML = WAM.tick(estado);
|
||||
if (aux.firstChild) tick.parentNode.replaceChild(aux.firstChild, tick);
|
||||
}
|
||||
}
|
||||
|
||||
async function animarTics(msg) {
|
||||
await WAM.espera(800);
|
||||
/* Los mensajes de sesión no se guardan; si se cancelan los ticks, la
|
||||
burbuja salta a "read" para no quedar congelada en ✓ / ✓✓ gris. */
|
||||
async function animarTics(msg, ctx) {
|
||||
try {
|
||||
await ctx.esperar(800);
|
||||
msg.estado = "delivered";
|
||||
WAM.guardar();
|
||||
repintarTickDe(msg, "delivered");
|
||||
await WAM.espera(800);
|
||||
await ctx.esperar(800);
|
||||
msg.estado = "read";
|
||||
WAM.guardar();
|
||||
repintarTickDe(msg, "read");
|
||||
} catch (e) {
|
||||
if (msg.estado !== "read") {
|
||||
msg.estado = "read";
|
||||
repintarTickDe(msg, "read");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/* ————————————————— flujos ————————————————— */
|
||||
|
||||
/* Burbuja OUT con el texto (ticks auto), typing ~900 ms y, por cada respuesta,
|
||||
typing ~900 ms + anadirMensajeDOM (con scroll). Cancelable por detenerDemo(). */
|
||||
WAM.flujo = function (texto, respuestas) {
|
||||
var lista = Array.isArray(respuestas) ? respuestas : [];
|
||||
var secuencia = (async function () {
|
||||
try {
|
||||
var out = mensajeOut(texto);
|
||||
anadir(out);
|
||||
await animarTics(out);
|
||||
/* Respuestas encadenadas: typing ~900 ms + burbuja IN por cada una.
|
||||
En la demo (enDemo) cada respuesta con autoPulsar se pulsa sola. */
|
||||
async function responder(lista, ctx, enDemo) {
|
||||
for (var i = 0; i < lista.length; i++) {
|
||||
escribiendo(true);
|
||||
await WAM.espera(900);
|
||||
escribiendo(false);
|
||||
anadir(mensajeDesde(lista[i]));
|
||||
await escribirDurante(ctx, 900);
|
||||
if (!ctx.vivo()) throw CANCELADO;
|
||||
var r = mensajeDesde(lista[i]);
|
||||
anadirSesion(r);
|
||||
if (enDemo) await autoPulsar(r, ctx);
|
||||
}
|
||||
escribiendo(false);
|
||||
} catch (e) {
|
||||
escribiendo(false);
|
||||
if (e !== CANCELADO) { /* el mockup nunca ensucia la consola */ }
|
||||
}
|
||||
})();
|
||||
return secuencia;
|
||||
|
||||
/* 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 ctx = contexto("flujo");
|
||||
var out = mensajeOut(texto, cita || null);
|
||||
anadirSesion(out);
|
||||
var tics = animarTics(out, ctx);
|
||||
tics.catch(nada);
|
||||
var turno = colas.flujo.then(function () {
|
||||
if (!ctx.vivo()) throw CANCELADO;
|
||||
return tics.then(function () { return responder(lista, ctx, false); });
|
||||
});
|
||||
colas.flujo = turno.catch(nada);
|
||||
return turno.catch(nada);
|
||||
};
|
||||
|
||||
/* 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();
|
||||
if (!limpio) return;
|
||||
var out = mensajeOut(limpio);
|
||||
anadir(out);
|
||||
var tics = animarTics(out);
|
||||
if (typeof tics.catch === "function") tics.catch(function () { /* cancelado */ });
|
||||
anadirSesion(out);
|
||||
animarTics(out, contexto("flujo")).catch(nada);
|
||||
};
|
||||
|
||||
/* Resuelve { texto, respuestas } desde los data-* que estampa render.js:
|
||||
data-flujo (índice del mensaje) + data-btn (template/buttons),
|
||||
data-sec + data-fila (lista) o data-card (carousel). */
|
||||
/* Resuelve { texto, respuestas, cita, msg, tipo } desde los data-* que
|
||||
estampa render.js: data-btn (template/buttons), data-sec + data-fila
|
||||
(lista) o data-card (carousel). Etiquetas vacías = las del render. */
|
||||
WAM.resolverFlujo = function (el) {
|
||||
var vacio = { texto: "", respuestas: [] };
|
||||
if (!el || typeof el.getAttribute !== "function") return vacio;
|
||||
var indice = parseInt(el.getAttribute("data-flujo"), 10);
|
||||
var mensajes = (WAM.estado && WAM.estado.mensajes) ? WAM.estado.mensajes : [];
|
||||
var msg = isNaN(indice) ? null : mensajes[indice];
|
||||
if (!msg) return vacio;
|
||||
var vacioR = { texto: "", respuestas: [], cita: null, msg: null, tipo: "" };
|
||||
if (!el || typeof el.getAttribute !== "function") return vacioR;
|
||||
var msg = WAM.mensajeDeElemento(el);
|
||||
if (!msg) return vacioR;
|
||||
var ds = el.dataset || {};
|
||||
|
||||
if (ds.sec !== undefined || ds.fila !== undefined) {
|
||||
var seccion = (msg.secciones || [])[parseInt(ds.sec, 10)];
|
||||
var fila = ((seccion && seccion.filas) || [])[parseInt(ds.fila, 10)];
|
||||
return fila ? { texto: fila.titulo || "", respuestas: fila.respuestas || [] } : vacio;
|
||||
if (!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) {
|
||||
var boton = (msg.botones || [])[parseInt(ds.btn, 10)];
|
||||
return boton ? { texto: boton.texto || "", respuestas: boton.respuestas || [] } : vacio;
|
||||
var n = parseInt(ds.btn, 10);
|
||||
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) {
|
||||
var card = (msg.cards || [])[parseInt(ds.card, 10)];
|
||||
return card ? { texto: card.textoBoton || "", respuestas: card.respuestas || [] } : vacio;
|
||||
var c = parseInt(ds.card, 10);
|
||||
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
|
||||
(main.js lo detecta y llama WAM.abrirSheet); sin flujo directo. */
|
||||
return vacio;
|
||||
/* Elemento plano de un mensaje list: es el botón que abre la hoja. */
|
||||
return vacioR;
|
||||
};
|
||||
|
||||
/* 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 ————————————————— */
|
||||
|
||||
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 () {
|
||||
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;
|
||||
avisarDemo();
|
||||
var cuerpo = document.getElementById("chat-cuerpo");
|
||||
if (cuerpo) cuerpo.innerHTML = "";
|
||||
vaciarChat();
|
||||
var ctx = contexto("demo");
|
||||
var secuencia = ((WAM.estado && WAM.estado.mensajes) || []).slice();
|
||||
(async function () {
|
||||
try {
|
||||
for (var i = 0; i < secuencia.length; i++) {
|
||||
var msg = secuencia[i];
|
||||
if (!msg) continue;
|
||||
if (msg.dir === "in") {
|
||||
escribiendo(true);
|
||||
await WAM.espera(700);
|
||||
escribiendo(false);
|
||||
if (msg.dir === "in") await escribirDurante(ctx, 700);
|
||||
if (!ctx.vivo()) throw CANCELADO;
|
||||
pintar(msg);
|
||||
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) {
|
||||
/* cancelado: detenerDemo() / render() ya dejaron el chat coherente */
|
||||
}
|
||||
if (ctx.vivo()) {
|
||||
demoAndando = false;
|
||||
escribiendo(false);
|
||||
avisarDemo();
|
||||
}
|
||||
})();
|
||||
@@ -228,9 +645,38 @@ window.WAM = window.WAM || {};
|
||||
WAM.detenerDemo = function () {
|
||||
var estaba = demoAndando;
|
||||
demoAndando = false;
|
||||
cancelarTodo(); /* aborta también flujos y typing en curso */
|
||||
escribiendo(false);
|
||||
if (estaba && typeof WAM.render === "function") WAM.render();
|
||||
if (estaba) avisarDemo();
|
||||
cancelarGrupo("demo"); /* solo el grupo de la demo */
|
||||
if (estaba) {
|
||||
/* vuelve al guion completo (y descarta la sesión: ver reiniciarInteraccion) */
|
||||
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;
|
||||
}
|
||||
})();
|
||||
|
||||
+173
-24
@@ -1,7 +1,8 @@
|
||||
/* WAM · main.js — dueño D (núcleo).
|
||||
Bootstrap: carga el estado, aplica tema, renderiza chat y editor,
|
||||
y enlaza la delegación de clics del chat, el composer, la reproducción
|
||||
de la demo y la toolbar. Único script con ejecución al cargar. */
|
||||
de la demo (velocidad, reiniciar chat) y la toolbar. Único script con
|
||||
ejecución al cargar. */
|
||||
window.WAM = window.WAM || {};
|
||||
(function () {
|
||||
"use strict";
|
||||
@@ -15,23 +16,97 @@ window.WAM = window.WAM || {};
|
||||
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" ————————————————— */
|
||||
|
||||
function pintarBotonDemo() {
|
||||
var btn = porId("btn-reproducir");
|
||||
if (!btn) return;
|
||||
var activa = WAM.demoActiva();
|
||||
btn.setAttribute("aria-pressed", activa ? "true" : "false");
|
||||
var span = btn.querySelector("span");
|
||||
if (!span) return;
|
||||
if (!span.dataset.etiqueta) span.dataset.etiqueta = span.textContent;
|
||||
span.textContent = WAM.demoActiva() ? "■ Detener" : span.dataset.etiqueta;
|
||||
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() {
|
||||
var ahora = Date.now();
|
||||
if (ahora - ultimoReproducir < 350) return;
|
||||
ultimoReproducir = ahora;
|
||||
if (WAM.demoActiva()) WAM.detenerDemo();
|
||||
else WAM.reproducirDemo();
|
||||
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 ————————————————— */
|
||||
|
||||
function alTema() {
|
||||
@@ -50,19 +125,25 @@ window.WAM = window.WAM || {};
|
||||
}
|
||||
|
||||
function reRenderTotal() {
|
||||
WAM.setTema(WAM.estado.tema);
|
||||
WAM.render();
|
||||
WAM.renderEditor();
|
||||
paso(function () { WAM.setTema(WAM.estado.tema); });
|
||||
paso(function () { WAM.render(); });
|
||||
paso(function () { WAM.renderEditor(); });
|
||||
pintarBotonDemo();
|
||||
}
|
||||
|
||||
function alExportar() {
|
||||
if (typeof WAM.guardarYa === "function") WAM.guardarYa();
|
||||
if (typeof WAM.exportarJSON === "function") WAM.exportarJSON();
|
||||
}
|
||||
|
||||
function alImportar() {
|
||||
var abrir = function () {
|
||||
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) {
|
||||
@@ -71,15 +152,29 @@ window.WAM = window.WAM || {};
|
||||
input.value = ""; /* permite reimportar el mismo archivo */
|
||||
if (!archivo || typeof WAM.importarJSON !== "function") return;
|
||||
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") {
|
||||
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 {
|
||||
reRenderTotal();
|
||||
}
|
||||
}
|
||||
|
||||
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.resetear();
|
||||
reRenderTotal();
|
||||
@@ -87,39 +182,76 @@ window.WAM = window.WAM || {};
|
||||
|
||||
/* ————————————————— 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) {
|
||||
if (bloquearEnlaceInseguro(e)) return;
|
||||
var objetivo = e.target;
|
||||
var el = (objetivo && objetivo.closest) ? objetivo.closest("[data-flujo]") : null;
|
||||
if (!el) return;
|
||||
e.preventDefault(); /* protege frente a botones montados sobre <a href="#"> */
|
||||
var ds = el.dataset || {};
|
||||
if (ds.fila !== undefined) return; /* filas del sheet: se atienden en #pantalla */
|
||||
if (el.classList.contains("usado") || el.getAttribute("aria-disabled") === "true") return;
|
||||
|
||||
var indice = parseInt(el.getAttribute("data-flujo"), 10);
|
||||
var msg = (!isNaN(indice) && WAM.estado && WAM.estado.mensajes) ? WAM.estado.mensajes[indice] : null;
|
||||
var plano = ds.btn === undefined && ds.sec === undefined &&
|
||||
ds.fila === undefined && ds.card === undefined;
|
||||
var msg = WAM.mensajeDeElemento(el);
|
||||
var plano = ds.btn === undefined && ds.sec === undefined && ds.card === undefined;
|
||||
|
||||
if (plano && msg && msg.tipo === "list") {
|
||||
if (typeof WAM.abrirSheet === "function") WAM.abrirSheet(indice);
|
||||
if (plano) {
|
||||
if (msg && msg.tipo === "list") WAM.abrirLista(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
var objetivo = e.target;
|
||||
var fila = (objetivo && objetivo.closest) ? objetivo.closest("[data-fila]") : null;
|
||||
if (!fila) return;
|
||||
if (!objetivo || !objetivo.closest) return;
|
||||
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();
|
||||
var flujo = WAM.resolverFlujo(fila);
|
||||
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet();
|
||||
if (flujo && flujo.texto) WAM.flujo(flujo.texto, flujo.respuestas);
|
||||
WAM.cerrarSheet();
|
||||
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 ————————————————— */
|
||||
@@ -137,17 +269,34 @@ window.WAM = window.WAM || {};
|
||||
|
||||
/* ————————————————— 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.estado = WAM.cargar();
|
||||
WAM.setTema(WAM.estado.tema);
|
||||
WAM.render();
|
||||
WAM.renderEditor();
|
||||
WAM.onErrorGuardar = function (texto) { WAM.avisar(texto); };
|
||||
paso(function () { WAM.estado = WAM.cargar(); });
|
||||
if (!WAM.estado) WAM.estado = WAM.normalizar(WAM.datosDemo());
|
||||
paso(function () { WAM.setTema(WAM.estado.tema); });
|
||||
paso(function () { WAM.render(); });
|
||||
paso(function () { WAM.renderEditor(); });
|
||||
|
||||
WAM.onDemoChange = pintarBotonDemo;
|
||||
al("chat-cuerpo", "click", alClicChat);
|
||||
al("pantalla", "click", alClicPantalla);
|
||||
al("pantalla", "keydown", alTeclaPantalla);
|
||||
al("composer", "submit", alComposer);
|
||||
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-presentacion", "click", alPresentacion);
|
||||
al("btn-exportar", "click", alExportar);
|
||||
|
||||
+403
-310
File diff suppressed because it is too large
Load Diff
+212
-48
@@ -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 () {
|
||||
var conjunto = new Set();
|
||||
var conjunto = new Set(entregados);
|
||||
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 ————————————————— */
|
||||
|
||||
var CAMPOS = {
|
||||
@@ -159,65 +207,109 @@ window.WAM = window.WAM || {};
|
||||
|
||||
/* ————————————————— normalización ————————————————— */
|
||||
|
||||
/* Rellena los campos que faltan por tipo y devuelve un estado válido.
|
||||
Usado por cargar() y por editor.importarJSON (import / upgrade). */
|
||||
WAM.normalizar = function (o) {
|
||||
var src = (o && typeof o === "object" && !Array.isArray(o)) ? o : {};
|
||||
/* Profundidad máxima de respuestas encadenadas (defensa ante JSON hostil). */
|
||||
var MAX_PROFUNDIDAD = 12;
|
||||
var MAX_ITEMS = 10;
|
||||
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();
|
||||
recolectarIds(src, conjunto);
|
||||
recolectarIds(WAM.estado, conjunto); /* evita colisiones con lo vivo */
|
||||
var tomar = crearAlocador(conjunto);
|
||||
var reclamados = new Set();
|
||||
|
||||
function normalizarMensaje(msj, forzarEntrada) {
|
||||
var m = (msj && typeof msj === "object") ? msj : {};
|
||||
function idDe(v) {
|
||||
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 msg = {
|
||||
id: (typeof m.id === "string" && m.id) ? m.id : tomar(),
|
||||
id: idDe(m.id),
|
||||
dir: (!forzarEntrada && m.dir === "out") ? "out" : "in",
|
||||
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"
|
||||
};
|
||||
|
||||
function respuestas(valor) {
|
||||
var lista = Array.isArray(valor) ? valor : [];
|
||||
return lista.map(function (r) { return normalizarMensaje(r, true); });
|
||||
if (profundidad >= MAX_PROFUNDIDAD) return [];
|
||||
return lista(valor, 20).map(function (r) { return normalizarMensaje(r, true, profundidad + 1); });
|
||||
}
|
||||
|
||||
function botones(valor) {
|
||||
var lista = Array.isArray(valor) ? valor : [];
|
||||
return lista.map(function (b) {
|
||||
var boton = (b && typeof b === "object") ? b : {};
|
||||
return lista(valor).map(function (b) {
|
||||
var boton = (b && typeof b === "object") ? b : { texto: typeof b === "string" ? b : "" };
|
||||
var out = { texto: cad(boton.texto), respuestas: respuestas(boton.respuestas) };
|
||||
/* Botón URL de plantilla (research-api §7.2): se conserva si viene */
|
||||
if (typeof boton.url === "string") out.url = boton.url;
|
||||
if (typeof boton.url === "string") out.url = urlLimpia(boton.url, "enlace");
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function autoPulsar() {
|
||||
var n = indiceAuto(m.autoPulsar);
|
||||
if (n !== null) msg.autoPulsar = n;
|
||||
}
|
||||
|
||||
switch (tipo) {
|
||||
case "text":
|
||||
msg.texto = cad(m.texto);
|
||||
break;
|
||||
case "image":
|
||||
case "video":
|
||||
msg.src = cad(m.src);
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
msg.caption = cad(m.caption);
|
||||
break;
|
||||
case "audio":
|
||||
msg.src = cad(m.src);
|
||||
msg.voz = m.voz === true;
|
||||
msg.duracion = cad(m.duracion, "0:00");
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
msg.voz = m.voz === true || m.voz === "true";
|
||||
msg.duracion = cad(m.duracion, "0:00") || "0:00";
|
||||
break;
|
||||
case "document":
|
||||
msg.src = cad(m.src);
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
msg.caption = cad(m.caption);
|
||||
msg.filename = cad(m.filename, "documento.pdf");
|
||||
msg.filename = cad(m.filename, "documento.pdf") || "documento.pdf";
|
||||
break;
|
||||
case "sticker":
|
||||
msg.src = cad(m.src);
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
break;
|
||||
case "location":
|
||||
msg.latitud = cad(m.latitud);
|
||||
@@ -232,32 +324,26 @@ window.WAM = window.WAM || {};
|
||||
msg.empresa = cad(m.empresa);
|
||||
break;
|
||||
case "template":
|
||||
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
|
||||
msg.headerTexto = cad(m.headerTexto);
|
||||
msg.headerMedia = cad(m.headerMedia);
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.botones = botones(m.botones);
|
||||
break;
|
||||
case "buttons":
|
||||
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
|
||||
msg.headerTexto = cad(m.headerTexto);
|
||||
msg.headerMedia = cad(m.headerMedia);
|
||||
msg.headerMedia = urlLimpia(m.headerMedia, "media");
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.botones = botones(m.botones);
|
||||
autoPulsar();
|
||||
break;
|
||||
case "list":
|
||||
msg.header = cad(m.header);
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.boton = cad(m.boton);
|
||||
msg.secciones = (Array.isArray(m.secciones) ? m.secciones : []).map(function (s) {
|
||||
msg.secciones = lista(m.secciones).map(function (s) {
|
||||
var seccion = (s && typeof s === "object") ? s : {};
|
||||
return {
|
||||
titulo: cad(seccion.titulo),
|
||||
filas: (Array.isArray(seccion.filas) ? seccion.filas : []).map(function (f) {
|
||||
var fila = (f && typeof f === "object") ? f : {};
|
||||
filas: lista(seccion.filas).map(function (f) {
|
||||
var fila = (f && typeof f === "object") ? f : { titulo: typeof f === "string" ? f : "" };
|
||||
return {
|
||||
titulo: cad(fila.titulo),
|
||||
descripcion: cad(fila.descripcion),
|
||||
@@ -266,28 +352,39 @@ window.WAM = window.WAM || {};
|
||||
})
|
||||
};
|
||||
});
|
||||
autoPulsar();
|
||||
break;
|
||||
case "cta_url":
|
||||
msg.headerMedia = cad(m.headerMedia);
|
||||
msg.headerMedia = urlLimpia(m.headerMedia, "media");
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.textoBoton = cad(m.textoBoton);
|
||||
msg.url = cad(m.url);
|
||||
msg.url = urlLimpia(m.url, "enlace");
|
||||
break;
|
||||
case "carousel":
|
||||
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 : {};
|
||||
return {
|
||||
media: cad(card.media),
|
||||
media: urlLimpia(card.media, "media"),
|
||||
texto: cad(card.texto),
|
||||
textoBoton: cad(card.textoBoton),
|
||||
url: cad(card.url),
|
||||
url: urlLimpia(card.url, "enlace"),
|
||||
respuestas: respuestas(card.respuestas)
|
||||
};
|
||||
});
|
||||
autoPulsar();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -297,18 +394,48 @@ window.WAM = window.WAM || {};
|
||||
tema: src.tema === "oscuro" ? "oscuro" : "claro",
|
||||
cuenta: {
|
||||
nombre: cad(cuentaSrc.nombre, "Café Aurora"),
|
||||
avatar: cad(cuentaSrc.avatar),
|
||||
avatar: urlLimpia(cuentaSrc.avatar, "media"),
|
||||
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) {
|
||||
return normalizarMensaje(x, false);
|
||||
mensajes: lista(src.mensajes, 500).map(function (x) {
|
||||
return normalizarMensaje(x, false, 0);
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
/* ————————————————— 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 () {
|
||||
var bruto = null;
|
||||
try { bruto = window.localStorage.getItem(WAM.CLAVE); } catch (e) { bruto = null; }
|
||||
@@ -316,20 +443,57 @@ window.WAM = window.WAM || {};
|
||||
if (bruto) {
|
||||
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);
|
||||
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 () {
|
||||
try {
|
||||
window.localStorage.setItem(WAM.CLAVE, JSON.stringify(WAM.estado));
|
||||
} catch (e) { /* sin localStorage disponible (cuota/file://): se ignora */ }
|
||||
if (temporizador) { pendiente = true; return; }
|
||||
escribir();
|
||||
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 () {
|
||||
if (temporizador) { clearTimeout(temporizador); temporizador = null; }
|
||||
pendiente = false;
|
||||
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());
|
||||
avisoEmitido = false;
|
||||
};
|
||||
|
||||
/* ————————————————— demo por defecto: "Café Aurora" ————————————————— */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Generated
+60
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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})`);
|
||||
});
|
||||
Reference in New Issue
Block a user