Estado inicial del mockup
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.zcode/
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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`.
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
@@ -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 "&";
|
||||
if (c === "<") return "<";
|
||||
if (c === ">") return ">";
|
||||
if (c === '"') return """;
|
||||
return "'";
|
||||
});
|
||||
}
|
||||
|
||||
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 🍰" })]
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
};
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user