Estado inicial del mockup

This commit is contained in:
urieljareth
2026-09-27 19:17:33 -06:00
commit dc6e31a9d7
15 changed files with 6590 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
test-results/
playwright-report/
.zcode/
*.log
+129
View File
@@ -0,0 +1,129 @@
# WhatsApp Cloud API · Mockup
Plataforma de mockups de la **WhatsApp Cloud API** para crear demos visuales de conversaciones tal y como se ven en un **iPhone**: un editor en pantalla y, al lado, un teléfono con Dynamic Island, status bar y un chat de WhatsApp que replica los 13 tipos de mensaje de la API (plantillas, botones rápidos, listas, carruseles…).
Es una web **100 % estática**: sin frameworks, sin build, sin servidor y sin dependencias de red obligatorias. Todo el estado vive en tu navegador.
---
## Cómo abrirla
1. **Doble clic en `index.html`** (o arrástralo a Chrome/Edge/Firefox).
2. Listo. No hace falta servidor web, instalación ni conexión a internet.
Al abrirlo verás dos columnas: a la izquierda el **editor**, a la derecha la **vista previa** del iPhone con la demo de ejemplo **«Café Aurora» ☕**, que recorre los 13 tipos de mensaje con flujos interactivos.
### Estructura del proyecto
```
index.html → página única; ábrela con doble clic
css/ → base, iphone, whatsapp (chat) y editor
js/ → store, render, editor, interactivity, main
docs/ → research-api.md (payloads y límites) y research-ui.md (estilos)
```
---
## Editor (columna izquierda)
### Identidad de la cuenta
En el bloque **Cuenta** configuras lo que se ve en la cabecera del chat:
- **Nombre** del negocio (encabezado del chat).
- **Subtítulo** (p. ej. «en línea»); cuando el chat «está escribiendo» se cambia temporalmente por *escribiendo…* en verde.
- **Avatar**: súbelo desde un archivo (se convierte a dataURL) o pega una URL/dataURL; también puedes quitarlo (se usa un placeholder SVG).
- **Cuenta verificada**: activa o desactiva la insignia azul junto al nombre.
- **Tema**: claro u oscuro (también con el botón *Tema* de la barra superior; se guarda con el resto del estado).
### Añadir, editar y reordenar mensajes
- La **paleta de tipos** (tarjetas con icono, estilo Meta) + botón **Añadir** crea un mensaje nuevo del tipo seleccionado.
- Cada mensaje es una **tarjeta colapsable** con un resumen; haz clic para desplegar sus campos.
- En cada tarjeta puedes cambiar los campos comunes: **Dirección** (Entrada/Salida), **Hora** (texto literal `HH:MM`, se muestra tal cual) y, solo en los salientes, **Estado (ticks)**: Enviado ✓ / Entregado ✓✓ / Leído ✓✓ (celeste).
- Acciones por tarjeta: **↑ / ↓** reordenar, **⧉** duplicar (con IDs regenerados) y **✕** borrar (pide confirmación).
- Todo cambio se guarda al instante y se refleja en el teléfono. El botón **Restablecer** de la barra superior borra `localStorage` y restaura la demo «Café Aurora».
### Campos por tipo de mensaje
| Tipo | Campos editables |
|---|---|
| Texto | texto (admite formato WhatsApp: `*negrita*`, `_cursiva_`, `~tachado~`, `` ` ```monoespaciado``` ` ` y URLs clicables) |
| Imagen / Vídeo | archivo o URL + caption opcional |
| Audio | archivo opcional, casilla **nota de voz** (waveform + micro) y duración (`0:12`) |
| Documento | nombre de archivo con extensión (define el color del icono: pdf, docx, xlsx…), caption y archivo opcional |
| Sticker | imagen/WebP (se muestra sin burbuja ni hora) |
| Ubicación | latitud, longitud, nombre y dirección (el mapa es un gráfico SVG con pin) |
| Contacto | nombre, teléfono, email y empresa |
| Plantilla | cabecera (texto, imagen, vídeo o documento), cuerpo, pie y hasta **10 botones** (con o sin URL) |
| Botones rápidos | cabecera opcional, cuerpo, pie y hasta **3 botones** de respuesta |
| Lista | cabecera, cuerpo, pie, etiqueta del botón (≤ 20 caracteres) y **secciones con filas** (título ≤ 24, descripción ≤ 72) |
| Botón URL | cabecera opcional (vacía = sin cabecera), cuerpo, pie, texto del botón y URL |
| Carrusel | cuerpo opcional y de **2 a 5 tarjetas** (imagen, texto, botón y respuestas) |
Los campos de imagen/vídeo (y el resto de medios) ofrecen dos orígenes con radios **URL / Subir archivo**; en modo archivo, una zona punteada acepta clic o arrastrar-y-soltar. Si quedan vacíos se muestra un placeholder SVG, sin necesidad de red.
### Drag & drop y ayuda rápida
- **Reordenar arrastrando**: cada tarjeta de mensaje tiene un asa (⠿) a la izquierda de su cabecera; arrástrala sobre otra tarjeta y suéltala encima o debajo — una línea azul marca el punto de inserción. Dentro de un mensaje también se arrastran los **botones** (plantilla y rápidos), las **secciones y filas** de la lista y las **tarjetas del carrusel**. Las flechas **↑ / ↓** siguen funcionando igual.
- **Soltar archivos**: en cualquier campo de media (avatar, imagen, vídeo, sticker, audio, cabecera de plantilla, tarjetas del carrusel) eliges el origen con los radios **URL / Subir archivo**; en modo archivo, la zona de borde punteado abre el selector con un clic o acepta archivos **arrastrados desde el explorador** (se convierten a dataURL y se guardan con el estado).
- **Contadores de límites**: los campos con límite oficial muestran `n / límite` alineado a la derecha bajo el campo (cuerpo de texto 4096, captions 1024, cabecera/pie 60, botón de plantilla 25, quick reply 20, botón de lista 20, títulos 24, descripción de fila 72, texto de tarjeta 160…). Se ponen **ámbar al 90 %** y **rojo al pasarse**, sin cortar nunca la escritura.
- **Barra de formato**: en los cuerpos grandes (texto, plantilla y lista), los botones **B / I / S / </>** envuelven la selección del textarea con el marcado de WhatsApp (`*negrita*`, `_cursiva_`, `~tachado~`, `` `monoespaciado` ``), manteniendo el cursor en su sitio.
- **Variables de plantilla**: el botón **«+ Añadir variable»** (cuerpo y cabecera de texto de plantillas) inserta `{{1}}`, `{{2}}`… en el cursor, contando las que ya contiene el texto.
- **Cabecera opcional**: en plantillas y botones rápidos, el interruptor **Mostrar cabecera** despliega las mini-tarjetas Texto / Imagen / Vídeo / Documento y sus campos (texto de cabecera o media).
### Flujos con respuestas encadenadas
La gracia de los mockups interactivos: dentro de cada botón (de plantilla o rápido), fila de lista o tarjeta de carrusel hay un sub-editor **«Respuestas»** donde añades mensajes entrantes que llegarán al pulsarlo. Al hacer clic en el botón en el teléfono:
1. Sale tu burbuja con el **texto del botón** (ticks ✓ → ✓✓ → azul, ~800 ms cada paso).
2. Aparece el indicador **escribiendo…** (~900 ms).
3. Llegan las respuestas encadenadas una a una, con scroll al fondo.
Si un botón no tiene respuestas, al pulsarlo solo sale la burbuja con su texto. Si un botón tiene **URL**, en lugar de flujo se abre el enlace en una pestaña nueva (igual que el botón URL y las tarjetas con URL).
### Exportar e importar JSON
- **Exportar** (barra superior) descarga `wam-demo.json` con todo el estado: cuenta, tema y mensajes (incluidas las respuestas anidadas).
- **Importar** carga ese archivo: se normaliza (rellena campos que falten y corrige tipos inválidos), se guarda y se re-renderiza todo. Ideal para compartir una demo o hacer copias de seguridad, ya que `localStorage` puede perderse al limpiar el navegador.
---
## Modo demo (columna derecha)
- **Reproducir demo**: vacía el chat y revela los mensajes uno a uno (~600 ms por mensaje, con «escribiendo…» antes de cada mensaje entrante), como si la conversación ocurriera en directo. Pulsar de nuevo (**■ Detener**) lo cancela.
- **Mensajes libres**: escribe en el campo *Mensaje* del composer y envía (Enter o botón): sale una burbuja con la **hora actual** y ticks automáticos ✓ → ✓✓ → ✓✓ azul.
- **Hoja de lista (sheet)**: al pulsar el botón de un mensaje de tipo *Lista* se abre la hoja inferior con las secciones y filas; toca una fila para disparar su flujo, o toca el fondo oscuro para cerrarla.
- **Modo presentación**: oculta el editor y deja solo el iPhone centrado, perfecto para pantallazos o para proyectar. Se alterna con el botón *Presentación* de la barra superior.
- **Tema**: alterna claro/oscuro en toda la app (editor y chat).
---
## Tipos de mensaje soportados (13)
`text` · `image` · `video` · `audio` · `document` · `sticker` · `location` · `contact` · `template` · `buttons` (quick replies) · `list` · `cta_url` · `carousel`
Detalles fieles a la API real (ver `docs/research-api.md`): máx. 3 quick replies, plantillas con hasta 10 botones (con más de 3 se muestran 2 y el plegado *«Ver todas las opciones»*), filas de lista con título y descripción, carrusel de 2 a 5 tarjetas con scroll y puntos indicadores, ticks de entrega solo en mensajes salientes, etc.
---
## Limitaciones
- **Persistencia por `localStorage`** (clave `wam-mockup-v1`), con una cuota de **~5 MB** según el navegador. Las imágenes y vídeos guardados como dataURL ocupan mucho (~33 % más que el archivo original), así que con varios medios grandes puedes agotarla; si eso pasa, los cambios simplemente dejan de guardarse (la app sigue funcionando). Exporta a JSON para no perder trabajo. Nota: al abrir por `file://`, Chrome/Edge/Firefox comparten el mismo origen, por lo que todas las demos locales comparten ese almacenamiento.
- **No envía mensajes reales**: es un mockup visual; no hay llamadas a la API de WhatsApp, números de teléfono ni tokens. Los botones con URL abren enlaces, nada más.
- **Las imágenes subidas se guardan como dataURL** (base64 dentro del propio JSON del estado). Eso hace el estado autosuficiente y portable en el export, pero infla el tamaño del JSON y de `localStorage`. Para medios pesados, usa URL remotas.
- Los iconos del composer (clip y micro) y los de llamada del encabezado son decorativos: no hacen nada.
- Vídeos sin URL/archivo se representan con una imagen placeholder (un `<video>` no puede mostrar un SVG).
---
## Consejos para capturas limpias
1. Activa el **modo presentación** para ocultar el editor y quedarte solo con el iPhone.
2. Elige el **tema** (claro u oscuro) según el fondo donde vayas a incrustar la captura.
3. Ajusta la **hora de cada mensaje** a mano (`10:24`, `9:41`…) para que la conversación se vea creíble y coherente.
4. Fija el **estado de ticks** de tus mensajes salientes (p. ej. todos en *Leído* ✓✓ azul) antes de capturar; si usas el composer, los ticks avanzan solos en ~1,6 s.
5. Usa **Reproducir demo** para grabar la pantalla mientras la conversación aparece paso a paso, o haz clic en los botones/filas para capturar el momento exacto del flujo.
6. Escribe el último mensaje desde el **composer** para que lleve la hora real de la captura.
7. Usa el **zoom del navegador** (Ctrl/⌘ + +) para agrandar el iPhone antes de la captura; todo escala limpiamente.
8. Antes de una demo ante público, **Restablecer** deja la conversación de «Café Aurora» en su estado original.
+196
View File
@@ -0,0 +1,196 @@
# 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
},
"mensajes": [ ]
}
```
Sobre común de CADA mensaje (`Mensaje`): `{ "id": "m1", "dir": "in"|"out",
"tipo": "<ver union>", "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 `<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).
## 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.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.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.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`)
### 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)
### 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
(~600 ms; mensaje `in` precedido de typing ~700 ms; cancelable)
- `WAM.espera(ms)` → Promise cancelable (todo timer pasa por aquí para poder detener)
### js/main.js (dueño D) — bootstrap
- `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`)
### IDs DOM (el HTML lo escribe A; el contenido lo rellena B o C en runtime)
| ID | Rellena | Papel |
|---|---|---|
| `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 |
| `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 |
| `composer` (form), `composer-input`, `composer-enviar` | A estructura / D lógica | campo "Mensaje" + enviar |
| `sheet`, `sheet-fondo`, `sheet-titulo`, `sheet-opciones` | B crea/desstruye | modal de selección de lista |
Contrato de clics B→D (delegación en `#chat-cuerpo`): B estampa en cada elemento interactivo
`data-flujo="<índice del mensaje>"` y además `data-btn` (template/buttons), `data-sec`+`data-fila` (list),
`data-card` (carousel). D resuelve con `resolverFlujo(el)` y llama `flujo()`. Los `<a>` de URL no llevan flujo.
### Convención CSS
- `css/base.css` (A): reset, layout grid de `#app`, `.toolbar`, variables `--wam-*` (fondo, panel, texto,
borde, acento) con overrides en `body[data-tema="oscuro"]`; `body.presentacion` oculta `#panel-editor`.
- `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`,
`.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`).
- 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`)
```html
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/iphone.css">
<link rel="stylesheet" href="css/whatsapp.css">
<link rel="stylesheet" href="css/editor.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/main.js"></script> <!-- única ejecución: WAM.init() -->
```
Ningún archivo lee `WAM.estado` en carga (solo dentro de funciones) → el orden garantiza cero
`undefined` y cero errores por `file://`.
## 4. División de trabajo (archivos sin solapar)
**A — shell:** `index.html` + `css/base.css` + `css/iphone.css`. HTML completo con TODOS los IDs de la
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-*`.
**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
icono por tipo; el valor elegido se guarda en `#nuevo-tipo-valor`), tarjetas por mensaje con TODOS los
campos del §1 (incluye sub-editor de `botones`/`secciones`/`cards` y sus `respuestas`), reordenar
(drag & drop por asa o flechas ↑/↓)/duplicar/borrar, exportar/importar JSON. Mutación solo vía
`setCampo` + `guardar` + `render`.
**D — núcleo:** `js/store.js` + `js/interactivity.js` + `js/main.js`. Demo por defecto (§6), localStorage,
`normalizar`, delegación de clics del chat, composer, `flujo` con typing y retrasos, ticks automáticos,
reproducir/detener demo, toggle tema/presentación/export/import/reset, bootstrap.
Regla de convivencia: la única API entre módulos son las funciones `WAM.*` y los IDs/data-* de esta SPEC.
Prohibido `document.getElementById` de IDs ajenos a tu panel salvo los listados aquí.
## 5. Criterios de aceptación (Chrome/Edge, abriendo por `file://`)
1. Doble clic en `index.html` → la demo carga SIN ningún error ni warning de consola.
2. Se renderizan los 13 tipos del §6 con su estructura esperada (burbuja+cola, hora, ticks en OUT,
botones apilados, sheet funcional, carousel desplazable).
3. Clic en quick reply / fila de lista / botón de plantilla / botón de card → burbuja OUT con ese texto,
indicador "escribiendo…" ~900 ms y llegada de las `respuestas` encadenadas con scroll al fondo.
4. `#composer-input` + Enter/enviar → mensaje libre OUT con hora actual y ticks sent→delivered→read.
5. "Reproducir demo" vacía el chat y lo revela paso a paso (~600 ms/mensaje, typing antes de cada IN);
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.
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
de la demo son opcionales y su ausencia degrada al placeholder).
## 6. Demo por defecto (D, en `datosDemo()`): "Café Aurora" ☕ — recorre los 13 tipos
IN text saludo · OUT text cliente · IN image (caption "Nuevo horario") · IN location (la cafetería) ·
IN contact (barista) · IN document (menu.pdf) · IN audio voz 0:12 · IN sticker · IN template
(header imagen, body confirmación de reserva, footer, botones "Confirmar"/"Cambiar" con respuestas) ·
IN buttons (body "¿Qué te muestro?", botones "Horarios"/"Promociones"/"Hablar" — 1-2 respuestas cada uno) ·
IN list (botón "Ver opciones", secciones Bebidas/Postres, filas con descripción y respuestas) ·
IN cta_url ("Abrir tienda") · IN carousel (3 cards "Los favoritos" con botón y respuestas).
Medias: URLs de picsum.photos con seed fija; textos cortos españoles creíbles. Verificados los límites
del research-api (≤3 quick replies, títulos 20/24, body 1024, footer 60…).
+210
View File
@@ -0,0 +1,210 @@
/* =====================================================================
WhatsApp Cloud API Mockup — css/base.css (Módulo A · shell)
Reset, variables --wam-*, layout grid de #app y barra superior (.toolbar).
El tema del shell se conmuta con body[data-tema] (lo fija WAM.setTema).
Sin dependencias externas.
===================================================================== */
/* --- Reset ----------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
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; }
ul, ol { margin: 0; padding: 0; }
[hidden] { display: none !important; } /* respeta #importar-archivo y stubs estáticos (#sheet) */
/* --- Variables del shell (--wam-*) ------------------------------------ */
:root {
color-scheme: light;
--wam-fondo: #e7ebee;
--wam-panel: #ffffff;
--wam-panel-alt: #f3f6f8;
--wam-texto: #1c2b33;
--wam-texto-suave: #5d6f7a;
--wam-borde: rgba(11, 20, 26, .14);
--wam-acento: #00a884;
--wam-acento-hover: #008069;
--wam-acento-contraste: #ffffff;
--wam-peligro: #c94a3d;
--wam-sombra: 0 10px 30px rgba(11, 20, 26, .14);
--wam-radio: 12px;
}
body[data-tema="oscuro"] {
color-scheme: dark;
--wam-fondo: #0a1116;
--wam-panel: #111b21;
--wam-panel-alt: #1c272f;
--wam-texto: #e9edef;
--wam-texto-suave: #8696a0;
--wam-borde: rgba(233, 237, 239, .15);
--wam-acento: #00a884;
--wam-acento-hover: #008069;
--wam-acento-contraste: #ffffff;
--wam-peligro: #e57164;
--wam-sombra: 0 10px 30px rgba(0, 0, 0, .5);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
"Helvetica Neue", Helvetica, Arial, sans-serif;
background: var(--wam-fondo);
color: var(--wam-texto);
-webkit-font-smoothing: antialiased;
}
:focus-visible { outline: 2px solid var(--wam-acento); outline-offset: 2px; }
/* --- 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-rows: auto minmax(0, 1fr);
grid-template-areas:
"toolbar toolbar"
"editor movil";
}
#toolbar { grid-area: toolbar; }
#panel-editor { grid-area: editor; }
#panel-movil { grid-area: movil; }
/* --- Barra superior de la app (.toolbar) ------------------------------- */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 18px;
background: var(--wam-panel);
border-bottom: 1px solid var(--wam-borde);
}
.toolbar-marca { display: flex; align-items: center; gap: 11px; min-width: 0; }
.toolbar-logo {
flex: none;
width: 34px;
height: 34px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #00a884, #008069);
color: #fff;
}
.toolbar-titulos { display: flex; flex-direction: column; gap: 1px; line-height: 1.15; min-width: 0; }
.toolbar h1 {
font-size: 15px;
font-weight: 700;
letter-spacing: .1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toolbar-subtitulo { font-size: 11.5px; 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; }
.tb-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 12px;
border: 1px solid var(--wam-borde);
border-radius: 10px;
background: var(--wam-panel);
color: var(--wam-texto);
font-size: 13px;
font-weight: 600;
line-height: 1;
cursor: pointer;
transition: background-color .15s ease, border-color .15s ease, color .15s ease, transform .05s ease;
}
.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); }
#btn-reset:hover { color: var(--wam-peligro); border-color: var(--wam-peligro); }
/* --- Editor (columna izquierda · solo el cascarón; contenido: módulo C) */
#panel-editor {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background: var(--wam-panel);
border-right: 1px solid var(--wam-borde);
}
.wam-bloque { padding: 16px 18px 20px; border-bottom: 1px solid var(--wam-borde); }
.wam-bloque:last-child { border-bottom: 0; }
.wam-bloque-titulo {
margin-bottom: 12px;
font-size: 11px;
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-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;
cursor: pointer;
transition: background-color .15s ease;
}
.wam-btn-anadir:hover { background: var(--wam-acento-hover); }
/* --- Modo presentación (lo activa D con body.presentacion) ------------- */
body.presentacion #panel-editor { display: none; }
body.presentacion #app {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"toolbar"
"movil";
}
/* --- Noscript ----------------------------------------------------------- */
.wam-noscript {
position: fixed;
left: 16px;
right: 16px;
bottom: 16px;
z-index: 99;
padding: 12px 16px;
border-radius: 10px;
background: #b3261e;
color: #fff;
font-size: 14px;
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 */
}
/* --- Accesibilidad: menos movimiento ------------------------------------ */
@media (prefers-reduced-motion: reduce) {
#chat-cuerpo { scroll-behavior: auto; }
.tb-btn, .wam-btn-anadir { transition: none; }
}
+784
View File
@@ -0,0 +1,784 @@
/* =====================================================================
css/editor.css — Dueño C (SPEC §2): panel editor.
Prefijo ed- (.ed-seccion .ed-campo .ed-tarjeta .ed-btn .ed-fila .ed-badge).
Colores base de docs/research-ui.md (#00a884 acento, #111b21/#e9edef texto,
#667781/#8696a0 meta). Preferencia por las variables --wam-* de css/base.css
(dueño A); los valores --ed-* solo aplican como reserva si A no las define.
El editor/shell reaccionan a body[data-tema] que fija WAM.setTema (B).
===================================================================== */
/* ---------- Tokens del editor ---------- */
#panel-editor {
--ed-panel: #ffffff;
--ed-fondo-suave: #f0f4f3;
--ed-texto: #111b21;
--ed-texto-suave: #667781;
--ed-borde: #d9e2e3;
--ed-acento: var(--wam-acento, #00a884);
--ed-acento-hondo: #008069;
--ed-acento-suave: rgba(0, 168, 132, 0.13);
--ed-peligro: #d64550;
--ed-peligro-suave: rgba(214, 69, 80, 0.12);
--ed-entrada: #2563a8;
--ed-entrada-suave: rgba(37, 99, 168, 0.13);
--ed-seleccion: #2b6be4;
--ed-seleccion-suave: rgba(43, 107, 228, 0.11);
--ed-ambar: #b45309;
--ed-sombra: 0 1px 2px rgba(16, 24, 32, 0.06);
}
body[data-tema="oscuro"] #panel-editor {
--ed-panel: #1f2c34;
--ed-fondo-suave: #172430;
--ed-texto: #e9edef;
--ed-texto-suave: #8696a0;
--ed-borde: #2a3942;
--ed-acento-hondo: #00a884;
--ed-acento-suave: rgba(0, 168, 132, 0.2);
--ed-peligro-suave: rgba(214, 69, 80, 0.22);
--ed-entrada: #6fa8dc;
--ed-entrada-suave: rgba(111, 168, 220, 0.16);
--ed-seleccion: #6d9bf3;
--ed-seleccion-suave: rgba(109, 155, 243, 0.16);
--ed-ambar: #f0a940;
--ed-sombra: none;
}
/* ---------- Base compacta del panel ---------- */
#panel-editor,
#panel-editor *,
#panel-editor *::before,
#panel-editor *::after {
box-sizing: border-box;
}
.ed-seccion {
display: grid;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--wam-borde, var(--ed-borde));
font-size: 13px;
line-height: 1.45;
color: var(--wam-texto, var(--ed-texto));
}
/* Contenedor de la identidad cuando vive dentro de .wam-bloque de A
(el padding y el título ya los aporta el bloque) */
.ed-cuenta {
display: grid;
gap: 8px;
font-size: 13px;
line-height: 1.45;
color: var(--wam-texto, var(--ed-texto));
}
.ed-titulo {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--wam-texto, var(--ed-texto-suave));
}
/* Aviso transitorio (import/export/errores) — sin consola */
.ed-aviso {
display: none;
margin: 0;
padding: 8px 18px;
font-size: 12.5px;
border-bottom: 1px solid var(--wam-borde, var(--ed-borde));
background: var(--ed-acento-suave);
color: var(--ed-acento-hondo);
}
.ed-aviso-visible { display: block; }
.ed-aviso-error {
background: var(--ed-peligro-suave);
color: var(--ed-peligro);
}
/* ---------- Campos ---------- */
.ed-campo {
display: grid;
gap: 3px;
min-width: 0;
font-size: 12px;
color: var(--wam-texto, var(--ed-texto-suave));
}
.ed-campo-e { font-size: 11.5px; }
.ed-input {
width: 100%;
padding: 5px 8px;
font: inherit;
font-size: 12.5px;
color: var(--wam-texto, var(--ed-texto));
background: var(--wam-panel, var(--ed-panel));
border: 1px solid var(--wam-borde, var(--ed-borde));
border-radius: 7px;
}
textarea.ed-input { resize: vertical; min-height: 46px; }
.ed-input:focus {
outline: none;
border-color: var(--ed-acento);
box-shadow: 0 0 0 2px var(--ed-acento-suave);
}
.ed-input::placeholder { color: var(--wam-texto, var(--ed-texto-suave)); opacity: 0.65; }
.ed-check {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
cursor: pointer;
color: var(--wam-texto, var(--ed-texto));
}
.ed-check input { accent-color: var(--ed-acento); width: 15px; height: 15px; margin: 0; }
/* Filas horizontales */
.ed-fila {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-items: end;
}
.ed-fila.ed-comunes {
display: flex;
flex-wrap: wrap;
align-items: end;
}
.ed-comunes > .ed-campo { flex: 1 1 92px; }
/* ---------- Botones ---------- */
.ed-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 10px;
font: inherit;
font-size: 12.5px;
color: var(--wam-texto, var(--ed-texto));
background: var(--wam-panel, var(--ed-panel));
border: 1px solid var(--wam-borde, var(--ed-borde));
border-radius: 8px;
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.ed-btn:hover { border-color: var(--ed-acento); color: var(--ed-acento-hondo); }
.ed-btn:focus-visible,
.ed-cab-principal:focus-visible {
outline: 2px solid var(--ed-acento);
outline-offset: 1px;
}
.ed-btn-primario {
background: var(--ed-acento);
border-color: var(--ed-acento);
color: #ffffff;
font-weight: 600;
white-space: nowrap;
}
.ed-btn-primario:hover {
background: var(--ed-acento-hondo);
border-color: var(--ed-acento-hondo);
color: #ffffff;
}
.ed-btn-mini {
padding: 2px 8px;
font-size: 12px;
line-height: 1.5;
border-radius: 6px;
}
.ed-btn-peligro { color: var(--ed-peligro); }
.ed-btn-peligro:hover {
border-color: var(--ed-peligro);
background: var(--ed-peligro-suave);
color: var(--ed-peligro);
}
.ed-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ed-btn:disabled:hover {
border-color: var(--wam-borde, var(--ed-borde));
color: var(--wam-texto, var(--ed-texto-suave));
}
.ed-add {
justify-self: start;
border-style: dashed;
color: var(--ed-acento-hondo);
}
/* Entrada de archivo visualmente oculta dentro de su etiqueta */
.ed-archivo {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---------- Alta de mensajes ---------- */
.ed-alta {
grid-template-columns: 1fr auto;
align-items: end;
}
.ed-alta .ed-titulo { grid-column: 1 / -1; }
.ed-alta .ed-select { min-width: 0; }
/* ---------- Lista de tarjetas ---------- */
.ed-lista {
margin: 0;
padding: 0; /* el padding del bloque lo pone .wam-bloque (A) */
list-style: none;
display: grid;
gap: 8px;
}
#panel-editor > .ed-lista { padding: 12px; } /* reserva sin .wam-bloque */
.ed-vacio {
padding: 16px 12px;
font-size: 12.5px;
text-align: center;
color: var(--wam-texto, var(--ed-texto-suave));
border: 1px dashed var(--wam-borde, var(--ed-borde));
border-radius: 10px;
}
/* ---------- Tarjeta de mensaje ---------- */
.ed-tarjeta {
background: var(--wam-panel, var(--ed-panel));
border: 1px solid var(--wam-borde, var(--ed-borde));
border-radius: 10px;
box-shadow: var(--ed-sombra);
overflow: hidden;
}
.ed-tarjeta.ed-abierta {
border-color: var(--ed-acento);
box-shadow: inset 3px 0 0 var(--ed-acento), var(--ed-sombra);
}
.ed-tarjeta-cab {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 6px;
}
.ed-cab-principal {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 3px 4px;
font: inherit;
text-align: left;
background: none;
border: 0;
border-radius: 6px;
cursor: pointer;
color: var(--wam-texto, var(--ed-texto));
}
.ed-num {
flex: 0 0 auto;
min-width: 18px;
height: 18px;
display: inline-grid;
place-items: center;
font-size: 11px;
font-weight: 700;
color: var(--ed-acento-hondo);
background: var(--ed-acento-suave);
border-radius: 999px;
}
.ed-dir {
flex: 0 0 auto;
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
border-radius: 999px;
}
.ed-dir-in {
color: var(--ed-entrada);
background: var(--ed-entrada-suave);
}
.ed-dir-out {
color: var(--ed-acento-hondo);
background: var(--ed-acento-suave);
}
.ed-badge {
flex: 0 0 auto;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--wam-texto, var(--ed-texto-suave));
border: 1px solid var(--wam-borde, var(--ed-borde));
border-radius: 999px;
}
.ed-resumen {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--wam-texto, var(--ed-texto-suave));
}
.ed-acciones {
flex: 0 0 auto;
display: inline-flex;
gap: 3px;
}
.ed-cuerpo {
display: grid;
gap: 10px;
padding: 9px 10px 11px;
border-top: 1px dashed var(--wam-borde, var(--ed-borde));
}
.ed-tarjeta:not(.ed-abierta) .ed-cuerpo { display: none; }
.ed-form { display: grid; gap: 8px; }
/* Tarjeta anidada (respuesta encadenada) */
.ed-tarjeta.ed-anidada {
background: var(--ed-fondo-suave);
box-shadow: none;
}
.ed-tarjeta.ed-anidada .ed-cuerpo { padding: 8px 9px 10px; }
.ed-anidadas { display: grid; gap: 6px; }
/* ---------- Bloques anidados (botones, secciones, cards, respuestas) ---------- */
.ed-sub {
display: grid;
gap: 7px;
padding: 8px;
background: var(--ed-fondo-suave);
border: 1px dashed var(--wam-borde, var(--ed-borde));
border-radius: 9px;
}
.ed-sub .ed-sub { background: var(--wam-panel, var(--ed-panel)); }
.ed-sub-cab {
display: flex;
align-items: center;
gap: 6px;
min-height: 22px;
}
.ed-sub-titulo {
flex: 1;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: var(--wam-texto, var(--ed-texto-suave));
}
.ed-item {
display: grid;
gap: 6px;
padding: 7px;
border: 1px solid var(--wam-borde, var(--ed-borde));
border-radius: 8px;
background: var(--wam-panel, var(--ed-panel));
}
.ed-nota {
margin: 0;
font-size: 11.5px;
color: var(--wam-texto, var(--ed-texto-suave));
}
/* ---------- Media (avatar, src, headerMedia, card.media) ---------- */
.ed-media {
display: flex;
gap: 9px;
align-items: flex-start;
}
.ed-media-vista {
flex: 0 0 auto;
width: 54px;
height: 54px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--wam-borde, var(--ed-borde));
background: var(--ed-fondo-suave);
}
.ed-media-icono {
display: grid;
place-items: center;
font-size: 20px;
}
.ed-media-vacio,
.ed-media-placeholder {
display: grid;
place-items: center;
border-style: dashed;
color: var(--wam-texto, var(--ed-texto-suave));
font-size: 15px;
}
.ed-media-datos {
flex: 1;
min-width: 0;
display: grid;
gap: 5px;
}
.ed-avatar { align-items: center; }
.ed-avatar-vista {
flex: 0 0 auto;
width: 58px;
height: 58px;
object-fit: cover;
border-radius: 50%;
border: 1px solid var(--wam-borde, var(--ed-borde));
background: var(--ed-fondo-suave);
}
/* ---------- Selector de tema ---------- */
.ed-tema { grid-template-columns: 1fr; }
/* ---------- Ajustes finos ---------- */
.ed-select { appearance: auto; }
.ed-seccion > .ed-btn { justify-self: start; }
#panel-editor hr { border: 0; border-top: 1px solid var(--wam-borde, var(--ed-borde)); margin: 0; }
/* =====================================================================
UX/UI del panel editor (referencia: constructor de plantillas de Meta)
Paleta de tipos, contadores, barra de formato, variables, toggle de
cabecera, radios + dropzone de media, drag & drop, grid de ubicación.
===================================================================== */
/* ---------- Transiciones sutiles en campos y tarjetas ---------- */
.ed-input,
.ed-tarjeta,
.ed-item,
.ed-media-vista {
transition: border-color 0.14s ease, box-shadow 0.14s ease, background-color 0.14s ease;
}
/* ---------- Alta de mensajes: paleta de tarjetas de tipo ---------- */
#panel-editor .wam-alta { display: grid; gap: 8px; }
#panel-editor .wam-alta .wam-btn-anadir { width: 100%; }
.ed-paleta {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(108px, 1fr));
gap: 6px;
}
.ed-tipo-card {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
padding: 7px 8px;
font: inherit;
font-size: 12px;
line-height: 1.2;
text-align: left;
color: var(--wam-texto, var(--ed-texto));
background: var(--wam-panel, var(--ed-panel));
border: 1px solid var(--wam-borde, var(--ed-borde));
border-radius: 9px;
cursor: pointer;
transition: border-color 0.14s ease, background-color 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
}
.ed-tipo-card .ed-ico {
flex: none;
display: inline-grid;
place-items: center;
color: var(--wam-texto-suave, var(--ed-texto-suave));
transition: color 0.14s ease;
}
.ed-tipo-card .ed-ico svg { display: block; }
.ed-tipo-card-e {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ed-tipo-card:hover {
border-color: var(--ed-seleccion);
color: var(--ed-seleccion);
}
.ed-tipo-card:hover .ed-ico { color: var(--ed-seleccion); }
.ed-tipo-card.ed-seleccionada {
border-color: var(--ed-seleccion);
background: var(--ed-seleccion-suave);
color: var(--ed-seleccion);
font-weight: 600;
box-shadow: inset 0 0 0 1px var(--ed-seleccion);
}
.ed-tipo-card.ed-seleccionada .ed-ico { color: var(--ed-seleccion); }
.ed-tipo-card:focus-visible {
outline: 2px solid var(--ed-seleccion);
outline-offset: 1px;
}
/* Mini-tarjetas verticales del tipo de cabecera (Texto/Imagen/Vídeo/Documento) */
.ed-header-tipos {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
}
.ed-tipo-card-mini {
flex-direction: column;
justify-content: center;
gap: 4px;
padding: 8px 4px;
text-align: center;
font-size: 11px;
}
.ed-tipo-card-mini .ed-tipo-card-e {
flex: none;
width: 100%;
}
/* ---------- Contadores de caracteres ---------- */
.ed-contador {
justify-self: end;
font-size: 10.5px;
font-variant-numeric: tabular-nums;
color: var(--wam-texto-suave, var(--ed-texto-suave));
transition: color 0.12s ease;
}
.ed-contador-alerta {
color: var(--ed-ambar);
font-weight: 600;
}
.ed-contador-over {
color: var(--ed-peligro);
font-weight: 700;
}
/* ---------- Barra de formato B / I / S / </> y variables ---------- */
.ed-fmtbar {
display: flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
}
.ed-fmt-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 7px;
font: inherit;
font-size: 12px;
color: var(--wam-texto-suave, var(--ed-texto-suave));
background: var(--wam-panel, var(--ed-panel));
border: 1px solid var(--wam-borde, var(--ed-borde));
border-radius: 6px;
cursor: pointer;
transition: border-color 0.12s ease, background-color 0.12s ease, color 0.12s ease;
}
.ed-fmt-btn:hover {
border-color: var(--ed-acento);
background: var(--ed-acento-suave);
color: var(--ed-acento-hondo);
}
.ed-fmt-btn:focus-visible {
outline: 2px solid var(--ed-seleccion);
outline-offset: 1px;
}
.ed-fmt-negrita { font-weight: 800; }
.ed-fmt-cursiva { font-style: italic; font-weight: 600; }
.ed-fmt-tachado { text-decoration: line-through; font-weight: 600; }
.ed-fmt-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 11px;
}
.ed-fmt-var {
margin-left: auto;
font-weight: 600;
font-size: 11.5px;
}
/* ---------- Interruptor «Mostrar cabecera» ---------- */
.ed-header-bloque { display: grid; gap: 7px; }
.ed-switch {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 11.5px;
font-weight: 600;
color: var(--wam-texto, var(--ed-texto));
cursor: pointer;
user-select: none;
}
.ed-switch input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
opacity: 0;
pointer-events: none;
}
.ed-switch-pista {
position: relative;
flex: none;
width: 30px;
height: 17px;
border-radius: 999px;
background: var(--wam-borde, var(--ed-borde));
transition: background-color 0.14s ease;
}
.ed-switch-nudo {
position: absolute;
top: 2px;
left: 2px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 1px 2px rgba(16, 24, 32, 0.35);
transition: transform 0.14s ease;
}
.ed-switch input:checked + .ed-switch-pista { background: var(--ed-seleccion); }
.ed-switch input:checked + .ed-switch-pista .ed-switch-nudo { transform: translateX(13px); }
.ed-switch input:focus-visible + .ed-switch-pista {
outline: 2px solid var(--ed-seleccion);
outline-offset: 2px;
}
/* ---------- Origen de media: radios URL / Subir archivo + dropzone ---------- */
.ed-media-modos {
display: flex;
align-items: center;
gap: 12px;
}
.ed-radio {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--wam-texto, var(--ed-texto));
cursor: pointer;
}
.ed-radio input {
accent-color: var(--ed-seleccion);
width: 14px;
height: 14px;
margin: 0;
cursor: pointer;
}
.ed-media .ed-panel-url,
.ed-media .ed-panel-archivo { display: none; }
.ed-media.ed-modo-url .ed-panel-url { display: block; }
.ed-media.ed-modo-archivo .ed-panel-archivo {
display: grid;
gap: 5px;
justify-items: stretch;
}
.ed-dropzona {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 10px;
font: inherit;
font-size: 12px;
text-align: center;
color: var(--wam-texto-suave, var(--ed-texto-suave));
background: var(--ed-fondo-suave);
border: 1.5px dashed var(--wam-borde, var(--ed-borde));
border-radius: 9px;
cursor: pointer;
transition: border-color 0.14s ease, background-color 0.14s ease, color 0.14s ease;
}
.ed-dropzona:hover {
border-color: var(--ed-acento);
color: var(--ed-acento-hondo);
}
.ed-dropzona .ed-ico { display: inline-grid; place-items: center; }
.ed-dropzona .ed-ico svg { display: block; }
.ed-dropzona.ed-dropzona-sobre {
border-color: var(--ed-seleccion);
background: var(--ed-seleccion-suave);
color: var(--ed-seleccion);
}
.ed-dropzona:focus-visible {
outline: 2px solid var(--ed-seleccion);
outline-offset: 1px;
}
/* ---------- Asas e indicadores de drag & drop ---------- */
.ed-asa {
flex: none;
display: inline-grid;
place-items: center;
width: 18px;
height: 24px;
margin-left: 2px;
color: var(--wam-texto-suave, var(--ed-texto-suave));
border-radius: 5px;
cursor: grab;
transition: color 0.12s ease, background-color 0.12s ease;
}
.ed-asa:hover {
color: var(--ed-seleccion);
background: var(--ed-seleccion-suave);
}
.ed-asa:active { cursor: grabbing; }
.ed-asa svg { display: block; }
.ed-listo-drag { cursor: grabbing; }
.ed-arrastrando { opacity: 0.45; }
.ed-tarjeta.ed-drop-antes,
.ed-item.ed-drop-antes { box-shadow: 0 -3px 0 0 var(--ed-seleccion); }
.ed-tarjeta.ed-drop-despues,
.ed-item.ed-drop-despues { box-shadow: 0 3px 0 0 var(--ed-seleccion); }
/* ---------- Grid de ubicación (2 columnas) ---------- */
.ed-grid2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-items: end;
}
/* ---------- Ventana estrecha ---------- */
@media (max-width: 380px) {
.ed-grid2 { grid-template-columns: 1fr; }
.ed-header-tipos { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* ---------- Menos movimiento ---------- */
@media (prefers-reduced-motion: reduce) {
.ed-tipo-card,
.ed-tipo-card .ed-ico,
.ed-fmt-btn,
.ed-dropzona,
.ed-asa,
.ed-switch-pista,
.ed-switch-nudo,
.ed-input,
.ed-tarjeta,
.ed-item,
.ed-media-vista,
.ed-contador {
transition: none;
}
}
+244
View File
@@ -0,0 +1,244 @@
/* =====================================================================
WhatsApp Cloud API Mockup — css/iphone.css (Módulo A · shell)
Columna de vista previa (#panel-movil, #barra-movil) y marco iPhone
393×852 (radio 55, negro) con Dynamic Island, status bar de 54 pt e
indicador home. Escalado 100 % CSS (transform + var --wam-escala)
para caber siempre en la pantalla. Geometría según docs/research-ui.md §7.
===================================================================== */
/* --- Escala del teléfono ----------------------------------------------
Fallback por pasos (media queries) y ajuste exacto con aritmética
tipada de CSS (@supports), que calcula escala = espacio disponible /
tamaño natural. El escenario reserva el tamaño YA escalado para que
el layout no deje huecos ni recortes. */
:root { --wam-escala: 1; }
@media (max-height: 1030px) { :root { --wam-escala: .9; } }
@media (max-height: 940px) { :root { --wam-escala: .82; } }
@media (max-height: 860px) { :root { --wam-escala: .74; } }
@media (max-height: 780px) { :root { --wam-escala: .66; } }
@media (max-height: 700px) { :root { --wam-escala: .58; } }
@media (max-height: 620px) { :root { --wam-escala: .5; } }
@supports (scale: calc(1vh / 1px)) {
:root {
/* 150 px = toolbar + barra-movil + aire; 352 px = editor mínimo + aire.
px/px → número (requisito de scale()); min/max solo comparan números. */
--wam-escala: max(.3, min(
1,
(100vh - 150px) / 852px,
(100vw - 352px) / 393px
));
}
body.presentacion {
/* sin editor cabe más teléfono (el ahorro es horizontal; el vertical igual) */
--wam-escala: max(.3, min(
1,
(100vh - 150px) / 852px,
(100vw - 64px) / 393px
));
}
}
/* --- Columna de vista previa (#panel-movil) ---------------------------- */
#panel-movil {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 14px 16px 18px;
overflow: auto;
background:
radial-gradient(1200px 500px at 50% -10%, rgba(0, 168, 132, .08), transparent 60%),
var(--wam-fondo);
}
/* Barra sobre el teléfono */
#barra-movil {
flex: none;
width: 100%;
max-width: 560px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
#barra-movil .barra-titulo {
font-size: 12px;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--wam-texto-suave);
}
#btn-reproducir {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border: 0;
border-radius: 999px;
background: var(--wam-acento);
color: #fff;
font-size: 13px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 168, 132, .35);
transition: background-color .15s ease, transform .05s ease;
}
#btn-reproducir:hover { background: var(--wam-acento-hover); }
#btn-reproducir:active { transform: translateY(1px); }
/* Escenario: reserva el tamaño ya escalado */
.iphone-escena {
flex: none;
margin: auto; /* centra con espacio sobrante; sin clipping si no cabe */
width: calc(393px * var(--wam-escala));
height: calc(852px * var(--wam-escala));
}
/* --- Marco iPhone: 393×852 pt, radio 55, negro (research-ui §7) -------- */
.iphone {
position: relative;
width: 393px;
height: 852px;
border-radius: 55px;
background: #000;
overflow: hidden;
transform: scale(var(--wam-escala));
transform-origin: top left;
box-shadow:
0 0 0 2px #3d4348, /* aro de titanio */
0 0 0 3.5px #1b1f22,
0 30px 60px rgba(4, 12, 10, .45);
}
/* Pantalla: llena el marco; el look del chat lo gobierna .wa (módulo B) */
.pantalla {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 47px;
background: #efeae2; /* valor de arranque (claro); .wa / .wa.oscuro mandan */
padding-top: 54px; /* status bar de 54 pt: el header arranca debajo */
}
/* Dynamic Island: 126×37, top 11 */
.island {
position: absolute;
top: 11px;
left: 50%;
transform: translateX(-50%);
width: 126px;
height: 37px;
border-radius: 20px;
background: #000;
z-index: 40;
}
.island::after { /* lente de la cámara frontal */
content: "";
position: absolute;
top: 50%;
right: 11px;
transform: translateY(-50%);
width: 9px;
height: 9px;
border-radius: 50%;
background: radial-gradient(circle at 35% 32%, #223244 0 22%, #05080c 62%);
}
/* Status bar: 54 pt; hora "9:41" + señal / wifi / batería */
.statusbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 30px 0 34px;
color: #111b21; /* en claro, status bar oscura (research-ui §7) */
font-size: 15px;
font-weight: 600;
letter-spacing: .02em;
z-index: 30;
pointer-events: none;
}
.sb-hora { min-width: 44px; font-variant-numeric: tabular-nums; }
.statusbar .sb-iconos { display: flex; align-items: center; gap: 7px; }
#pantalla.oscuro .statusbar { color: #fff; }
/* Indicador home: 139×5, bottom 8 */
.home-indicator {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
width: 139px;
height: 5px;
border-radius: 3px;
background: #fff; /* en oscuro (research-ui §7) */
z-index: 45;
pointer-events: none;
}
#pantalla:not(.oscuro) .home-indicator { background: #0b141a; } /* en claro, oscuro */
/* --- Colocación del chat (estructura A; la estética es css/whatsapp.css B) */
#chat-header { flex: none; }
.header-linea { display: flex; align-items: center; gap: 4px; min-width: 0; }
#chat-verificado { display: inline-flex; flex: none; }
#chat-cuerpo {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
list-style: none;
padding: 8px 0 14px;
scroll-behavior: smooth;
overscroll-behavior: contain;
}
#chat-escribiendo { flex: none; }
#composer { flex: none; }
/* Composer: solo valores de arranque con var(--wa-*) de B (con fallback);
whatsapp.css (cargado después) prevalece en caso de empate. */
#composer .attach,
#composer .enviar {
display: inline-flex;
padding: 2px;
border: 0;
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-input {
flex: 1 1 auto;
min-width: 0;
padding: 0;
border: 0;
outline: none;
background: transparent;
color: inherit;
font-size: 16px;
}
#composer-input::placeholder { color: var(--wa-text-meta, #667781); }
/* Color de arranque de los iconos del header (B puede reestilarlos) */
#chat-header .back-chevron { color: var(--wa-accent, #00a884); }
#chat-header .icon-video,
#chat-header .icon-call { color: var(--wa-chrome-text, #111b21); }
/* --- Modo presentación: el iPhone se centra y respira más --------------- */
body.presentacion #panel-movil { padding: 14px 20px 22px; }
/* --- Accesibilidad: menos movimiento ------------------------------------- */
@media (prefers-reduced-motion: reduce) {
#btn-reproducir { transition: none; }
}
+679
View File
File diff suppressed because one or more lines are too long
+425
View File
@@ -0,0 +1,425 @@
# WhatsApp Business Platform (Cloud API) — Tipos de mensajes, payloads y límites
> Investigación sobre la documentación oficial de Meta (developers.facebook.com), 2026-09-26.
> Nota: Meta migró la doc de `/docs/whatsapp/cloud-api/...` a `/documentation/business-messaging/whatsapp/...`.
> Los tipos text/media/location/contacts siguen accesibles en las URLs legacy; interactive/templates ya
> solo viven en la nueva ruta. Los datos citados provienen de las páginas listadas en "Fuentes".
## 1. Convención común de todos los mensajes
Endpoint: `POST https://graph.facebook.com/<API_VERSION>/<PHONE_NUMBER_ID>/messages`
con header `Authorization: Bearer <TOKEN>` y `Content-Type: application/json`
(Fuente: Message API reference — §Fuentes [17]).
Sobre (envelope) común a todo mensaje (BaseMessageProperties, [17]):
```json
{
"messaging_product": "whatsapp", // requerido, siempre "whatsapp"
"recipient_type": "individual", // "individual" (o "group" para grupos)
"to": "<número del usuario>", // requerido
"type": "<text|image|video|audio|document|sticker|location|contacts|interactive|template|reaction>",
"context": { "message_id": "wamid..." } // opcional: responde a un mensaje previo
}
```
Respuesta de éxito: `contacts[].input`, `contacts[].wa_id` y `messages[].id` (`wamid.HBgL...`);
el `message_status` inicial puede ser `accepted` (también existen `held_for_quality_assessment` y `paused`) [17].
## 2. Text
Payload [1]:
```json
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "16505551234",
"type": "text",
"text": {
"preview_url": false,
"body": "Hola mundo"
}
}
```
| Campo | Descripción | Límite |
|---|---|---|
| `text.body` | Texto del mensaje (requerido). El cliente hipervincula URLs automáticamente | **4096 caracteres** |
| `text.preview_url` | `true` = el cliente intenta renderizar preview del enlace | opcional |
Vista en el teléfono: burbuja de solo texto; con `preview_url: true` **solo el primer URL** del body
recibe preview (requiere `http://` o `https://`); si no se puede obtener, queda como enlace clicable [1].
El preview usa el Open Graph de la página: `og:title` (negrita, máx 2 líneas), `og:description`
(~80 caracteres), `og:url` y `og:image` (thumbnail <600 KB, ≥300 px de ancho) renderizados encima
del texto de la burbuja [22].
**Formato de texto** (`*negrita*`, `_cursiva_`, `~tachado~`, ` ```mono``` `):
la página oficial actual de text-messages **ya no documenta la sintaxis** de formateo (verificado [1]);
lo único oficial vigente leído es que el body/footer interactivos "soportan emojis y markdown" [17]
y que el header de plantillas tipo TEXT **prohíbe** caracteres markdown [20]. La sintaxis con
`*asteriscos*`, `_guiones bajos_`, `~tildes~` y triple acento grave es la estándar del cliente
WhatsApp, pero su referencia oficial en el site de developers está retirada: dato NO verificable
contra fuentes oficiales en esta investigación.
## 3. Media (image, video, audio, document, sticker)
Patrón común: el objeto de media admite `id` (asset subido vía Media API — recomendado por
performance) **o** `link` (URL pública — "no recomendado"); se provee solo uno [2]–[6].
**Image** [2] — `type: "image"`, objeto `image`: `{ id | link, caption }`.
Caption máx **1024 caracteres**. Formatos: JPEG (`image/jpeg`) y PNG (`image/png`),
8-bit RGB/RGBA, máx **5 MB**.
**Video** [3] — `type: "video"`, objeto `video`: `{ id | link, caption }`.
Caption máx **1024**. Solo códecs **H.264 (video) + AAC (audio)**, un solo stream de audio o ninguno;
MP4 (`video/mp4`) y 3GP (`video/3gpp`), máx **16 MB**. H.264 "High" con B-frames no funciona en
Android; usar Main/Baseline y `moov` antes de `mdat` (ffmpeg `-movflags faststart`).
**Audio** [4] — `type: "audio"`, objeto `audio`: `{ id | link, voice }`.
`voice: true` = mensaje de voz (requiere OGG con codec OPUS, mono; el OGG base no es soportado).
Formatos: AAC, AMR, MP3 (`audio/mpeg`), M4A (`audio/mp4`), OGG (`audio/ogg`); máx **16 MB**.
Vista: archivos ≤512 KB muestran icono de play como mensaje de voz; los mayores muestran icono de descarga [4].
**Document** [5] — `type: "document"`, objeto `document`: `{ id | link, caption, filename }`.
Caption máx **1024**; `filename` (con extensión) define el icono del tipo de archivo en el cliente.
Tipos soportados (máx **100 MB**): TXT, XLS/XLSX, DOC/DOCX, PPT/PPTX, PDF. Otros tipos pueden
enviarse pero no están soportados oficialmente.
**Sticker** [6] — `type: "sticker"`, objeto `sticker`: `{ id | link }` (sin caption).
Formato **WebP** únicamente: animado máx **500 KB**, estático máx **100 KB**.
Vista: se muestra como sticker (sin texto); error si el WebP no es soportado o excede el tamaño.
Vista general media: la burbuja muestra el recurso y, si aplica, el caption debajo; en document
aparece icono por extensión + filename; en image/video el caption va bajo el recurso [2][3][5].
## 4. Location
Payload [7] — `type: "location"`, objeto `location`:
| Campo | Requerido | Ejemplo |
|---|---|---|
| `latitude` | sí | `"37.44216251868683"` (grados decimales) |
| `longitude` | sí | `"-122.16153582049394"` |
| `name` | no | `"Philz Coffee"` |
| `address` | no | `"101 Forest Ave, Palo Alto, CA 94301"` |
Vista: tarjeta de mapa con las coordenadas; nombre y dirección se muestran como título/detalle.
La página no especifica límites de caracteres ni renderizado adicional [7].
## 5. Contacts
Payload [8] — `type: "contacts"`, array `contacts[]` de objetos contacto.
Cada mensaje puede incluir **hasta 257 contactos** (se recomienda menos por usabilidad).
Único campo requerido por contacto: `name.formatted_name` (es lo que aparece en la burbuja
junto al botón de flecha de perfil). Resto opcionales [8]:
- `name`: `formatted_name`, `first_name`, `last_name`, `middle_name`, `suffix`, `prefix`
- `phones[]`: `phone`, `type` (CELL/MAIN/IPHONE/HOME/WORK), `wa_id` — si falta `wa_id`,
el botón Message/Save se sustituye por **"Invite to WhatsApp"**
- `emails[]`: `email`, `type` (PERSONAL/WORK)
- `urls[]`: `url`, `type` (COMPANY/WORK/PERSONAL/FACEBOOK_PAGE/INSTAGRAM)
- `org`: `company`, `department`, `title`
- `addresses[]`: `street`, `city`, `state`, `zip`, `country`, `country_code` (ISO-2), `type`
- `birthday`: formato `YYYY-MM-DD`
Vista: tarjeta de contacto con avatar, nombre formateado y filas por cada dato presente;
algunos metadatos (direcciones, cumpleaños) pueden no mostrarse según el dispositivo del usuario [8].
## 6. Interactive
`type: "interactive"` + objeto `interactive` con `type` de: `button`, `list`, `cta_url`,
`product`, `product_list`, `catalog_message`, `carousel`, `flow` (y `call_permission_request`) [17].
Objetos internos: `header` (`HeaderObject`: text/image/video/document; requerido para `product_list`,
**no permitido** en `product`), `body` (`text`, requerido — "emojis y markdown soportados"),
`footer` (`text`, opcional — "emojis, markdown y links soportados"), `action` (estructura varía por tipo) [17].
### 6.1 Botones reply (quick replies) — `interactive.type: "button"` [10]
```json
{ "type": "interactive",
"interactive": {
"type": "button",
"header": { "type": "text", "text": "Detalles" },
"body": { "text": "¿Confirmas tu cita?" },
"footer": { "text": "Elige una opción" },
"action": { "buttons": [
{ "type": "reply", "reply": { "id": "id_si", "title": "Sí" } },
{ "type": "reply", "reply": { "id": "id_no", "title": "No" } } ] } } }
```
| Campo | Límite |
|---|---|
| Botones | **máx 3** |
| `reply.title` | **20 caracteres**, único entre botones |
| `reply.id` | **256 caracteres**, único por botón |
| `body.text` | **1024 caracteres** (URLs se hipervinculan) |
| `footer.text` | **60 caracteres** |
| `header` | tipos text/image/video/document (límite de header text no declarado en esta página) [10] |
Vista: header arriba (media o texto), body, footer y los botones como filas-blanco fijas bajo la
burbuja. Al tocar, webhook `messages` con `button_reply.id`/`title` + `context.id` del mensaje original [10].
### 6.2 Lista — `interactive.type: "list"` [9]
```json
{ "type": "interactive",
"interactive": {
"type": "list",
"header": { "type": "text", "text": "Menú" },
"body": { "text": "Selecciona un producto" },
"footer": { "text": "Catálogo 2026" },
"action": { "button": "Ver opciones",
"sections": [
{ "title": "Bebidas",
"rows": [ { "id": "r1", "title": "Café", "description": "Chico" } ] } ] } } }
```
| Campo | Límite |
|---|---|
| Secciones | **mín 1, máx 10** |
| Filas (total, sumando secciones) | **máx 10** |
| `action.button` (etiqueta que despliega) | **20 caracteres** |
| `header.text` | **60 caracteres** (solo type text) |
| `body.text` | **4096 caracteres** |
| `footer.text` | **60 caracteres** |
| Título de sección | **24 caracteres** (requerido solo si hay >1 sección, [17]) |
| `row.title` | **24 caracteres** |
| `row.description` | opcional, **72 caracteres** |
| `row.id` | **200 caracteres** |
Vista: burbuja con header/body/footer y **un único botón** (`action.button`) que abre la lista
de opciones seleccionables a pantalla (las filas muestran title y description); el webhook devuelve
`list_reply` con `id`, `title`, `description` + `context` [9].
### 6.3 CTA URL — `interactive.type: "cta_url"` [11]
```json
"interactive": {
"type": "cta_url",
"header": { "type": "image", "image": { "link": "https://…/promo.jpg" } },
"body": { "text": "Aprovecha la promo" },
"footer": { "text": "Vence hoy" },
"action": { "name": "cta_url",
"parameters": { "display_text": "Abrir tienda", "url": "https://mitienda.com" } } }
```
Límites: `display_text` **20 caracteres**; `body.text` **1024**; `footer.text` **60**;
`header.text` **60**. Header opcional de tipo image/video/document/text. Un solo botón URL por
acción; la doc menciona etiquetas únicas "si se usan múltiples botones" sin declarar máximo [11].
Vista: media/texto del header, body, footer y un botón azul que abre el navegador con la URL [11].
### 6.4 Producto único — `interactive.type: "product"` [15]
```json
"interactive": {
"type": "product",
"body": { "text": "Texto opcional" },
"footer": { "text": "Texto opcional" },
"action": { "catalog_id": "123456789", "product_retailer_id": "SKU-001" } }
```
`action` requiere `catalog_id` + `product_retailer_id` (SKU del producto); `header` **no permitido**
en este tipo [15][17]; body/footer opcionales. Sin límites de caracteres declarados en la página [15].
Vista: tarjeta con imagen/nombre/precio actuales del producto; al tocar se abre una
**Product Detail Page (PDP)** con los datos en tiempo real (solo imágenes; videos/GIF no se muestran);
hay botón para escribir al negocio; reenviable, no puede enviarse como notificación [15].
### 6.5 Multi-producto — `interactive.type: "product_list"` [16]
```json
"interactive": {
"type": "product_list",
"header": { "type": "text", "text": "Nuestra tienda" },
"body": { "text": "Elige productos" },
"footer": { "text": "Envío gratis" },
"action": { "catalog_id": "123456789",
"sections": [ { "title": "Cafés",
"product_items": [ { "product_retailer_id": "SKU-001" } ] } ] } }
```
Requeridos: header type **text**, body, `action.catalog_id` + `sections[]` (`title` +
`product_items[].product_retailer_id`). Límite documentado: **hasta 30 productos** en secciones;
máximo de secciones y límites de texto NO declarados en la página (el título de sección es el
`SectionObject.title` de máx 24 chars según [17]). Si el SKU no existe se descarta y llega
error pidiendo actualización de catálogo [16]. Vista: carrusel horizontal de productos que abre
PDP y permite armar carrito por dispositivo (el carrito no sincroniza entre dispositivos) [16].
### 6.6 Catálogo — `interactive.type: "catalog_message"` [14]
```json
"interactive": {
"type": "catalog_message",
"body": { "text": "Explora nuestro catálogo" },
"action": { "name": "catalog_message",
"parameters": { "thumbnail_product_retailer_id": "SKU-001" } },
"footer": { "text": "Tienda oficial" } }
```
`body.text` máx **1024**; `footer.text` máx **60**; `thumbnail_product_retailer_id` opcional
(define la miniatura; si falta se usa la imagen del primer artículo del catálogo) [14].
Vista: miniatura de producto, texto fijo de encabezado, body, footer y botón **"View catalog"**
que abre el catálogo completo dentro de WhatsApp [14].
### 6.7 Carrusel de productos — `interactive.type: "carousel"` (cards tipo product) [13]
```json
"interactive": {
"type": "carousel",
"body": { "text": "Lo más vendido" },
"action": { "cards": [
{ "card_index": 0, "type": "product",
"action": { "product_retailer_id": "SKU-001", "catalog_id": "123456789" } } ] } }
```
Límites: **2 a 10 cards**; `card_index` entero **0–9**, sin repetir; todas las cards deben
referenciar el **mismo `catalog_id`**; `body.text` máx **1024**. **Sin header, footer ni botones**
a nivel de mensaje [13]. Vista: tarjetas desplazables horizontalmente, cada una abre su PDP [13][15].
### 6.8 Carrusel de media — cards con header image/video [12]
```json
"interactive": {
"type": "carousel",
"body": { "text": "Nuevos cursos" },
"action": { "cards": [
{ "card_index": 0,
"header": { "type": "image", "image": { "link": "https://…/a.jpg" } },
"body": { "text": "Curso A" },
"action": { "name": "cta_url",
"parameters": { "display_text": "Inscribirme", "url": "https://…" } } } ] } }
```
Límites exactos [12]: **2–10 cards**; header por card **solo image o video** (`link` o `id`);
body por card máx **160 caracteres y 2 saltos de línea**; body del mensaje máx **1024**;
quick-reply `id` máx **256**, etiquetas (quick reply y URL) máx **20**; cada card lleva **un botón
URL o uno o más quick replies**; tipos y cantidades de botones deben ser idénticos en todas las cards.
Sin header/footer a nivel de mensaje. Vista: cards con media arriba, texto y botón, desplazables
izquierda→derecha desde índice 0 [12].
## 7. Template messages
Único tipo enviable **fuera de la ventana de servicio** al cliente; se crean vía Template API o
WhatsApp Manager y deben quedar `APPROVED` antes de enviarse [19]. Creación: `name` (máx 512,
minúsculas/guiones bajos), `category` (AUTHENTICATION/MARKETING/UTILITY), `language`,
`parameter_format` (`named` p.ej. `{{first_name}}`, o `positional` p.ej. `{{1}}`) y `components`
[19]. Límites de cuenta: **250 plantillas/WABA** (6000 con portafolio verificado + display name
aprobado), **100 plantillas creadas por hora** por WABA [19].
### 7.1 Envío por Cloud API [21]
```json
{ "messaging_product": "whatsapp",
"recipient_type": "individual",
"to": "16505551234",
"type": "template",
"template": {
"name": "reservation_confirmation",
"language": { "code": "en_US" },
"components": [
{ "type": "header",
"parameters": [ { "type": "image", "image": { "id": "2871834006348767" } } ] },
{ "type": "body",
"parameters": [ { "type": "text", "parameter_name": "day", "text": "Saturday" } ] },
{ "type": "buttons",
"sub_type": "URL", "index": 0,
"parameters": [ { "type": "text", "text": "abc123" } ] } ] } }
```
El header solo se envía si la plantilla usa header de media (image/video/document; los header
text/location no llevan parámetros en el envío) [21]. En plantillas posicionales los parámetros
van por orden, sin `parameter_name` [21]. En webhook los botones responden con
`button_reply`/`list_reply`; COPY_CODE entrega el código y CATALOG/MPM abren catálogo (los
sub_types de botón definidos por la API son: `CATALOG`, `COPY_CODE`, `FLOW`, `MPM`, `OTP`,
`PHONE_NUMBER`, `QUICK_REPLY`, `URL` [18]).
### 7.2 Componentes y límites de caracteres (creación) [20]
| Componente | Formatos / tipos | Límites |
|---|---|---|
| **HEADER** (1 máx) | `format`: TEXT, IMAGE, VIDEO, DOCUMENT, LOCATION | TEXT: **60 caracteres**, sin markdown, admite 1 parámetro. Media: handle de Resumable Upload API (GIF mp4 ≤3.5 MB, solo Marketing). LOCATION: sin parámetros al crear; al enviar `latitude`, `longitude`, `name`, `address`; solo UTILITY/MARKETING |
| **BODY** (requerido, 1) | texto | **1024 caracteres**; parámetros múltiples named/posicionales con ejemplos obligatorios |
| **FOOTER** (opcional, 1) | texto | **60 caracteres** |
| **BUTTONS** (array `buttons`) | ver abajo | **máx 10 botones en total** por plantilla |
Botones por tipo [20]:
- **QUICK_REPLY**: hasta **10**; etiqueta máx **25 caracteres**; deben agruparse (no intercalar
`QR, URL, QR`); el orden en el teléfono lo define `index` al enviar.
- **URL**: hasta **2**; etiqueta 25; `url` máx **2000 caracteres**, admite **1 variable** al final
(requiere `example`; valores con caracteres especiales deben ir percent-encoded: `%20`, `%3A`,
`%7C`… o el envío falla). El título corto del botón puede sobreescribirse (`tap-target-url-title-override`).
- **PHONE_NUMBER**: solo **1**; `phone_number` máx **20 caracteres** (se recortan ceros a la
izquierda tras el código de país); etiqueta 25.
- **COPY_CODE**: solo **1**; código copiable en `example`, **máx 20 caracteres**.
- **CATALOG**: abre el catálogo; **MPM** (multi-producto) muestra hasta **30 productos en hasta
10 secciones**; ambos no personalizables.
- **VOICE_CALL**: inicia llamada de WhatsApp al negocio; **OTP** (URL/ONE_TAP/ZERO_TAP) para
plantillas de autenticación, con `autofill_text`, `package_name`, `signature_hash`.
Rendimiento en pantalla [20]: con **>3 botones** WhatsApp muestra 2 y un botón **"See all options"**
que expande el resto; plantillas con **4+ botones**, o quick replies mezcladas con otros tipos,
**no se ven en clientes de escritorio**. Vista general: header arriba del body; body con variables
resaltadas; footer en gris pequeño; botones como filas fijas al pie de la burbuja [20].
### 7.3 Plantillas de carrusel (marketing)
Existen `media-card-carousel-templates` (cards de media) y `product-card-carousel-template-messages`
[19]; su estructura de componentes en la Template API incluye `type: CAROUSEL` y
`LIMITED_TIME_OFFER` [18]. Detalle no extraído en esta investigación (fuera del alcance directo).
## 8. Tabla resumen de límites clave
| Elemento | Límite | Fuente |
|---|---|---|
| `text.body` / body de lista | 4096 | [1][9] |
| Caption de image/video/document | 1024 | [2][3][5] |
| Tamaño image / video / audio / document | 5 MB / 16 MB / 16 MB / 100 MB | [2][3][4][5] |
| Sticker WebP animado / estático | 500 KB / 100 KB | [6] |
| Contactos por mensaje | 257 | [8] |
| Botones reply / list rows / secciones | 3 / 10 / 10 | [10][9] |
| Título botón (interactive) / display_text | 20 / 20 | [10][11][12] |
| `reply.id` / `row.id` | 256 / 200 | [10][9] |
| Header / footer interactivos | 60 / 60 | [9][11] |
| Section title / row title / row description | 24 / 24 / 72 | [9][17] |
| Body de card de carrusel de media | 160 (2 saltos) | [12] |
| Cards de carrusel | 2–10 (índices 0–9) | [12][13] |
| Productos por product_list / MPM | 30 (en ≤10 secciones) | [16][20] |
| Plantilla: header TEXT / body / footer | 60 / 1024 / 60 | [20] |
| Plantilla: botones totales / QR / URL / PHONE / COPY_CODE | 10 / 10 / 2 / 1 / 1 | [20] |
| Plantilla: etiqueta botón / URL / phone / código | 25 / 2000 / 20 / 20 | [20] |
| Plantillas por WABA / creación por hora | 250 (6000 verificada) / 100 | [19] |
## 9. Fuentes consultadas (oficiales, developers.facebook.com)
[1] https://developers.facebook.com/docs/whatsapp/cloud-api/messages/text-messages
[2] https://developers.facebook.com/docs/whatsapp/cloud-api/messages/image-messages
[3] https://developers.facebook.com/docs/whatsapp/cloud-api/messages/video-messages
[4] https://developers.facebook.com/docs/whatsapp/cloud-api/messages/audio-messages
[5] https://developers.facebook.com/docs/whatsapp/cloud-api/messages/document-messages
[6] https://developers.facebook.com/docs/whatsapp/cloud-api/messages/sticker-messages
[7] https://developers.facebook.com/docs/whatsapp/cloud-api/messages/location-messages
[8] https://developers.facebook.com/docs/whatsapp/cloud-api/messages/contacts-messages
[9] https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-list-messages
[10] https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-reply-buttons-messages
[11] https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-cta-url-messages
[12] https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-media-carousel-messages.md
[13] https://developers.facebook.com/documentation/business-messaging/whatsapp/catalogs/interactive-product-carousel-messages.md
[14] https://developers.facebook.com/documentation/business-messaging/whatsapp/catalogs/catalog-messages.md
[15] https://developers.facebook.com/documentation/business-messaging/whatsapp/catalogs/single-product-messages.md
[16] https://developers.facebook.com/documentation/business-messaging/whatsapp/catalogs/multi-product-messages.md
[17] https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/message-api.md
[18] https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/message-template-api.md
[19] https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/overview.md
[20] https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/components.md
[21] https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/utility-templates/utility-templates.md
[22] https://developers.facebook.com/documentation/business-messaging/whatsapp/link-previews.md
**Declaraciones de cobertura**: (a) la sintaxis de formateo de texto (*bold*, _italic_, ~tachado~,
mono) ya no está documentada en las páginas oficiales consultadas — solo consta la nota de que
body/footer interactivos soportan markdown [17] y la prohibición de markdown en header TEXT de
plantillas [20]; (b) límites no declarados: máximo de secciones en product_list, límites de texto
en `product`, límite de header text en `interactive.type: button`, y máximo de botones en
`cta_url` — se reportan como no documentados, no como valores asumidos; (c) varias páginas nuevas
(carousel-messages, product-messages en su slug HTML) solo renderizan con JavaScript y se accedió
a su contenido vía las versiones `.md` publicadas por Meta en el índice `llms.txt`.
+324
View File
@@ -0,0 +1,324 @@
# Research UI — WhatsApp Business/Cloud API renderizado en WhatsApp iOS
> Objetivo: especificación orientada a CSS para replicar cómo se ven los mensajes de la
> Cloud API en la app de WhatsApp para iPhone (diseño unificado 2022+, con notas del
> rediseño iOS 2024–2025). Valores en px sobre un layout de 393 pt de ancho.
## 0. Fiabilidad de las fuentes
Meta **no publica** hex oficiales del cliente móvil; su documentación describe estructura
(orden de componentes, límites de caracteres) pero no colores. Los hex del cliente son
**medidos por la comunidad** (Figma kits, clones de código abierto, WABetaInfo) y
coinciden entre fuentes independientes, por lo que se consideran fiables. Marcado usado:
- `[META]` — confirmado en developers.facebook.com (estructura/límites, no hex).
- `[COM]` — hex/medidas de comunidad (Múltiples kits Figma y clones coinciden).
- `[WBI]` — WABetaInfo vía PocketNow (cambio de color de burbuja iOS, beta 2.21.200.11).
- `[APL]` — Apple/screensizes.app (geometría del dispositivo).
---
## 1. Modo claro
```css
.wa {
/* Fondo del chat: beige con patrón doodle */
--wa-bg-chat: #efeae2; /* [COM] variantes citadas: #efe7de / clásico #e5ddd5 */
--wa-doodle-opacity: 0.045; /* [COM] doodles al ~4–6% sobre el fondo */
/* Burbujas */
--wa-bubble-in: #ffffff; /* [COM] iOS entrante blanca; Web/Android #f0f0f0; clásico #ece5dd */
--wa-bubble-out: #d9fdd3; /* [COM] saliente verde claro; clásico iOS #dcf8c6 */
/* [WBI] beta iOS 2.21.200.11: #e1f6ca → #e2fdd5 (misma familia de verde) */
/* Texto */
--wa-text: #111b21; /* [COM] texto de burbuja, casi negro */
--wa-text-meta: #667781; /* [COM] hora, fechas, "escribiendo…", footer de plantilla */
--wa-link: #027eb5; /* [COM] enlaces dentro de burbuja (azul WhatsApp, no iOS blue) */
--wa-tick: #667781; /* [COM] ✓ enviado y ✓✓ entregado (mismo gris que la hora) */
--wa-tick-read: #53bdeb; /* [COM] ✓✓ leído (celeste) — igual en claro y oscuro */
/* Chrome (header + barra inferior): iOS usa gris muy claro, no blanco puro */
--wa-chrome: #f6f6f6; /* [COM] header y compose bar iOS; WhatsApp Web usa #f0f2f5 */
--wa-chrome-text: #000000;
--wa-compose-field: #ffffff; /* [COM] campo "Mensaje" blanco redondeado */
--wa-separator: rgba(0,0,0,.08); /* [COM] hairlines */
--wa-accent: #00a884; /* [COM] verde de acento actual (iconos, botones) */
--wa-accent-deep: #008069; /* [COM] variante oscura del acento */
}
```
Brand histórico (único hex "oficial" publicado en paletas de marca) `[COM/Mobbin]`:
`#075e54` teal header clásico · `#128c7e` teal secundario · `#25d366` verde marca.
El teal de header desapareció del chat en el rediseño de 2022; solo sirve para
ilustraciones de marca.
## 2. Modo oscuro
```css
.wa.dark {
--wa-bg-chat: #0b141a; /* [COM] Web/Android unificado; iOS AMOLED tiende a #000000 */
--wa-doodle-opacity: 0.04;
--wa-bubble-in: #202c33; /* [COM] entrante; variante iOS citada #1f2c33 */
--wa-bubble-out: #005c4b; /* [COM] saliente verde oscuro; [WBI] beta iOS: #204f46 */
--wa-text: #e9edef; /* [COM] casi blanco */
--wa-text-meta: #8696a0; /* [COM] hora/meta en oscuro */
--wa-link: #53bdeb; /* [COM] enlaces toman el celeste de acento en oscuro */
--wa-tick: #8696a0;
--wa-tick-read: #53bdeb;
--wa-chrome: #202c33; /* [COM] header/compose Web; iOS oscuro puro: #1c1c1e–#000000 */
--wa-chrome-text: #e9edef;
--wa-compose-field: #2a3942; /* [COM] campo "Mensaje" en oscuro */
--wa-separator: rgba(255,255,255,.10);
--wa-accent: #00a884;
}
```
Nota iOS 2024–2025: el rediseño de WhatsApp iOS usa fondo **negro puro** en oscuro y
burbujas aún más redondeadas (guías de personalización WWeb; ithinkdiff sobre beta
26.29.10.70). Si el mockup representa "iOS hoy": `--wa-bg-chat:#000`,
`--wa-bubble-in:#1c1c1e`–`#202c33`, `--wa-bubble-out:#005c4b`, chrome `#000/#1c1c1e`.
## 3. Tipografía
```css
.wa {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Helvetica Neue", Helvetica, Arial, sans-serif; /* [APL/COM] stack iOS */
-webkit-font-smoothing: antialiased;
}
.wa .bubble { font-size: 17px; line-height: 1.3; } /* 17 pt = .body de iOS [COM] */
.wa .meta { font-size: 11px; line-height: 1; } /* hora + ticks dentro de burbuja */
.wa .header-title { font-size: 17px; font-weight: 600; } /* nombre contacto, semibold */
.wa .header-sub { font-size: 13px; font-weight: 400; } /* "en línea" / "escribiendo…" */
.wa .btn-label { font-size: 17px; } /* botones de plantilla, texto centrado */
.wa .template-footer { font-size: 12px; color: var(--wa-text-meta); }
.wa .btn-domain { font-size: 12px; color: var(--wa-text-meta); } /* dominio del CTA URL */
```
- Enlaces de burbuja sin subrayado; `color: var(--wa-link)`.
- Hora y ticks van **dentro** de la burbuja, alineados abajo-derecha; el texto del
mensaje se rellena con un espaciador para no pisarlos (ver §4).
## 4. Geometría de burbujas
```css
.wa .row { display:flex; padding:1px 16px 0; } /* fila de mensaje */
.wa .row + .row { margin-top:2px; } /* mismo emisor: 2–3px [COM] */
.wa .row.new-group { margin-top:10px; } /* cambia emisor: 8–12px [COM] */
.wa .row.out { justify-content:flex-end; }
.wa .bubble {
position:relative;
max-width:78%; /* ~75–80% del ancho [COM] */
padding:6px 9px 8px 7px; /* izq≠der por la cola [COM] */
border-radius:18px; /* esquinas continuas (squircle) [COM] */
background:var(--wa-bubble-in);
}
.wa .row.out .bubble { background:var(--wa-bubble-out); }
/* Cola: entrante arriba-izquierda, saliente arriba-derecha.
La esquina del lado de la cola se afila; el rabito curvo se dibuja con ::before */
.wa .row.in .bubble { border-top-left-radius:5px; }
.wa .row.out .bubble { border-top-right-radius:5px; }
.wa .row.in .bubble::before { /* cola entrante */
content:""; position:absolute; top:0; left:-8px;
width:8px; height:13px; fill:var(--wa-bubble-in);
/* clip-path con curva tipo cola; alternativa: SVG inline */
clip-path: path("M8 0 H0 C4 4 6 8 8 13 Z");
}
.wa .row.out .bubble::before { /* cola saliente (espejo) */
content:""; position:absolute; top:0; right:-8px;
width:8px; height:13px; fill:var(--wa-bubble-out);
clip-path: path("M0 0 H8 C4 4 2 8 0 13 Z");
}
```
- Rediseño iOS 2025: burbujas todavía más redondas (WABetaInfo/ithinkdiff) — para un
look "última generación" usar `border-radius:20–22px` y esquina de cola `6px`.
- Media (foto/vídeo) sin padding lateral: la imagen ocupa toda la burbuja
(`padding:0`), esquinas redondeadas iguales, hora encima con gradiente inferior.
Hora + ticks dentro de la burbuja:
```css
.wa .meta {
display:flex; align-items:center; gap:2px;
justify-content:flex-end;
float:right; /* o grid: el texto envuelve alrededor */
margin:6px 0 -2px 12px; /* el texto del mensaje reserva este hueco */
color:var(--wa-text-meta); font-size:11px; user-select:none;
}
.wa .meta .tick-read { color:var(--wa-tick-read); }
```
Estados de tick `[META/COM]`: reloj (pendiente) → `✓` enviado → `✓✓` gris entregado →
`✓✓` celeste `#53bdeb` leído. El doble tick son dos marcas solapadas ~2px.
## 5. Chrome del chat
```css
.wa .header {
display:flex; align-items:center; gap:12px;
padding:8px 12px; background:var(--wa-chrome);
border-bottom:1px solid var(--wa-separator);
/* con Dynamic Island el header arranca bajo la status bar (54pt) */
}
.wa .back-chevron { width:22px; } /* chevron fino, color acento azul iOS o gris */
.wa .avatar { width:40px; height:40px; border-radius:50%; object-fit:cover; }
.wa .header-title { flex:1; } /* nombre bold + subtítulo debajo */
.wa .icon-video, .wa .icon-call { width:24px; margin-left:16px; } /* videollamada, llamada */
```
- Subtítulo: "en línea" estático gris; "escribiendo…" en verde acento `[COM]`.
- Barra inferior (compose):
```css
.wa .compose {
display:flex; align-items:center; gap:8px;
padding:8px 12px calc(8px + env(safe-area-inset-bottom));
background:var(--wa-chrome);
}
.wa .attach { width:28px; } /* + o clip: cámara/fotos/adjuntos */
.wa .field {
flex:1; display:flex; align-items:center; gap:8px;
height:38px; padding:0 12px; border-radius:19px; /* píldora [COM] */
background:var(--wa-compose-field);
color:var(--wa-text-meta); /* placeholder "Mensaje" */
}
.wa .mic { width:26px; } /* micrófono cuando el campo está vacío */
```
## 6. Renderizado de mensajes de la Cloud API `[META]` + observación `[COM]`
### 6.1 Botones de respuesta rápida (interactive `type:"button"`)
- Estructura: header opcional + body (≤1024) + footer (≤60) + **hasta 3 botones**
(título ≤20 caracteres `[META]`).
- En iOS: **apilados verticalmente a todo el ancho del cuerpo**, dentro de una tarjeta
continua con hairline entre ellos; texto **centrado** en verde acento
(`--wa-accent` claro / `#53bdeb`-verde según versión en oscuro) `[COM]`.
```css
.wa .qr-btns { display:flex; flex-direction:column; background:var(--wa-bubble-in);
border-radius:14px; overflow:hidden; margin-top:8px; width:min(78%,340px); }
.wa .qr-btn { padding:12px 8px; text-align:center; font-size:17px;
color:var(--wa-accent); }
.wa .qr-btn + .qr-btn { border-top:1px solid var(--wa-separator); }
```
### 6.2 Botón CTA URL
- Un botón tipo lista bajo la burbuja: icono de **enlace** (o teléfono) a la izquierda,
etiqueta centrada ≤25 caracteres, **dominio en gris pequeño (12px) al pie del botón**
`[COM]`; abre el navegador externo `[META]`.
- Añadir un icono de enlace y en el borde inferior una fila de dominio:
`.cta-domain { border-top:1px solid var(--wa-separator); font-size:12px; color:var(--wa-text-meta); text-align:center; }`
### 6.3 Mensajes de lista (`type:"list"`)
- Burbuja con header (≤60, semibold), body (hasta 4096), footer (≤60) y **un único
botón** (≤20 caracteres) `[META]`.
- Al pulsarlo, iOS abre un **modal/sheet** con las secciones (≤10) y filas (≤10;
título 24, descripción 72) — no filas inline `[META]`. En el mockup basta la burbuja
con botón; el sheet es una vista modal con chevrons/grupo agrupado estilo iOS.
### 6.4 Plantillas (message templates)
Orden renderizado `[META]`: **header → body → footer → botones**.
```css
.wa .template { width:min(78%,340px); border-radius:14px; overflow:hidden;
background:var(--wa-bubble-in); display:flex; flex-direction:column; }
.wa .tpl-media { width:100%; aspect-ratio:16/9; object-fit:cover; } /* header media full-bleed */
.wa .tpl-body { padding:8px 10px; font-size:15px; white-space:pre-wrap; } /* ≤1024 */
.wa .tpl-footer { padding:0 10px 8px; font-size:12px; color:var(--wa-text-meta); }
.wa .tpl-btns { border-top:1px solid var(--wa-separator); } /* reusar .qr-btns */
```
- Header puede ser imagen/vídeo (16:9 recortado a ancho de burbuja), documento
(icono + nombre), texto (≤60, semibold) o ubicación genérica `[META]`.
- Footer gris pequeño (≤60) pegado al body.
- Botones: quick replies (≤25 en plantillas) agrupados, URL ≤2, teléfono ≤1, total ≤10;
si hay >3 botones, el resto se pliega en "See all options" `[META]`.
### 6.5 Carrusel (carousel template) `[META]`
- 2–10 tarjetas, swipe horizontal con **scroll-snap**; cada tarjeta: media del header
(recortada automática a proporción ancha según dispositivo) + body ≤160 + hasta
2 botones; todas las tarjetas comparten componentes.
- Tarjeta ~ 78% del ancho menos padding, con indicador de página (puntos) abajo `[COM]`.
```css
.wa .carousel { display:flex; gap:8px; overflow-x:auto; scroll-snap-type:x mandatory;
padding:2px 16px 8px; }
.wa .card { flex:0 0 78%; scroll-snap-align:center; border-radius:14px; overflow:hidden;
background:var(--wa-bubble-in); }
```
### 6.6 Documento `[META]`
Burbuja blanca/color entrante con: **icono de página coloreado según extensión** (el
cliente elige el icono por extensión), nombre de archivo (`filename`) en negro/texto
medio, extensión + nº páginas en gris pequeño, y botón circular de descarga a la
derecha `[META: icono+nombre+tap para descargar; COM: disposición]`. Caption opcional
(≤1024) debajo.
### 6.7 Audio / nota de voz `[META]`
Burbuja con reproductor: botón play/pausa circular, **waveform** (barras verticales de
~2px con huecos, altura variable), duración en gris y avatar del emisor en notas de voz
propias `[COM: geometría del waveform]`. En la Cloud API se envía como `type:"audio"`
(`audio`/`voice`), se renderiza igual que una nota de voz del usuario `[META]`.
### 6.8 Ubicación `[META]`
Burbuja con **miniatura de mapa estático** (cuadrada redondeada arriba, con pin rojo
centrado `[COM]`), nombre del sitio en semibold y dirección en gris debajo; tap abre
mapa. Campos que llegan: `latitude`, `longitude`, `name`, `address` `[META]`.
### 6.9 Contacto `[META]`
**Tarjeta** con iniciales/avatar redondo, nombre formateado en semibold y **flecha de
perfil** (chevron); al pulsar abre vista de perfil estilo iOS con teléfonos, emails,
direcciones, empresa, cumpleaños y webs, más botones "Message + Save contact" (con
`wa_id`) o "Invite to WhatsApp" (sin `wa_id`) `[META]`.
## 7. Marco de iPhone moderno `[APL]` (iPhone 15/16 Pro base)
```css
.iphone { width:393px; height:852px; border-radius:55px; background:#000;
padding:0; position:relative; overflow:hidden; } /* 393×852pt, radio 55pt */
.island { position:absolute; top:11px; left:50%; transform:translateX(-50%);
width:126px; height:37px; border-radius:20px; background:#000; } /* [COM] */
.statusbar { position:absolute; top:0; height:54px; width:100%;
display:flex; justify-content:space-between; align-items:center;
padding:18px 32px 0; color:#fff; font-size:15px; font-weight:600; }
/* hora "9:41" a la izquierda; derecha: señal (4 barras), wifi, batería (iconos SF) */
.home-indicator { position:absolute; bottom:8px; left:50%; transform:translateX(-50%);
width:139px; height:5px; border-radius:3px; background:#fff; } /* [COM] 134–139 */
```
- Status bar 54 pt de alto en modelos con Dynamic Island `[APL/useyourloaf]`; la hora
"9:41" es la convención de Apple en marketing `[APL]`.
- En modo claro la status bar del chat es **oscura** (texto negro sobre header claro);
en oscuro, blanca.
- El contenido del chat corre **debajo** del header con `backdrop-filter: blur` en iOS
`[COM]` — opcional en mockup; los safe-area (59 pt arriba, 34 pt abajo) delimitan el
header/compose reales.
## 8. Resumen de decisiones para el mockup
1. Usar el set 2022+ unificado (`#efeae2/#ffffff/#d9fdd3` claro, `#0b141a/#202c33/#005c4b`
oscuro) como base; si el objetivo es "iOS 2025", fondo oscuro `#000` y burbujas 20–22px.
2. Cola arriba (no abajo como WhatsApp Web): entrante `top-left`, saliente `top-right`.
3. Hora+ticks flotan abajo-derecha dentro de la burbuja con espaciador de texto.
4. Botones = tarjeta apilada con hairlines, texto centrado en acento verde.
5. Los hex de cliente son mediciones de comunidad (Meta no publica token UI);
documentar en el mockup como "aproximados".
## Fuentes consultadas
- Meta — Carousel templates: developers.facebook.com/docs/whatsapp/business-management-api/message-templates/carousel-templates
- Meta — Template components (nueva ruta): developers.facebook.com/documentation/business-messaging/whatsapp/templates/components
- Meta — Reply buttons: developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-reply-buttons-messages
- Meta — List messages: developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-list-messages
- Meta — Document / Location / Contacts messages: developers.facebook.com/documentation/business-messaging/whatsapp/messages/{document,location,contacts}-messages
- PocketNow/WABetaInfo (hex burbuja iOS beta): pocketnow.com/whatsapp-redesigned-chat-bubble-iphone
- Tokens de comunidad: ui.shreshtharora.com/components/bubble · whatsapp-components.crafter.run · styles.refero.design · gists github.com/13swat · nextwork.ai · codewithcurious.com · mobbin.com/colors/brand/whatsapp · color-hex.com/color-palette/110833
- Dispositivo: screensizes.app/?model=iphone-15-pro · useyourloaf.com/blog/iphone-15-screen-sizes · behance.net/gallery/153642485 (Dynamic Island) · support.apple.com/en-us/111829
- Rediseño iOS oscuro: wwebcustomizer.com/blog/how-to-make-whatsapp-web-look-like-an-iphone-2026 · ithinkdiff.com (burbujas más redondas, beta 26.29.10.70)
+196
View File
@@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Mockup visual 100 % estático de los mensajes de la WhatsApp Cloud API renderizados en un iPhone.">
<meta name="referrer" content="no-referrer">
<title>WhatsApp Cloud API · Mockup</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' rx='6' fill='%2300a884'/%3E%3Cpath fill='%23fff' d='M7.1 3.8c.5-.1 1 .2 1.2.7l1.3 3c.2.4.1.9-.3 1.2l-1.6 1.4a13.3 13.3 0 0 0 6.2 6.2l1.4-1.6c.3-.4.8-.5 1.2-.3l3 1.3c.5.2.8.7.7 1.2l-.5 2.6c-.1.5-.6.9-1.1.9C10.4 20.4 3.6 13.6 3.6 5.4c0-.5.4-1 .9-1.1z'/%3E%3C/svg%3E">
<!-- Orden EXACTO de la SPEC §2/§4 -->
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/iphone.css">
<link rel="stylesheet" href="css/whatsapp.css">
<link rel="stylesheet" href="css/editor.css">
</head>
<body data-tema="claro">
<div id="app">
<!-- ============ Barra superior de la app (shell A) ============ -->
<header id="toolbar" class="toolbar">
<div class="toolbar-marca">
<span class="toolbar-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M7.1 3.8c.5-.1 1 .2 1.2.7l1.3 3c.2.4.1.9-.3 1.2l-1.6 1.4a13.3 13.3 0 0 0 6.2 6.2l1.4-1.6c.3-.4.8-.5 1.2-.3l3 1.3c.5.2.8.7.7 1.2l-.5 2.6c-.1.5-.6.9-1.1.9C10.4 20.4 3.6 13.6 3.6 5.4c0-.5.4-1 .9-1.1z"/></svg>
</span>
<div class="toolbar-titulos">
<h1>WhatsApp Cloud API</h1>
<span class="toolbar-subtitulo">Mockup visual · demo estática</span>
</div>
</div>
<div class="toolbar-acciones">
<button id="btn-tema" class="tb-btn" type="button" title="Alternar tema claro / oscuro" aria-label="Alternar tema">
<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)">
<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">
<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">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 15.6c-.5 0-.9-.4-.9-.9V6.5L8.4 9.2A.9.9 0 1 1 7.1 7.9l4.2-4.2a.9.9 0 0 1 1.3 0l4.2 4.2a.9.9 0 1 1-1.3 1.3l-2.7-2.7v8.2c0 .5-.4.9-.9.9zM4.9 18.5h14.2a.9.9 0 1 1 0 1.8H4.9a.9.9 0 1 1 0-1.8z"/></svg>
<span>Importar</span>
</button>
<input type="file" id="importar-archivo" accept="application/json,.json" hidden>
<button id="btn-reset" class="tb-btn" type="button" title="Restablecer la demo (borra localStorage)">
<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M12 5V2.8a.7.7 0 0 0-1.13-.55L6.9 5.2a.7.7 0 0 0 0 1.1l3.97 2.95A.7.7 0 0 0 12 8.7V6.5a5.5 5.5 0 1 1-5.5 5.5H4.75A7.25 7.25 0 1 0 12 5z"/></svg>
<span>Restablecer</span>
</button>
</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>
<!-- El formulario de identidad lo pinta js/editor.js (C) en runtime -->
<div id="editor-cuenta"></div>
</section>
<section class="wam-bloque">
<h2 class="wam-bloque-titulo">Mensajes</h2>
<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>
<input type="hidden" id="nuevo-tipo-valor" value="text">
<button id="btn-anadir" class="wam-btn-anadir" type="button">Añadir</button>
</div>
<!-- Una tarjeta colapsable por mensaje la pinta js/editor.js (C) -->
<div id="lista-mensajes"></div>
</section>
</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>
<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 class="iphone-escena">
<div id="iphone" class="iphone">
<div id="pantalla" class="pantalla wa">
<!-- Hardware -->
<div id="isla" class="island" aria-hidden="true"></div>
<div id="statusbar" class="statusbar" aria-hidden="true">
<span class="sb-hora">9:41</span>
<span class="sb-iconos">
<svg class="sb-svg sb-senal" width="18" height="12" viewBox="0 0 18 12" fill="currentColor" aria-hidden="true">
<rect x="0" y="7.4" width="3" height="4.6" rx="1"/>
<rect x="4.7" y="5.2" width="3" height="6.8" rx="1"/>
<rect x="9.4" y="2.8" width="3" height="9.2" rx="1"/>
<rect x="14.1" y="0.4" width="3" height="11.6" rx="1"/>
</svg>
<svg class="sb-svg sb-wifi" width="17" height="12" viewBox="0 0 17 12" fill="none" aria-hidden="true">
<path d="M1.2 4.1a11 11 0 0 1 14.6 0" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
<path d="M3.9 7a7 7 0 0 1 9.2 0" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
<circle cx="8.5" cy="10.2" r="1.7" fill="currentColor"/>
</svg>
<svg class="sb-svg sb-bateria" width="28" height="13" viewBox="0 0 28 13" fill="none" aria-hidden="true">
<rect x="0.75" y="0.75" width="22.5" height="11.5" rx="3.5" stroke="currentColor" stroke-width="1.2" opacity="0.45"/>
<path d="M25.3 4.4v4.2c1.2-.3 2-1.1 2-2.1s-.8-1.8-2-2.1z" fill="currentColor" opacity="0.45"/>
<rect x="2.5" y="2.5" width="15.5" height="8" rx="2" fill="currentColor"/>
</svg>
</span>
</div>
<!-- Header del chat (estructura A · contenido B) -->
<header id="chat-header" class="header">
<svg class="back-chevron" viewBox="0 0 24 24" width="24" height="24" fill="none" aria-hidden="true">
<path d="M15 4.5 7.5 12l7.5 7.5" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<img id="chat-avatar" class="avatar" alt="Avatar de la cuenta">
<div class="header-title">
<span class="header-linea">
<span id="chat-nombre"></span>
<span id="chat-verificado" class="header-verificado" title="Cuenta verificada">
<svg class="verificado-svg" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<path fill="currentColor" d="M12 1.7l2.1 1.6 2.6-.5 1.1 2.4 2.4 1.1-.5 2.6 1.6 2.1-1.6 2.1.5 2.6-2.4 1.1-1.1 2.4-2.6-.5-2.1 1.6-2.1-1.6-2.6.5-1.1-2.4-2.4-1.1.5-2.6L2.7 12l1.6-2.1-.5-2.6 2.4-1.1 1.1-2.4 2.6.5z"/>
<path fill="#fff" d="M10.7 15.1l-3-3 1.3-1.3 1.7 1.7 4.3-4.3 1.3 1.3z"/>
</svg>
</span>
</span>
<span id="chat-subtitulo" class="header-sub"></span>
</div>
<svg class="icon-video" viewBox="0 0 24 24" width="24" height="24" fill="currentColor" aria-hidden="true">
<path d="M4.5 5.5h9A2.5 2.5 0 0 1 16 8v8a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 2 16V8a2.5 2.5 0 0 1 2.5-2.5z"/>
<path d="M17.5 10.2l3.6-2.6c.5-.4 1.2 0 1.2.6v7.6c0 .6-.7 1-1.2.6l-3.6-2.6z"/>
</svg>
<svg class="icon-call" viewBox="0 0 24 24" width="21" height="21" fill="currentColor" aria-hidden="true">
<path d="M7.1 3.8c.5-.1 1 .2 1.2.7l1.3 3c.2.4.1.9-.3 1.2l-1.6 1.4a13.3 13.3 0 0 0 6.2 6.2l1.4-1.6c.3-.4.8-.5 1.2-.3l3 1.3c.5.2.8.7.7 1.2l-.5 2.6c-.1.5-.6.9-1.1.9C10.4 20.4 3.6 13.6 3.6 5.4c0-.5.4-1 .9-1.1z"/>
</svg>
</header>
<!-- Scroll de mensajes (estructura A · filas li.wa-row de B) -->
<ul id="chat-cuerpo" aria-live="polite"></ul>
<!-- Indicador "escribiendo…" (estructura A · contenido y animación de B) -->
<div id="chat-escribiendo"></div>
<!-- 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>
<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>
</div>
<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>
</button>
</form>
<!-- Sheet de lista (estructura A · ciclo crear/destruir y contenido: B) -->
<div id="sheet" class="sheet" hidden>
<div id="sheet-fondo" class="sheet-fondo"></div>
<div class="sheet-panel" role="dialog" aria-modal="true" aria-labelledby="sheet-titulo">
<div id="sheet-titulo" class="sheet-titulo"></div>
<ul id="sheet-opciones" class="sheet-opciones"></ul>
</div>
</div>
<div id="home-indicator" class="home-indicator" aria-hidden="true"></div>
</div>
</div>
</div>
</section>
</div>
<noscript><p class="wam-noscript">Esta demo necesita JavaScript habilitado.</p></noscript>
<!-- Orden EXACTO de la SPEC §3 (todos al final del body, sin defer/module;
el único efecto al cargar es WAM.init() desde main.js) -->
<script src="js/store.js"></script> <!-- D: WAM.CLAVE/estado/normalizar; NO toca DOM al cargar -->
<script src="js/render.js"></script> <!-- B: render del chat; NO toca DOM al cargar -->
<script src="js/editor.js"></script> <!-- C: formularios del editor; NO toca DOM al cargar -->
<script src="js/interactivity.js"></script> <!-- D: flujos/typing/demo; NO toca DOM al cargar -->
<script src="js/main.js"></script> <!-- D: única ejecución — WAM.init() -->
</body>
</html>
+1758
View File
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
/* 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. */
window.WAM = window.WAM || {};
(function () {
"use strict";
var CANCELADO = "wam:cancelado";
var TIPOS_VALIDOS = [
"text", "image", "video", "audio", "document", "sticker", "location",
"contact", "template", "buttons", "list", "cta_url", "carousel"
];
/* 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 quitar(fn) {
var i = cancelaciones.indexOf(fn);
if (i !== -1) cancelaciones.splice(i, 1);
}
function cancelarTodo() {
var copia = cancelaciones.slice();
cancelaciones = [];
for (var i = 0; i < copia.length; i++) copia[i]();
}
function escribiendo(visible) {
if (typeof WAM.setEscribiendo === "function") WAM.setEscribiendo(visible);
}
/* 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);
}
cancelaciones.push(cancelar);
});
};
WAM.demoActiva = function () { return demoAndando; };
function avisarDemo() {
if (typeof WAM.onDemoChange === "function") {
try { WAM.onDemoChange(demoAndando); } catch (e) { /* el hook es opcional */ }
}
}
/* ————————————————— alta de mensajes (mutación + guardar + DOM incremental) ————————————————— */
function anadir(msg) {
WAM.estado.mensajes.push(msg);
WAM.guardar();
if (typeof WAM.anadirMensajeDOM === "function") WAM.anadirMensajeDOM(msg);
}
function mensajeOut(texto) {
return {
id: WAM.generarId(),
dir: "out",
tipo: "text",
hora: WAM.horaAhora(),
estado: "sent",
texto: String(texto == null ? "" : texto)
};
}
/* Copia una respuesta anidada (Mensaje completo) para poder reutilizarla
cada vez que se vuelve a pulsar el mismo botón. */
function mensajeDesde(origen) {
var copia;
try {
copia = JSON.parse(JSON.stringify(
(origen && typeof origen === "object") ? origen : { tipo: "text", texto: String(origen == null ? "" : origen) }
));
} catch (e) {
copia = { tipo: "text", texto: "" };
}
if (!copia || typeof copia !== "object" || Array.isArray(copia)) copia = { tipo: "text", texto: "" };
if (TIPOS_VALIDOS.indexOf(copia.tipo) === -1) copia.tipo = "text";
copia.id = WAM.generarId();
copia.dir = "in";
copia.hora = WAM.horaAhora();
copia.estado = "sent";
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. */
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 (!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). */
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);
msg.estado = "delivered";
WAM.guardar();
repintarTickDe(msg, "delivered");
await WAM.espera(800);
msg.estado = "read";
WAM.guardar();
repintarTickDe(msg, "read");
}
/* ————————————————— 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);
for (var i = 0; i < lista.length; i++) {
escribiendo(true);
await WAM.espera(900);
escribiendo(false);
anadir(mensajeDesde(lista[i]));
}
escribiendo(false);
} catch (e) {
escribiendo(false);
if (e !== CANCELADO) { /* el mockup nunca ensucia la consola */ }
}
})();
return secuencia;
};
/* Envío libre del composer: burbuja OUT con hora actual y ticks automáticos. */
WAM.enviarLibre = function (texto) {
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 */ });
};
/* 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). */
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 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 (ds.btn !== undefined) {
var boton = (msg.botones || [])[parseInt(ds.btn, 10)];
return boton ? { texto: boton.texto || "", respuestas: boton.respuestas || [] } : vacio;
}
if (ds.card !== undefined) {
var card = (msg.cards || [])[parseInt(ds.card, 10)];
return card ? { texto: card.textoBoton || "", respuestas: card.respuestas || [] } : vacio;
}
/* 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;
};
/* ————————————————— reproducción de la demo ————————————————— */
WAM.reproducirDemo = function () {
if (demoAndando) return;
demoAndando = true;
avisarDemo();
var cuerpo = document.getElementById("chat-cuerpo");
if (cuerpo) cuerpo.innerHTML = "";
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 (typeof WAM.anadirMensajeDOM === "function") WAM.anadirMensajeDOM(msg);
await WAM.espera(600);
}
demoAndando = false;
escribiendo(false);
avisarDemo();
} catch (e) {
demoAndando = false;
escribiendo(false);
avisarDemo();
}
})();
};
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();
};
})();
+161
View File
@@ -0,0 +1,161 @@
/* 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. */
window.WAM = window.WAM || {};
(function () {
"use strict";
function porId(id) {
return document.getElementById(id);
}
function al(id, evento, manejador) {
var el = porId(id);
if (el) el.addEventListener(evento, manejador);
}
/* ————————————————— botón "Reproducir demo" ————————————————— */
function pintarBotonDemo() {
var btn = porId("btn-reproducir");
if (!btn) return;
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;
}
function alReproducir() {
if (WAM.demoActiva()) WAM.detenerDemo();
else WAM.reproducirDemo();
pintarBotonDemo();
}
/* ————————————————— toolbar ————————————————— */
function alTema() {
WAM.estado.tema = WAM.estado.tema === "oscuro" ? "claro" : "oscuro";
WAM.guardar();
WAM.setTema(WAM.estado.tema);
}
function alPresentacion() {
document.body.classList.toggle("presentacion");
var btn = porId("btn-presentacion");
if (btn) {
btn.setAttribute("aria-pressed",
document.body.classList.contains("presentacion") ? "true" : "false");
}
}
function reRenderTotal() {
WAM.setTema(WAM.estado.tema);
WAM.render();
WAM.renderEditor();
pintarBotonDemo();
}
function alExportar() {
if (typeof WAM.exportarJSON === "function") WAM.exportarJSON();
}
function alImportar() {
var archivo = porId("importar-archivo");
if (archivo) archivo.click();
}
function alArchivoImport(e) {
var input = e.currentTarget;
var archivo = input.files && input.files[0];
input.value = ""; /* permite reimportar el mismo archivo */
if (!archivo || typeof WAM.importarJSON !== "function") return;
WAM.detenerDemo();
var promesa = WAM.importarJSON(archivo);
if (promesa && typeof promesa.then === "function") {
promesa.then(reRenderTotal, reRenderTotal);
} else {
reRenderTotal();
}
}
function alReset() {
WAM.detenerDemo();
WAM.resetear();
reRenderTotal();
}
/* ————————————————— chat: delegación de clics (contrato B→D) ————————————————— */
function alClicChat(e) {
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 */
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;
if (plano && msg && msg.tipo === "list") {
if (typeof WAM.abrirSheet === "function") WAM.abrirSheet(indice);
return;
}
var flujo = WAM.resolverFlujo(el);
if (flujo && flujo.texto) WAM.flujo(flujo.texto, flujo.respuestas);
}
/* Opciones del sheet 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;
var hoja = porId("sheet");
if (!hoja || !hoja.contains(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);
}
/* ————————————————— composer ————————————————— */
function alComposer(e) {
e.preventDefault();
var input = porId("composer-input");
var texto = input ? input.value : "";
WAM.enviarLibre(texto);
if (input) {
input.value = "";
input.focus();
}
}
/* ————————————————— bootstrap ————————————————— */
WAM.init = function () {
WAM.estado = WAM.cargar();
WAM.setTema(WAM.estado.tema);
WAM.render();
WAM.renderEditor();
WAM.onDemoChange = pintarBotonDemo;
al("chat-cuerpo", "click", alClicChat);
al("pantalla", "click", alClicPantalla);
al("composer", "submit", alComposer);
al("btn-reproducir", "click", alReproducir);
al("btn-tema", "click", alTema);
al("btn-presentacion", "click", alPresentacion);
al("btn-exportar", "click", alExportar);
al("btn-importar", "click", alImportar);
al("importar-archivo", "change", alArchivoImport);
al("btn-reset", "click", alReset);
pintarBotonDemo();
};
WAM.init();
})();
+699
View File
@@ -0,0 +1,699 @@
/* ==========================================================================
js/render.js — dueño B: pinta el chat de WhatsApp dentro del iPhone.
API (SPEC §2): WAM.render / renderHeader / renderMensaje / anadirMensajeDOM /
setTema / setEscribiendo / scrollAbajo / abrirSheet / cerrarSheet / tick.
Campos por tipo: SPEC §1. Colores, geometría y tipografía: docs/research-ui.md.
Cero listeners de flujo (los clics los resuelve js/interactivity.js con los
data-* estampados aquí; main.js abre el sheet del list con el data-flujo
"plano" del botón). Listeners propios: cierre en #sheet-fondo (elemento
creado por B), scroll de puntos del carousel y despliegue del plegado
">3 botones". No toca DOM al cargar.
========================================================================== */
(function () {
"use strict";
window.WAM = window.WAM || {};
/* ------------------------------ helpers ------------------------------ */
function byId(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function vacio(s) { return s == null || String(s) === ""; }
function estado() { return WAM.estado || { cuenta: {}, mensajes: [] }; }
var ESCRIBIENDO = false;
/* Placeholder local; en la práctica usa WAM.svgPlaceholder (js/store.js). */
function placeholder(texto) {
if (typeof WAM.svgPlaceholder === "function") {
try {
var u = WAM.svgPlaceholder(String(texto == null ? "" : texto));
if (u) return u;
} catch (e) { /* fallback abajo */ }
}
var t = esc(String(texto == null ? "" : texto).slice(0, 42));
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240">' +
'<rect width="320" height="240" fill="#e7e3da"/>' +
'<rect x="122" y="70" width="76" height="60" rx="9" fill="none" stroke="#b9b2a4" stroke-width="4"/>' +
'<circle cx="143" cy="89" r="6" fill="#b9b2a4"/>' +
'<path d="M126 122 L148 104 L163 117 L176 106 L194 121" fill="none" stroke="#b9b2a4" stroke-width="4" stroke-linejoin="round"/>' +
'<text x="160" y="176" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" fill="#9a927f">' + t + "</text></svg>";
return "data:image/svg+xml," + encodeURIComponent(svg);
}
/* ------------------------------ iconos SVG inline ------------------------------ */
function svgChevronAtras() {
return '<svg width="12" height="20" viewBox="0 0 12 20" aria-hidden="true"><path d="M10.3 1.5 2 10l8.3 8.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgChevronDer(px) {
var s = px || 14;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 12 20" aria-hidden="true"><path d="M1.7 1.5 10 10l-8.3 8.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgVideoLlamada() {
return '<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true"><path d="M3.6 6.2h10.6a1.9 1.9 0 0 1 1.9 1.9v7.8a1.9 1.9 0 0 1-1.9 1.9H3.6a1.9 1.9 0 0 1-1.9-1.9V8.1a1.9 1.9 0 0 1 1.9-1.9Zm13.4 4.1 4.4-3a.55.55 0 0 1 .9.5v8.4a.55.55 0 0 1-.9.5l-4.4-3Z" fill="currentColor"/></svg>';
}
function svgLlamada(px) {
var s = px || 23;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3.4c.6-.6 1.5-.5 2 .1l2.1 2.6c.4.6.4 1.4-.1 1.9l-1.2 1.4c.9 1.8 2.5 3.4 4.3 4.3l1.4-1.2c.5-.5 1.3-.5 1.9-.1l2.6 2.1c.6.5.7 1.4.1 2l-1.6 1.7c-.6.6-1.5.9-2.4.6-5.2-1.7-9.3-5.8-11-11-.3-.9 0-1.8.6-2.4Z" fill="currentColor"/></svg>';
}
function svgVerificado() {
return '<svg width="17" height="17" viewBox="0 0 16 16" aria-hidden="true">' +
'<polygon points="16,8 14.1,10.53 13.66,13.66 10.53,14.1 8,16 5.47,14.1 2.34,13.66 1.9,10.53 0,8 1.9,5.47 2.34,2.34 5.47,1.9 8,0 10.53,1.9 13.66,2.34 14.1,5.47" fill="#53bdeb"/>' +
'<path d="M4.6 8.3 7 10.6 11.4 5.7" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgPlay(px) {
var s = px || 18;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M8.2 5.4v13.2L19.4 12Z" fill="currentColor"/></svg>';
}
function svgMicrofono(px) {
var s = px || 19;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 14.6a3.4 3.4 0 0 0 3.4-3.4V5.9a3.4 3.4 0 0 0-6.8 0v5.3a3.4 3.4 0 0 0 3.4 3.4Zm-5.8-3.4a5.8 5.8 0 0 0 11.6 0M12 17v3.2M8.8 20.2h6.4" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgMusica(px) {
var s = px || 19;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.2 18.3V5.6L18.4 3.5v12.7" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6.7" cy="18.4" r="2.5" fill="currentColor"/><circle cx="15.9" cy="16.3" r="2.5" fill="currentColor"/></svg>';
}
function svgDescarga(px) {
var s = px || 15;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4.2v10.6M7.6 10.6l4.4 4.4 4.4-4.4M5 19.6h14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgEnlace(px) {
var s = px || 18;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.9 14.1a4.1 4.1 0 0 1 0-5.8l3-3a4.1 4.1 0 0 1 5.8 5.8l-1.7 1.7M14.1 9.9a4.1 4.1 0 0 1 0 5.8l-3 3a4.1 4.1 0 0 1-5.8-5.8l1.7-1.7" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
}
function svgCorreo(px) {
var s = px || 15;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5.5" width="18" height="13" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M4 7l8 6 8-6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgEmpresa(px) {
var s = px || 15;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><rect x="3.2" y="7.6" width="17.6" height="12.4" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M9 7.6V6.2A2.2 2.2 0 0 1 11.2 4h1.6A2.2 2.2 0 0 1 15 6.2v1.4M3.2 12.4h17.6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
}
/* Icono de página coloreado por extensión (research-ui §6.6) */
var COLORES_DOC = {
pdf: "#e2574c", doc: "#4577e0", docx: "#4577e0",
xls: "#2f9e5b", xlsx: "#2f9e5b", csv: "#2f9e5b",
ppt: "#e5813d", pptx: "#e5813d",
txt: "#8592a0", zip: "#a5855e", rar: "#a5855e"
};
function extDe(nombre) {
var n = String(nombre == null ? "" : nombre);
var partes = n.split(".");
return partes.length > 1 ? partes.pop().toLowerCase() : "doc";
}
function docIcon(ext, px) {
var w = px || 30;
var h = Math.round(w * 36 / 30);
var color = COLORES_DOC[ext] || "#7c8894";
var etiqueta = ext.length <= 4 ? ext.toUpperCase() : "DOC";
return '<svg width="' + w + '" height="' + h + '" viewBox="0 0 30 36" aria-hidden="true">' +
'<path d="M3 4a3 3 0 0 1 3-3h14l7 7v24a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3Z" fill="' + color + '"/>' +
'<path d="M20 1l7 7h-6a1 1 0 0 1-1-1Z" fill="rgba(255,255,255,.4)"/>' +
'<text x="15" y="24.5" text-anchor="middle" font-family="Arial, sans-serif" font-size="7.5" font-weight="700" fill="#fff">' + esc(etiqueta) + "</text></svg>";
}
/* Mapa estático con pin rojo (research-ui §6.8, data-URI sin red) */
function svgMapa() {
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="190" viewBox="0 0 320 190">' +
'<rect width="320" height="190" fill="#e8e4da"/>' +
'<path d="M0 0h90L60 60H0Z" fill="#ded9cb"/>' +
'<path d="M230 190v-70l90-24v94Z" fill="#ded9cb"/>' +
'<path d="M115 0h75l-12 62h-70Z" fill="#e0dcd1"/>' +
'<path d="M0 150q80-26 160-6t160-14v60H0Z" fill="#cfe0e8"/>' +
'<path d="M118 0q10 60-8 120t-30 70" stroke="#fff" stroke-width="11" fill="none"/>' +
'<path d="M0 84q90 18 170 2t150-30" stroke="#fff" stroke-width="11" fill="none"/>' +
'<path d="M205 0q-6 50 12 95t40 95" stroke="#fff" stroke-width="7" fill="none"/>' +
'<path d="M40 40q60 24 120 12" stroke="#fff" stroke-width="5" fill="none"/>' +
'<path d="M252 40q40 10 68 6" stroke="#fff" stroke-width="5" fill="none"/>' +
'<ellipse cx="160" cy="103" rx="12" ry="4" fill="rgba(0,0,0,.18)"/>' +
'<path d="M160 34c-14.4 0-24 10-24 22.6C136 72 160 97 160 97s24-25 24-40.4C184 44 174.4 34 160 34Z" fill="#e14b4b"/>' +
'<circle cx="160" cy="56" r="8" fill="#fff"/>' +
"</svg>";
return "data:image/svg+xml," + encodeURIComponent(svg);
}
/* ------------------------------ texto enriquecido ------------------------------ */
/* Formato WhatsApp (research-api §2): *negrita* _cursiva_ ~tachado~ ```mono```
+ URLs hipervinculadas (color --wa-link, sin subrayado). */
function formatear(txt) {
var partes = String(txt == null ? "" : txt).split(/(https?:\/\/[^\s<>"]+)/g);
var html = "";
for (var i = 0; i < partes.length; i++) {
var p = partes[i];
if (p === "") continue;
if (/^https?:\/\//.test(p)) {
html += '<a class="wa-link" href="' + esc(p) + '" target="_blank" rel="noopener noreferrer">' + esc(p) + "</a>";
} else {
var t = esc(p);
t = t.replace(/```([\s\S]+?)```/g, "<code>$1</code>");
t = t.replace(/\*([^*\n]+)\*/g, "<strong>$1</strong>");
t = t.replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/g, "$1<em>$2</em>");
t = t.replace(/(^|[^\w])~([^~\n]+)~(?=[^\w]|$)/g, "$1<del>$2</del>");
html += t;
}
}
return html;
}
/* ------------------------------ ticks y meta ------------------------------ */
/* ✓ enviado (gris) / ✓✓ entregado (gris) / ✓✓ leído (celeste #53bdeb) */
var TRAZO_TICK = '<path d="M1 5.4 4.1 8.5 10.4 1.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>';
WAM.tick = function (estadoTick) {
var uno = '<svg class="tick" width="12" height="10" viewBox="0 0 11 10" aria-hidden="true">' + TRAZO_TICK + "</svg>";
var dos = '<svg class="tick" width="17" height="10" viewBox="0 0 16 10" aria-hidden="true">' + TRAZO_TICK +
'<path d="M6 5.4 9.1 8.5 15.4 1.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
if (estadoTick === "read") return dos.replace('class="tick"', 'class="tick tick-read"');
if (estadoTick === "delivered") return dos;
return uno;
};
/* Hora (+ ticks solo en OUT) dentro de la burbuja */
function metaHTML(m, extra) {
var mm = m || {};
var html = '<span class="meta' + (extra ? " " + extra : "") + '">' + esc(vacio(mm.hora) ? "" : mm.hora);
if (mm.dir === "out") html += WAM.tick(mm.estado || "sent");
return html + "</span>";
}
/* ------------------------------ piezas de burbujas ------------------------------ */
function insigniaPlay(px) {
return '<span class="wa-play-badge' + (px && px <= 38 ? " wa-play-sm" : "") + '">' + svgPlay(px || 26) + "</span>";
}
function burbujaTexto(m) {
return '<div class="bubble"><div class="wa-texto">' + formatear(m.texto) + metaHTML(m) + "</div></div>";
}
/* image / video: media full-bleed; caption debajo; hora encima o en caption.
El placeholder SVG (data:image/svg+xml) solo lo decodifican los <img>: un
<video> con ese src lanza MEDIA_ELEMENT_ERROR, así que el vídeo sin src
degrada al placeholder como imagen (SPEC §5.10). */
function burbujaMedia(m, tipo) {
var tieneCaption = !vacio(m.caption);
var esVideo = tipo === "video";
var sinSrc = vacio(m.src);
var src = sinSrc ? placeholder(esVideo ? "video" : (m.caption || "imagen")) : m.src;
var media;
if (esVideo) {
var nodo = sinSrc
? '<img class="wa-media" src="' + esc(src) + '" alt="" draggable="false">'
: '<video class="wa-media" src="' + esc(src) + '" muted playsinline preload="metadata"></video>';
media = '<div class="wa-media-wrap">' + nodo + insigniaPlay(52) + "</div>";
} else {
media = '<img class="wa-media" src="' + esc(src) + '" alt="" draggable="false">';
}
var html = '<div class="bubble wa-bubble-media' + (tieneCaption ? " wa-con-caption" : "") + '">' + media;
if (tieneCaption) {
html += '<div class="wa-caption">' + formatear(m.caption) + metaHTML(m) + "</div>";
} else {
html += '<div class="wa-gradiente"></div>' + metaHTML(m, "meta-en-media");
}
return html + "</div>";
}
/* Waveform determinista a partir del id del mensaje */
function waveform(m) {
var n = 26, paso = 4.6, alto = 18;
var semilla = 0, id = String((m && m.id) || "wam");
for (var c = 0; c < id.length; c++) semilla = (semilla + id.charCodeAt(c) * (c + 7)) % 9973;
var barras = "";
for (var i = 0; i < n; i++) {
var h = 4 + Math.abs(Math.sin(semilla * 0.37 + i * 1.7)) * (alto - 5);
var y = (alto - h) / 2;
barras += '<rect x="' + (i * paso).toFixed(1) + '" y="' + y.toFixed(1) + '" width="2" height="' + h.toFixed(1) + '" rx="1"/>';
}
var w = (n * paso - 2.6).toFixed(1);
return '<svg class="audio-wave" width="' + w + '" height="' + alto + '" viewBox="0 0 ' + w + " " + alto + '" fill="currentColor" aria-hidden="true">' + barras + "</svg>";
}
function burbujaAudio(m) {
var voz = !!m.voz;
var dur = vacio(m.duracion) ? "" : '<span class="audio-dur">' + esc(m.duracion) + "</span>";
return '<div class="bubble wa-audio"><div class="audio-line">' +
'<span class="audio-play">' + svgPlay(15) + "</span>" +
waveform(m) + dur +
'<span class="audio-icono' + (voz ? " wa-voz" : "") + '">' + (voz ? svgMicrofono() : svgMusica()) + "</span>" +
"</div>" + metaHTML(m) + "</div>";
}
function burbujaDocumento(m) {
var nombre = vacio(m.filename) ? "documento.pdf" : String(m.filename);
var ext = extDe(nombre);
var html = '<div class="bubble wa-doc"><div class="doc-line">' +
'<span class="doc-icono">' + docIcon(ext, 30) + "</span>" +
'<span class="doc-datos"><span class="doc-nombre">' + esc(nombre) + "</span>" +
'<span class="doc-sub">' + esc(ext.toUpperCase()) + " · Documento</span></span>" +
'<span class="doc-descarga">' + svgDescarga() + "</span>" +
"</div>";
if (!vacio(m.caption)) html += '<div class="wa-caption">' + formatear(m.caption) + metaHTML(m) + "</div>";
else html += metaHTML(m);
return html + "</div>";
}
function stickerHTML(m) {
var src = vacio(m.src) ? placeholder("sticker") : m.src;
return '<div class="sticker-box" data-hora="' + esc(vacio(m.hora) ? "" : m.hora) + '">' +
'<img class="sticker-img" src="' + esc(src) + '" alt="Sticker" draggable="false"></div>';
}
function burbujaUbicacion(m) {
var tieneDatos = !vacio(m.nombre) || !vacio(m.direccion);
var html = '<div class="bubble wa-loc"><img class="loc-mapa" src="' + svgMapa() + '" alt="Ubicación" draggable="false">';
if (tieneDatos) {
html += '<div class="loc-datos">' +
(vacio(m.nombre) ? "" : '<div class="loc-nombre">' + esc(m.nombre) + "</div>") +
(vacio(m.direccion) ? "" : '<div class="loc-dir">' + esc(m.direccion) + "</div>") +
metaHTML(m) + "</div>";
} else {
html += '<div class="wa-gradiente"></div>' + metaHTML(m, "meta-en-media");
}
return html + "</div>";
}
function inicialesDe(nombre) {
var partes = String(nombre == null ? "" : nombre).trim().split(/\s+/);
var s = "";
for (var i = 0; i < partes.length && s.length < 2; i++) {
if (partes[i]) s += partes[i].charAt(0).toUpperCase();
}
return s || "?";
}
function colorDe(nombre) {
var paleta = ["#7f8ea8", "#8a7fa8", "#a87f96", "#7fa88f", "#a89a7f", "#7f9aa8", "#a8847f", "#6f8ba4"];
var h = 0, s = String(nombre == null ? "" : nombre);
for (var i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 9973;
return paleta[h % paleta.length];
}
function burbujaContacto(m) {
var nombre = vacio(m.nombre) ? "Contacto" : String(m.nombre);
var filas = "";
if (!vacio(m.telefono)) filas += '<div class="contact-fila">' + svgLlamada(15) + "<span>" + esc(m.telefono) + "</span></div>";
if (!vacio(m.email)) filas += '<div class="contact-fila">' + svgCorreo() + "<span>" + esc(m.email) + "</span></div>";
if (!vacio(m.empresa)) filas += '<div class="contact-fila">' + svgEmpresa() + "<span>" + esc(m.empresa) + "</span></div>";
return '<div class="bubble wa-contact">' +
'<div class="contact-cab">' +
'<span class="contact-avatar" style="background:' + colorDe(nombre) + '">' + esc(inicialesDe(nombre)) + "</span>" +
'<span class="contact-nombre">' + esc(nombre) + "</span>" +
'<span class="contact-chev">' + svgChevronDer(14) + "</span>" +
"</div>" +
(filas ? '<div class="contact-filas">' + filas + "</div>" : "") +
metaHTML(m) +
"</div>";
}
/* Botones apilados (template / quick replies). Con url → <a>; sin url → flujo.
Con >3 botones (plantillas) el cliente real muestra 2 y un plegado
"See all options" (research-api §7.2 / research-ui §6.4): se pintan los
2 primeros + "Ver todas las opciones", que despliega el resto. */
function tarjetaBotones(botones, i, clase) {
var bs = Array.isArray(botones) ? botones : [];
var total = Math.min(bs.length, 10);
var plegar = total > 3;
var html = "", ocultos = "";
for (var j = 0; j < total; j++) {
var b = bs[j] || {};
var etiqueta = vacio(b.texto) ? "Botón " + (j + 1) : String(b.texto);
var nodo;
if (!vacio(b.url)) {
nodo = '<a class="qr-btn" href="' + esc(b.url) + '" target="_blank" rel="noopener noreferrer">' + esc(etiqueta) + "</a>";
} else {
nodo = '<span class="qr-btn" role="button" tabindex="0" data-flujo="' + i + '" data-btn="' + j + '">' + esc(etiqueta) + "</span>";
}
if (plegar && j < 2) html += nodo;
else if (plegar) ocultos += nodo.replace(/^<(a|span) /, "<$1 hidden ");
else html += nodo;
}
if (!html && !ocultos) return "";
if (ocultos) {
html += '<span class="qr-btn" role="button" tabindex="0" data-ver-todas="1">Ver todas las opciones (' + (total - 2) + ")</span>" + ocultos;
}
return '<div class="qr-btns' + (clase ? " " + clase : "") + '">' + html + "</div>";
}
/* Despliegue del plegado ">3 botones" (listener propio de B, solo presentación) */
function activarPlegado(li) {
var toggle = li.querySelector("[data-ver-todas]");
if (!toggle) return;
toggle.addEventListener("click", function () {
var ocultos = li.querySelectorAll(".qr-btn[hidden]");
for (var k = 0; k < ocultos.length; k++) ocultos[k].removeAttribute("hidden");
if (toggle.parentNode) toggle.parentNode.removeChild(toggle);
});
}
/* Header compartido por template y buttons (research-api §6.1/§7.2):
imagen / vídeo / documento / texto. Vídeo sin headerMedia → placeholder
como <img> (un <video> no decodifica el SVG; ver burbujaMedia). */
function headerHTML(m) {
var ht = m.headerTipo || "";
if (ht === "imagen") {
var src = vacio(m.headerMedia) ? placeholder(vacio(m.headerTexto) ? "imagen" : m.headerTexto) : m.headerMedia;
return '<img class="tpl-media" src="' + esc(src) + '" alt="" draggable="false">';
}
if (ht === "video") {
var sinVideo = vacio(m.headerMedia);
var srcv = sinVideo ? placeholder("video") : m.headerMedia;
var nodo = sinVideo
? '<img class="tpl-media" src="' + esc(srcv) + '" alt="" draggable="false">'
: '<video class="tpl-media" src="' + esc(srcv) + '" muted playsinline preload="metadata"></video>';
return '<div class="tpl-media-wrap">' + nodo + insigniaPlay(38) + "</div>";
}
if (ht === "documento") {
var nom = vacio(m.headerTexto) ? "documento.pdf" : String(m.headerTexto);
return '<div class="tpl-header-doc">' + docIcon(extDe(nom), 22) + "<span>" + esc(nom) + "</span></div>";
}
if (ht === "texto") {
return vacio(m.headerTexto) ? "" : '<div class="tpl-header-txt">' + esc(m.headerTexto) + "</div>";
}
return "";
}
/* template: header → body → footer → botones (research-ui §6.4) */
function tplHTML(m, i) {
var header = headerHTML(m);
var body = vacio(m.body) ? "" : '<div class="tpl-body">' + formatear(m.body) + "</div>";
var footer = vacio(m.footer) ? "" : '<span class="template-footer">' + formatear(m.footer) + "</span>";
return '<div class="tpl">' + header + body +
'<div class="wa-pie">' + footer + metaHTML(m) + "</div>" +
tarjetaBotones(m.botones, i, "tpl-btns") +
"</div>";
}
/* Pie común dentro de burbuja interactiva: footer gris + hora/ticks */
function pieBurbuja(m, footer) {
return '<div class="wa-pie">' + (vacio(footer) ? "" : '<span class="template-footer">' + formatear(footer) + "</span>") + metaHTML(m) + "</div>";
}
/* buttons: quick replies (máx 3) con header opcional (research-api §6.1) */
function buttonsHTML(m, i) {
return '<div class="wa-stack">' +
'<div class="bubble">' + headerHTML(m) + '<div class="wa-texto">' + formatear(m.body) + "</div>" + pieBurbuja(m, m.footer) + "</div>" +
tarjetaBotones(m.botones, i, "") +
"</div>";
}
/* list: header + body + footer + botón único. Sin data-btn/sec/fila/card:
es el marcador "plano" que main.js resuelve llamando WAM.abrirSheet(i). */
function listHTML(m, i) {
var cabecera = vacio(m.header) ? "" : '<div class="tpl-header-txt">' + esc(m.header) + "</div>";
var etiqueta = vacio(m.boton) ? "Ver opciones" : String(m.boton);
return '<div class="wa-stack">' +
'<div class="bubble">' + cabecera +
'<div class="wa-texto">' + formatear(m.body) + "</div>" +
pieBurbuja(m, m.footer) + "</div>" +
'<div class="qr-btns"><span class="qr-btn" role="button" tabindex="0" data-flujo="' + i + '">' + esc(etiqueta) + "</span></div>" +
"</div>";
}
function dominioDe(url) {
var u = String(url == null ? "" : url);
if (!u) return "";
try { return new URL(u).hostname || u; } catch (e) { return u.replace(/^[a-z]+:\/\//i, "").split("/")[0]; }
}
/* cta_url: headerMedia opcional + body + footer + botón con dominio.
Sin url el botón queda inerte (research-api §6.3: nada que abrir) y la
fila de dominio no se pinta (dominioDe("") = ""). */
function ctaHTML(m, i) {
var html = '<div class="wa-stack">';
if (!vacio(m.headerMedia)) {
html += '<div class="bubble wa-bubble-media"><img class="wa-media" src="' + esc(m.headerMedia) + '" alt="" draggable="false"></div>';
}
html += '<div class="bubble"><div class="wa-texto">' + formatear(m.body) + "</div>" + pieBurbuja(m, m.footer) + "</div>";
var etiqueta = vacio(m.textoBoton) ? "Abrir enlace" : String(m.textoBoton);
var dominio = dominioDe(m.url);
var boton;
if (vacio(m.url)) {
boton = '<span class="cta-btn cta-btn-inactiva" aria-disabled="true">' + svgEnlace() + '<span class="btn-label">' + esc(etiqueta) + "</span></span>";
} else {
boton = '<a class="cta-btn" href="' + esc(m.url) + '" target="_blank" rel="noopener noreferrer">' + svgEnlace() + '<span class="btn-label">' + esc(etiqueta) + "</span></a>";
}
html += '<div class="cta-card">' + boton +
(dominio ? '<div class="cta-domain">' + esc(dominio) + "</div>" : "") +
"</div></div>";
return html;
}
/* carousel: burbuja body + tarjetas con scroll-snap + puntos */
function carouselHTML(m, i) {
var cards = Array.isArray(m.cards) ? m.cards : [];
var html = '<div class="wa-stack wa-stack-full">';
if (!vacio(m.body)) {
html += '<div class="bubble"><div class="wa-texto">' + formatear(m.body) + "</div>" + pieBurbuja(m, "") + "</div>";
}
html += '<div class="carousel">';
for (var j = 0; j < cards.length; j++) {
var c = cards[j] || {};
var media = vacio(c.media) ? placeholder(vacio(c.texto) ? "card" : c.texto) : c.media;
var boton;
if (!vacio(c.url)) {
boton = '<a class="qr-btn" href="' + esc(c.url) + '" target="_blank" rel="noopener noreferrer">' + esc(vacio(c.textoBoton) ? "Ver más" : c.textoBoton) + "</a>";
} else {
boton = '<span class="qr-btn" role="button" tabindex="0" data-flujo="' + i + '" data-card="' + j + '">' + esc(vacio(c.textoBoton) ? "Ver más" : c.textoBoton) + "</span>";
}
html += '<div class="card">' +
'<img class="card-media" src="' + esc(media) + '" alt="" draggable="false">' +
(vacio(c.texto) ? "" : '<div class="card-body">' + formatear(c.texto) + "</div>") +
'<div class="card-btns">' + boton + "</div></div>";
}
html += "</div>";
if (cards.length > 1) {
html += '<div class="car-puntos">';
for (j = 0; j < cards.length; j++) html += '<span class="car-punto' + (j === 0 ? " activo" : "") + '"></span>';
html += "</div>";
}
return html + "</div>";
}
/* Puntos del carousel (listener de presentación, no de flujo) */
function activarPuntos(li) {
var car = li.querySelector(".carousel");
if (!car) return;
var puntos = li.querySelectorAll(".car-punto");
if (!puntos.length) return;
car.addEventListener("scroll", function () {
var hijo = car.firstElementChild;
var paso = hijo ? hijo.offsetWidth + 8 : 0;
if (!paso) return;
var idx = Math.round(car.scrollLeft / paso);
if (idx < 0) idx = 0;
if (idx > puntos.length - 1) idx = puntos.length - 1;
for (var k = 0; k < puntos.length; k++) {
puntos[k].className = "car-punto" + (k === idx ? " activo" : "");
}
}, { passive: true });
}
/* ------------------------------ API pública ------------------------------ */
function textoSubtitulo() {
if (ESCRIBIENDO) return "escribiendo…";
var c = estado().cuenta || {};
return vacio(c.subtitulo) ? "en línea" : String(c.subtitulo);
}
/* Avatar/nombre/subtítulo/verificado en #chat-header (chevron + iconos) */
WAM.renderHeader = function () {
var header = byId("chat-header");
if (!header) return;
var c = estado().cuenta || {};
var nombre = vacio(c.nombre) ? "Chat" : String(c.nombre);
var avatar = vacio(c.avatar) ? placeholder(nombre) : c.avatar;
header.innerHTML =
'<span class="wa-back">' + svgChevronAtras() + "</span>" +
'<img id="chat-avatar" class="wa-avatar" src="' + esc(avatar) + '" alt="" draggable="false">' +
'<div class="wa-header-tit">' +
'<div class="header-title"><span id="chat-nombre">' + esc(nombre) + "</span>" +
'<span id="chat-verificado" class="wa-verificado"' + (c.verificado ? "" : " hidden") + ">" + svgVerificado() + "</span></div>" +
'<div class="header-sub' + (ESCRIBIENDO ? " wa-sub-verde" : "") + '" id="chat-subtitulo">' + esc(textoSubtitulo()) + "</div>" +
"</div>" +
'<span class="wa-hicon">' + svgVideoLlamada() + "</span>" +
'<span class="wa-hicon">' + svgLlamada() + "</span>";
};
/* li.wa-row con todo el contenido del tipo + data-* para la interactividad */
WAM.renderMensaje = function (msg, i) {
var m = msg || {};
var idx = typeof i === "number" ? i : 0;
var dir = m.dir === "out" ? "out" : "in";
var mensajes = estado().mensajes || [];
var prev = idx > 0 ? mensajes[idx - 1] : null;
var grupo = !prev || prev.dir !== m.dir ? " grupo" : "";
var cuerpo;
switch (m.tipo) {
case "image": cuerpo = burbujaMedia(m, "imagen"); break;
case "video": cuerpo = burbujaMedia(m, "video"); break;
case "audio": cuerpo = burbujaAudio(m); break;
case "document": cuerpo = burbujaDocumento(m); break;
case "sticker": cuerpo = stickerHTML(m); break;
case "location": cuerpo = burbujaUbicacion(m); break;
case "contact": cuerpo = burbujaContacto(m); break;
case "template": cuerpo = tplHTML(m, idx); break;
case "buttons": cuerpo = buttonsHTML(m, idx); break;
case "list": cuerpo = listHTML(m, idx); break;
case "cta_url": cuerpo = ctaHTML(m, idx); break;
case "carousel": cuerpo = carouselHTML(m, idx); break;
default: cuerpo = burbujaTexto(m);
}
var li = document.createElement("li");
li.className = "wa-row " + dir + grupo;
if (!vacio(m.id)) li.setAttribute("data-id", String(m.id));
li.innerHTML = cuerpo;
if (m.tipo === "carousel") activarPuntos(li);
activarPlegado(li);
return li;
};
/* renderHeader() + repinta TODOS los mensajes en #chat-cuerpo */
WAM.render = function () {
WAM.renderHeader();
var cuerpo = byId("chat-cuerpo");
if (!cuerpo) return;
var ind = byId("chat-escribiendo");
var indEnCuerpo = !!(ind && ind.parentNode === cuerpo);
var mensajes = estado().mensajes || [];
var frag = document.createDocumentFragment();
for (var i = 0; i < mensajes.length; i++) frag.appendChild(WAM.renderMensaje(mensajes[i], i));
cuerpo.innerHTML = "";
if (indEnCuerpo && ind) frag.appendChild(ind); /* conserva el indicador */
cuerpo.appendChild(frag);
WAM.scrollAbajo();
};
/* append + scrollAbajo (lo usa la interactividad, sin re-render total) */
WAM.anadirMensajeDOM = function (msg) {
var cuerpo = byId("chat-cuerpo");
if (!cuerpo) return;
var mensajes = estado().mensajes || [];
var i = mensajes.indexOf ? mensajes.indexOf(msg) : -1;
if (i < 0) i = mensajes.length;
var nodo = WAM.renderMensaje(msg, i);
var ind = byId("chat-escribiendo");
if (ind && ind.parentNode === cuerpo && !ind.hidden) cuerpo.insertBefore(nodo, ind);
else cuerpo.appendChild(nodo);
WAM.scrollAbajo();
};
/* body[data-tema] + clase oscuro en #pantalla */
WAM.setTema = function (tema) {
var oscuro = tema === "oscuro";
if (document.body) document.body.setAttribute("data-tema", oscuro ? "oscuro" : "claro");
var pantalla = byId("pantalla");
if (pantalla) {
if (oscuro) pantalla.classList.add("oscuro");
else pantalla.classList.remove("oscuro");
}
};
/* Muestra/oculta #chat-escribiendo; subtítulo "escribiendo…" en verde */
WAM.setEscribiendo = function (visible) {
ESCRIBIENDO = !!visible;
var cuerpo = byId("chat-cuerpo");
var ind = byId("chat-escribiendo");
if (!ind && cuerpo) {
ind = document.createElement("li");
ind.id = "chat-escribiendo";
ind.className = "wa-row in";
ind.innerHTML = '<div class="bubble wa-bubble-escribiendo">' +
'<span class="wam-dot"></span><span class="wam-dot"></span><span class="wam-dot"></span></div>';
cuerpo.appendChild(ind);
}
if (ind) {
/* Respeta la colocación de A (index.html:147, fuera de #chat-cuerpo):
rellena contenido, alterna hidden y hereda la fila visual. */
if (String(ind.className).indexOf("wa-row") === -1) {
ind.className = String(ind.className ? ind.className + " " : "") + "wa-row in";
}
if (String(ind.innerHTML).indexOf("wa-bubble-escribiendo") === -1) {
ind.innerHTML = '<div class="bubble wa-bubble-escribiendo">' +
'<span class="wam-dot"></span><span class="wam-dot"></span><span class="wam-dot"></span></div>';
}
ind.hidden = !ESCRIBIENDO;
if (ESCRIBIENDO && ind.parentNode === cuerpo) cuerpo.appendChild(ind); /* al final */
}
var sub = byId("chat-subtitulo");
if (sub) {
sub.textContent = textoSubtitulo();
sub.className = "header-sub" + (ESCRIBIENDO ? " wa-sub-verde" : "");
}
if (ESCRIBIENDO) WAM.scrollAbajo();
};
WAM.scrollAbajo = function () {
var cuerpo = byId("chat-cuerpo");
if (cuerpo) cuerpo.scrollTop = cuerpo.scrollHeight;
};
/* Sheet de lista (B crea/destruye) en #pantalla */
WAM.abrirSheet = function (i) {
var pantalla = byId("pantalla");
if (!pantalla) return;
WAM.cerrarSheet();
var mensajes = estado().mensajes || [];
var m = (typeof i === "number" ? mensajes[i] : null) || {};
var secciones = Array.isArray(m.secciones) ? m.secciones : [];
var cuenta = estado().cuenta || {};
var titulo = vacio(m.header) ? (vacio(cuenta.nombre) ? "Opciones" : String(cuenta.nombre)) : String(m.header);
var html = "", total = 0, s, f;
for (s = 0; s < secciones.length && total < 10; s++) {
var sec = secciones[s] || {};
var filas = Array.isArray(sec.filas) ? sec.filas : [];
if (secciones.length > 1 && !vacio(sec.titulo)) {
html += '<div class="sheet-seccion">' + esc(sec.titulo) + "</div>";
}
html += '<div class="sheet-grupo">';
for (f = 0; f < filas.length && total < 10; f++) {
var fila = filas[f] || {};
total++;
html += '<span class="sheet-fila" role="button" tabindex="0" data-flujo="' + i + '" data-sec="' + s + '" data-fila="' + f + '">' +
'<span class="sheet-fila-txt"><span class="sheet-fila-titulo">' + esc(vacio(fila.titulo) ? "Opción" : fila.titulo) + "</span>" +
(vacio(fila.descripcion) ? "" : '<span class="sheet-fila-desc">' + esc(fila.descripcion) + "</span>") +
"</span>" + svgChevronDer(13) + "</span>";
}
html += "</div>";
}
var hoja = document.createElement("div");
hoja.id = "sheet";
hoja.className = "sheet";
hoja.innerHTML = '<div class="sheet-fondo" id="sheet-fondo"></div>' +
'<div class="sheet-panel"><div class="sheet-cabecera"><span class="sheet-titulo" id="sheet-titulo">' + esc(titulo) + "</span></div>" +
'<div class="sheet-opciones" id="sheet-opciones">' + html +
(total ? "" : '<div class="sheet-vacio">Sin opciones</div>') + "</div></div>";
pantalla.appendChild(hoja);
var fondo = hoja.querySelector("#sheet-fondo");
if (fondo) fondo.addEventListener("click", function () { WAM.cerrarSheet(); });
};
WAM.cerrarSheet = function () {
var hoja = byId("sheet");
if (hoja && hoja.parentNode) hoja.parentNode.removeChild(hoja);
};
})();
+544
View File
@@ -0,0 +1,544 @@
/* WAM · store.js — dueño D (núcleo).
Estado único, persistencia en localStorage, normalización, generación de IDs
y demo por defecto "Café Aurora" (los 13 tipos, respuestas encadenadas).
No toca el DOM al cargar: solo define WAM.*. */
window.WAM = window.WAM || {};
(function () {
"use strict";
WAM.CLAVE = "wam-mockup-v1";
WAM.estado = null;
var TIPOS = [
"text", "image", "video", "audio", "document", "sticker", "location",
"contact", "template", "buttons", "list", "cta_url", "carousel"
];
var HEADER_TIPOS = ["texto", "imagen", "video", "documento"];
/* ————————————————— utilidades ————————————————— */
function dos(n) { return (n < 10 ? "0" : "") + n; }
WAM.horaAhora = function () {
var d = new Date();
return dos(d.getHours()) + ":" + dos(d.getMinutes());
};
function cad(v, porDefecto) {
if (v === undefined || v === null) return porDefecto || "";
return String(v);
}
function escaparXml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
if (c === "&") return "&amp;";
if (c === "<") return "&lt;";
if (c === ">") return "&gt;";
if (c === '"') return "&quot;";
return "&apos;";
});
}
function aDataUri(svg) {
return "data:image/svg+xml," + encodeURIComponent(svg);
}
/* Placeholder genérico (taza) para src/avatar/headerMedia vacíos. */
WAM.svgPlaceholder = function (texto) {
var t = escaparXml(texto || "");
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="#efe6dc"/>' +
'<path d="M204 96 l8 -16 m16 16 l8 -16 m16 16 l8 -16" fill="none" stroke="#c8a27a" stroke-width="5" stroke-linecap="round"/>' +
'<rect x="184" y="118" width="70" height="52" rx="10" fill="none" stroke="#a9825f" stroke-width="7"/>' +
'<path d="M254 130 h13 a13 13 0 0 1 0 26 h-13" fill="none" stroke="#a9825f" stroke-width="7"/>' +
(t ? '<text x="240" y="242" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="24" fill="#7f5539">' + t + '</text>' : '') +
'</svg>';
return aDataUri(svg);
};
/* Imagen SVG en línea y pequeña para las medias de la demo (sin red). */
function svgDemo(texto, emoji, fondo, acento) {
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="132" r="76" fill="' + acento + '"/>' +
'<text x="240" y="156" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="68">' + escaparXml(emoji || "☕") + '</text>' +
'<text x="240" y="272" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="25" fill="#5b4232">' + escaparXml(texto || "") + '</text>' +
'</svg>';
return aDataUri(svg);
}
function svgAvatar() {
return aDataUri(
'<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="#6f4e37"/>' +
'<text x="48" y="63" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="42">☕</text>' +
'</svg>'
);
}
function svgSticker() {
return aDataUri(
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">' +
'<circle cx="160" cy="160" r="150" fill="#fff8ef" stroke="#e3cfae" stroke-width="10"/>' +
'<text x="160" y="198" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="130">☕</text>' +
'</svg>'
);
}
/* ————————————————— IDs secuenciales ————————————————— */
/* Recorre el estado (mensajes, respuestas anidadas, botones, filas, cards)
y junta todos los id existentes para no colisionar nunca. */
function recolectarIds(o, conjunto) {
if (!o || typeof o !== "object") return;
if (Array.isArray(o)) {
for (var i = 0; i < o.length; i++) recolectarIds(o[i], conjunto);
return;
}
if (typeof o.id === "string" && o.id) conjunto.add(o.id);
var claves = ["mensajes", "respuestas", "botones", "secciones", "filas", "cards"];
for (var c = 0; c < claves.length; c++) {
var v = o[claves[c]];
if (v && typeof v === "object") recolectarIds(v, conjunto);
}
}
function crearAlocador(usados) {
var conjunto = usados || new Set();
return function tomar() {
var n = 1;
while (conjunto.has("m" + n)) n++;
var id = "m" + n;
conjunto.add(id);
return id;
};
}
WAM.generarId = function () {
var conjunto = new Set();
recolectarIds(WAM.estado, conjunto);
return crearAlocador(conjunto)();
};
/* ————————————————— mensajes ————————————————— */
var CAMPOS = {
text: function () { return { texto: "" }; },
image: function () { return { src: "", caption: "" }; },
video: function () { return { src: "", caption: "" }; },
audio: function () { return { src: "", voz: true, duracion: "0:00" }; },
document: function () { return { src: "", caption: "", filename: "documento.pdf" }; },
sticker: function () { return { src: "" }; },
location: function () { return { latitud: "", longitud: "", nombre: "", direccion: "" }; },
contact: function () { return { nombre: "", telefono: "", email: "", empresa: "" }; },
template: function () { return { headerTipo: "", headerTexto: "", headerMedia: "", body: "", footer: "", botones: [] }; },
buttons: function () { return { headerTipo: "", headerTexto: "", headerMedia: "", body: "", footer: "", botones: [] }; },
list: function () { return { header: "", body: "", footer: "", boton: "", secciones: [] }; },
cta_url: function () { return { headerMedia: "", body: "", footer: "", textoBoton: "", url: "" }; },
carousel: function () { return { body: "", cards: [] }; }
};
WAM.nuevoMensaje = function (tipo) {
var valido = Object.prototype.hasOwnProperty.call(CAMPOS, tipo);
var fabrica = valido ? CAMPOS[tipo] : CAMPOS.text;
var msg = {
id: WAM.generarId(),
dir: "in",
tipo: valido ? tipo : "text",
hora: WAM.horaAhora(),
estado: "sent"
};
var campos = fabrica();
for (var k in campos) {
if (Object.prototype.hasOwnProperty.call(campos, k)) msg[k] = campos[k];
}
return msg;
};
/* ————————————————— 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 : {};
var conjunto = new Set();
recolectarIds(src, conjunto);
recolectarIds(WAM.estado, conjunto); /* evita colisiones con lo vivo */
var tomar = crearAlocador(conjunto);
function normalizarMensaje(msj, forzarEntrada) {
var m = (msj && typeof msj === "object") ? msj : {};
var tipo = TIPOS.indexOf(m.tipo) !== -1 ? m.tipo : "text";
var msg = {
id: (typeof m.id === "string" && m.id) ? m.id : tomar(),
dir: (!forzarEntrada && m.dir === "out") ? "out" : "in",
tipo: tipo,
hora: (typeof m.hora === "string" && m.hora) ? m.hora : 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); });
}
function botones(valor) {
var lista = Array.isArray(valor) ? valor : [];
return lista.map(function (b) {
var boton = (b && typeof b === "object") ? 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;
return out;
});
}
switch (tipo) {
case "text":
msg.texto = cad(m.texto);
break;
case "image":
case "video":
msg.src = cad(m.src);
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");
break;
case "document":
msg.src = cad(m.src);
msg.caption = cad(m.caption);
msg.filename = cad(m.filename, "documento.pdf");
break;
case "sticker":
msg.src = cad(m.src);
break;
case "location":
msg.latitud = cad(m.latitud);
msg.longitud = cad(m.longitud);
msg.nombre = cad(m.nombre);
msg.direccion = cad(m.direccion);
break;
case "contact":
msg.nombre = cad(m.nombre);
msg.telefono = cad(m.telefono);
msg.email = cad(m.email);
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.body = cad(m.body);
msg.footer = cad(m.footer);
msg.botones = botones(m.botones);
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) {
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 : {};
return {
titulo: cad(fila.titulo),
descripcion: cad(fila.descripcion),
respuestas: respuestas(fila.respuestas)
};
})
};
});
break;
case "cta_url":
msg.headerMedia = cad(m.headerMedia);
msg.body = cad(m.body);
msg.footer = cad(m.footer);
msg.textoBoton = cad(m.textoBoton);
msg.url = cad(m.url);
break;
case "carousel":
msg.body = cad(m.body);
msg.cards = (Array.isArray(m.cards) ? m.cards : []).map(function (c) {
var card = (c && typeof c === "object") ? c : {};
return {
media: cad(card.media),
texto: cad(card.texto),
textoBoton: cad(card.textoBoton),
url: cad(card.url),
respuestas: respuestas(card.respuestas)
};
});
break;
}
return msg;
}
var cuentaSrc = (src.cuenta && typeof src.cuenta === "object") ? src.cuenta : {};
return {
version: 1,
tema: src.tema === "oscuro" ? "oscuro" : "claro",
cuenta: {
nombre: cad(cuentaSrc.nombre, "Café Aurora"),
avatar: cad(cuentaSrc.avatar),
subtitulo: cad(cuentaSrc.subtitulo, "en línea"),
verificado: cuentaSrc.verificado === true
},
mensajes: (Array.isArray(src.mensajes) ? src.mensajes : []).map(function (x) {
return normalizarMensaje(x, false);
})
};
};
/* ————————————————— persistencia ————————————————— */
WAM.cargar = function () {
var bruto = null;
try { bruto = window.localStorage.getItem(WAM.CLAVE); } catch (e) { bruto = null; }
var dato = null;
if (bruto) {
try { dato = JSON.parse(bruto); } catch (e) { dato = null; }
}
if (!dato) dato = WAM.datosDemo();
WAM.estado = WAM.normalizar(dato);
return WAM.estado;
};
WAM.guardar = function () {
try {
window.localStorage.setItem(WAM.CLAVE, JSON.stringify(WAM.estado));
} catch (e) { /* sin localStorage disponible (cuota/file://): se ignora */ }
};
WAM.resetear = function () {
try { window.localStorage.removeItem(WAM.CLAVE); } catch (e) { /* idem */ }
WAM.estado = WAM.normalizar(WAM.datosDemo());
};
/* ————————————————— demo por defecto: "Café Aurora" ————————————————— */
function men(dir, tipo, hora, campos) {
var msg = { id: "", dir: dir, tipo: tipo, hora: hora, estado: "sent" };
var k;
for (k in campos) {
if (Object.prototype.hasOwnProperty.call(campos, k)) msg[k] = campos[k];
}
return msg;
}
WAM.datosDemo = function () {
return {
version: 1,
tema: "claro",
cuenta: {
nombre: "Café Aurora",
avatar: svgAvatar(),
subtitulo: "en línea",
verificado: true
},
mensajes: [
/* 1 · text in (saludo) */
men("in", "text", "10:24", {
texto: "¡Hola! 👋 Bienvenido a Café Aurora. Soy Luna, tu barista de guardia. ¿Te cuento las novedades?"
}),
/* 2 · text out (cliente) */
men("out", "text", "10:24", {
estado: "read",
texto: "¡Hola, Luna! Sí, cuéntame ☕"
}),
/* 3 · image */
men("in", "image", "10:25", {
src: svgDemo("Nuevo horario", "⏰", "#f3e7d8", "#c8a27a"),
caption: "Nuevo horario desde el 1 de octubre ☕"
}),
/* 4 · video (sin src → placeholder SVG) */
men("in", "video", "10:25", {
src: "",
caption: "Así tostamos nuestros granos cada mañana"
}),
/* 5 · location */
men("in", "location", "10:26", {
latitud: "40.4168",
longitud: "-3.7038",
nombre: "Café Aurora",
direccion: "Calle Mayor 12, 28013 Madrid"
}),
/* 6 · contact */
men("in", "contact", "10:26", {
nombre: "Luna Mendoza",
telefono: "+34 611 222 333",
email: "[email protected]",
empresa: "Café Aurora"
}),
/* 7 · document */
men("in", "document", "10:27", {
src: "",
caption: "Nuestra carta con granos y precios",
filename: "carta-cafe-aurora.pdf"
}),
/* 8 · audio (nota de voz) */
men("in", "audio", "10:27", {
src: "",
voz: true,
duracion: "0:12"
}),
/* 9 · sticker */
men("in", "sticker", "10:28", {
src: svgSticker()
}),
/* 10 · template (header imagen + confirmación de reserva) */
men("in", "template", "10:29", {
headerTipo: "imagen",
headerTexto: "",
headerMedia: svgDemo("Tu mesa te espera", "☕", "#e9d8c3", "#a9825f"),
body: "Hola 👋 Tu mesa para 2 personas está reservada este viernes a las 19:30. ¿Confirmas la reserva?",
footer: "Café Aurora · Reservas",
botones: [
{
texto: "Confirmar",
respuestas: [
men("in", "text", "10:29", {
texto: "¡Genial! Reserva confirmada para el viernes a las 19:30 ✨ Te esperamos."
})
]
},
{
texto: "Cambiar fecha",
respuestas: [
men("in", "text", "10:29", {
texto: "Sin problema. ¿Para qué día y hora te la cambio?"
})
]
}
]
}),
/* 11 · buttons (quick replies, máx 3 · títulos ≤ 20) */
men("in", "buttons", "10:30", {
body: "¿Qué te muestro ahora?",
footer: "Elige una opción",
botones: [
{
texto: "Horarios",
respuestas: [
men("in", "text", "10:30", {
texto: "Abrimos de lunes a viernes de 7:30 a 20:00 y sábados de 9:00 a 14:00."
})
]
},
{
texto: "Promociones",
respuestas: [
men("in", "text", "10:30", { texto: "🔥 Martes de cold brew: 2×1 todo el día." }),
men("in", "text", "10:30", { texto: "🥐 Viernes: croissant gratis con tu café." })
]
},
{
texto: "Hablar con el equipo",
respuestas: [
men("in", "text", "10:30", {
texto: "Te paso con Marta del equipo humano, un momento…"
})
]
}
]
}),
/* 12 · list (filas ≤ 10 · títulos fila ≤ 24 · descripción ≤ 72) */
men("in", "list", "10:31", {
header: "Menú de la casa",
body: "¿Qué te apetece hoy?",
footer: "Catálogo 2026",
boton: "Ver opciones",
secciones: [
{
titulo: "Bebidas",
filas: [
{
titulo: "Espresso double",
descripcion: "Intenso, 100 % arábica",
respuestas: [men("in", "text", "10:31", { texto: "Un espresso double rumbo a tu mesa ☕" })]
},
{
titulo: "Latte de avena",
descripcion: "Suave, con leche de avena",
respuestas: [men("in", "text", "10:31", { texto: "¡Buen gusto! Latte de avena anotado 🌾" })]
},
{
titulo: "Cold brew 12 h",
descripcion: "Macerado en frío durante 12 horas",
respuestas: [men("in", "text", "10:31", { texto: "Cold brew bien frío, ¡perfecto para hoy! 🧊" })]
}
]
},
{
titulo: "Postres",
filas: [
{
titulo: "Tarta de queso",
descripcion: "La de siempre, casera",
respuestas: [men("in", "text", "10:31", { texto: "Una porción de tarta de queso, ¡gran elección! 🍰" })]
},
{
titulo: "Croissant almendra",
descripcion: "Horneado cada mañana",
respuestas: [men("in", "text", "10:31", { texto: "Croissant de almendra recién hecho 🥐" })]
}
]
}
]
}),
/* 13 · cta_url (botón con URL → <a target="_blank">, sin flujo) */
men("in", "cta_url", "10:32", {
headerMedia: svgDemo("Tienda online", "🛍️", "#efe0cd", "#b08968"),
body: "Ya puedes pedir nuestros granos tostados a domicilio.",
footer: "Envío en 24-48 h",
textoBoton: "Abrir tienda",
url: "https://cafeaurora.example/tienda"
}),
/* 14 · carousel (3 cards con respuestas encadenadas) */
men("in", "carousel", "10:33", {
body: "Los favoritos de la casa ☕",
cards: [
{
media: svgDemo("Espresso Aurora", "☕", "#f0e3d0", "#a9825f"),
texto: "Espresso Aurora · tueste medio con notas de cacao. Bolsa 250 g.",
textoBoton: "¡Lo quiero!",
url: "",
respuestas: [men("in", "text", "10:33", { texto: "¡Excelente! El Espresso Aurora sale hoy mismo hacia ti 📦" })]
},
{
media: svgDemo("Cold brew botella", "🧋", "#e4ecf0", "#7f9cab"),
texto: "Cold brew en botella de 1 L, listo para servir muy frío.",
textoBoton: "Pedir",
url: "",
respuestas: [men("in", "text", "10:33", { texto: "Cold brew de 1 L añadido a tu pedido 🧊" })]
},
{
media: svgDemo("Tarta de queso", "🍰", "#f5ebe2", "#c9a227"),
texto: "Tarta de queso casera, entera o por porciones.",
textoBoton: "Probar",
url: "",
respuestas: [men("in", "text", "10:33", { texto: "La tarta de queso sale del horno a las 12:00 🍰" })]
}
]
})
]
};
};
})();