# SPEC — WhatsApp Cloud API Mockup (demo visual 100 % estática) Web estática: `index.html` + `css/*.css` + `js/*.js`. Sin frameworks, sin build, sin módulos ES, sin `fetch()` de archivos locales; funciona abriendo `index.html` por `file://` con doble clic. Único global: `window.WAM` (cada JS hace `window.WAM = window.WAM || {}` y añade propiedades). Estado único y vivo en `WAM.estado`; tras toda mutación: `WAM.guardar()` + re-render. Bases: `docs/research-api.md` (payloads y límites) y `docs/research-ui.md` (colores, burbujas, marco). UI en español. ## 1. Modelo de datos (`WAM.estado`, JSON serializable → localStorage / export) ```json { "version": 1, "tema": "claro", "cuenta": { "nombre": "Café Aurora", "avatar": "", "subtitulo": "en línea", "verificado": true, "avisoCifrado": true }, "mensajes": [ ] } ``` Sobre común de CADA mensaje (`Mensaje`): `{ "id": "m1", "dir": "in"|"out", "tipo": "", "hora": "10:24" (texto literal HH:MM), "estado": "sent"|"delivered"|"read" }`. `estado` solo se renderiza si `dir:"out"` (ticks abajo-derecha); por defecto `"sent"`. Campos EXACTOS por `tipo` (los que faltan no aplican; `respuestas` = array de `Mensaje` completos con `dir:"in"`, anidados, NO se renderizan en el chat: los consume la interactividad): 1. `text`: `texto` 2. `image`: `src`, `caption` 3. `video`: `src`, `caption` 4. `audio`: `src` (opcional), `voz` (bool: nota de voz), `duracion` ("0:12") 5. `document`: `src` (ignorado), `caption`, `filename` (con extensión; el icono depende de ella) 6. `sticker`: `src` (WebP/imagen; sin burbuja de texto ni hora dentro) 7. `location`: `latitud`, `longitud`, `nombre`, `direccion` 8. `contact`: `nombre` (formatted_name), `telefono`, `email`, `empresa` 9. `template`: `headerTipo` (""|"texto"|"imagen"|"video"|"documento"), `headerTexto`, `headerMedia`, `body`, `footer`, `botones`: `[ { "texto", "url" (opcional, ≤2 con url), "respuestas": [Mensaje…] } ]` 10. `buttons` (quick replies, máx 3): `headerTipo` (""|"texto"|"imagen"|"video"|"documento"), `headerTexto`, `headerMedia`, `body`, `footer`, `botones`: `[ { "texto", "respuestas" } ]` 11. `list`: `header`, `body`, `footer`, `boton` (etiqueta ≤20), `secciones`: `[ { "titulo", "filas": [ { "titulo", "descripcion", "respuestas" } ] } ]` 12. `cta_url`: `headerMedia` ("" = sin header), `body`, `footer`, `textoBoton`, `url` 13. `carousel` (2–5 cards): `body`, `cards`: `[ { "media", "texto", "textoBoton", "url", "respuestas" } ]` Convenciones: `src`/`avatar`/`headerMedia`/`media` = dataURL o URL remota; `""` → placeholder SVG de `WAM.svgPlaceholder(texto)`. Botones con `url` no vacía se renderizan como `` (cta_url, template URL, card de carousel); sin `url` → flujo interactivo con `respuestas`. Botón de plantilla o card con URL no permitida: `normalizar` deja `url: ""` y añade `urlBloqueada: true` (sigue siendo un botón de enlace, inerte `.qr-btn-inactiva` en render.js; nunca un quick reply con flujo; `resolverFlujo` lo ignora). Editar a mano el campo URL en el editor retira la marca. `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. El editor lo mantiene al reordenar o borrar botones / filas / cards (sigue al elemento; se quita si se borró el pulsado). **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; 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). Lo recortado (más de 500 mensajes, 10 botones/secciones/filas/cards, 20 respuestas o 12 niveles) queda en `WAM.ultimosRecortes` y en `estado.__recortes` (no enumerable): `{ mensajes, mensajesOriginales, botones, secciones, filas, cards, respuestas, profundidad }`. - `WAM.describirRecortes(r?)` → string en español para el toast de importar («Se importaron 500 de 600 mensajes; se recortaron 3 respuestas…») o `""` si no se recortó nada (por defecto `WAM.ultimosRecortes`) - `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: `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` (+ `.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 | Mensaje)` / `WAM.cerrarSheet()` → void (hoja de lista en `#pantalla`: título = `boton`, filas `button.sheet-fila[data-sec][data-fila]`, botón `.sheet-cerrar`) - `WAM.abrirOpciones(i | Mensaje, li?)` → void: hoja "Todas las opciones" de un mensaje con >3 botones (la abre el propio B al pulsar `[data-ver-todas]`). Sus filas de respuesta llevan `data-opcion=""` y B reenvía el clic al `[data-btn=""]` oculto de la burbuja, que atiende la delegación normal de main.js (busca la fila viva en el momento del clic). `renderMensaje` de un mensaje cuya hoja está abierta la cierra (re-render parcial del editor), así la hoja nunca queda desfasada. - `scrollAbajo()` es instantáneo (ignora `scroll-behavior`) y deja el chat "pegado abajo": mientras el usuario no suba (>60 px del fondo), un ResizeObserver sobre `#chat-cuerpo` y sus filas vuelve a bajar cuando algo crece (imágenes que decodifican tarde, pestaña Vista previa que pasa a visible). - Documento: campo opcional `tamano` (y `headerTamano` en la cabecera de plantilla) → subtítulo "243 KB • pdf"; sin él, solo la extensión en mayúsculas ("PDF"). - Hooks de CSS (css/iphone.css): `body[data-wam-zoom="100"|"125"]` fija la escala del teléfono (sin atributo = ajuste automático), solo ≥ 901 px y fuera de Presentación. Lo pone `#sel-zoom` (ux.js, `WAM.setZoom(v)`, persistido en `localStorage["wam-zoom"]`); en ≤ 520 px el teléfono va sin marco salvo con `body.con-marco`. - `WAM.tick(estado)` → string SVG inline (✓ / ✓✓ del color de la hora; ✓✓ leído azul iOS `#027bfc`) - Botones del teléfono: `