# 06 · UI embebible

En vez de construir un chat, un gestor de plantillas y una pantalla de ajustes en cada aplicación, **se incrustan las del gateway**. Se mantienen en un solo sitio y todas vuestras apps tienen la misma.

| Pantalla (`screen`) | Para qué | Rol mínimo | Función de licencia |
| --- | --- | --- | --- |
| `chat` | Bandeja de conversaciones, responder, asignar, notas, enviar plantillas y archivos | `agent` | `chat` |
| `templates` | Ver, crear, editar y enviar a revisión plantillas; biblioteca | `agent` ver · `admin` editar | `templates` |
| `settings` | Cuenta y números, agentes, claves de API, eventos, ajustes, licencia | `admin` | — |
| `costs` | Costes y volumen por categoría, país y día | `admin` | `costs` |
| `signup` | Alta de un número (la abre `settings`; ventana propia) | `admin` | — |

Además la licencia debe incluir la función **`embed`**. Si no está incluida, `POST /v1/embed/tokens` devuelve `403 feature_not_licensed` y vuestra app puede ocultar la entrada de menú comprobando antes `GET /v1/license`.

## Cómo funciona (sin cookies de terceros)

```
 navegador                    vuestro backend                   wa-gateway
    │  1. carga la página con <wa-gateway-embed>
    │  2. POST /api/whatsapp/embed-token ───▶
    │                           3. POST /v1/embed/tokens (API key, usuario y rol)
    │                                                   ──────────────▶
    │                           ◀─────────────  token de 5 min, de un solo uso
    │  ◀── { url: ".../embed/chat#token=…" }
    │  4. crea el <iframe src=url>   ─────────────────────────────────▶
    │                                       5. el iframe canjea el token → access token de 15 min (en memoria)
    │                                       6. el iframe llama a la API con ese access token
    │  ◀────── 7. 60 s antes de caducar: wa:token_expiring ──── renovar (pasos 2-3) → wa:token
```

- **La API key nunca llega al navegador.** Solo vuestro backend la usa.
- El token viaja en el **fragmento** de la URL (`#token=…`): no se envía al servidor ni en `Referer`. El iframe lo borra de la barra al leerlo.
- El token es de **un solo uso** y dura como máximo 5 minutos; el access token, 15, y solo vive en la memoria del iframe (no hay cookies ni `localStorage`).

## Integración en 3 pasos

### 1. Registrar los orígenes de vuestra app (una vez, administración)

```bash
curl -X PATCH $ADMIN_URL/admin/v1/apps/$APP_ID -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"allowed_origins": ["https://erp.cliente.com", "https://staging.erp.cliente.com"]}'
```

Solo esos orígenes pueden incrustar las pantallas (`Content-Security-Policy: frame-ancestors`) y solo con ellos se intercambian mensajes. Incluid todos los entornos. Es el fallo más común: si el iframe aparece en blanco, mirad la consola del navegador.

### 2. Un endpoint en vuestro backend (lo único que escribís)

Recibe `{ screen, params }`, comprueba **vuestra** sesión, pide el token y devuelve `{ url }`:

```js
// ejemplos/node/endpoint-token-embebido.mjs (ejecutado en la verificación)
const r = await fetch(`${WA_BASE_URL}/v1/embed/tokens`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${WA_API_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ screen, user: { external_user_id: String(u.id), name: u.nombre, email: u.email, role: rol(u) }, params }),
});
const { url } = await r.json();
res.end(JSON.stringify({ url }));
```

| Campo de `user` | Descripción |
| --- | --- |
| `external_user_id` | El id del usuario en **vuestra** app. Con él se crea el agente la primera vez |
| `name`, `email` | Se muestran en la bandeja y firman las respuestas |
| `role` | `agent`, `supervisor` o `admin`. **El rol que enviáis es el que se aplica**: decidid el mapeo con cuidado |

Otros campos: `params` (parámetros de la pantalla, abajo), `locale` (`es`/`en`), `ttl_seconds` (30–300). Con una clave multi-tenant añadid `X-Tenant-Ref` (el id del cliente en vuestra app).

- Un usuario nuevo **cuenta para `max_agents`** de la licencia (`403 license_limit_reached` al superarlo); uno ya existente entra siempre. Se actualizan nombre, correo y rol en cada token.
- Un agente desactivado en el gateway (`PATCH /v1/agents/{id}` con `active:false`) no puede entrar: `403`.
- No cachéis la respuesta del endpoint: cada carga del iframe necesita un token nuevo.

### 3. El componente en la página

```html
<script type="module" src="https://wa.vuestrodominio.com/embed/loader.js"></script>

<wa-gateway-embed
    screen="chat"
    token-endpoint="/api/whatsapp/embed-token"
    contact-ref="cli-9917"
    theme="auto"
    style="display:block; height: 100%"></wa-gateway-embed>
```

Dadle altura (por CSS o con el atributo `autoheight`, que ajusta al contenido). Mínimo 320 px de ancho: por debajo de 720 px pasa a una sola columna con botón «volver», así que sirve en un panel lateral o en móvil.

#### Atributos

| Atributo | Valores | Efecto |
| --- | --- | --- |
| `screen` | `chat`, `templates`, `settings`, `costs` | Pantalla a mostrar. Cambiarlo pide un token nuevo y recarga |
| `token-endpoint` | URL de **vuestro** backend | Se llama con `POST {screen, params}` y `credentials: include`. Su URL de respuesta debe ser del origen del gateway |
| `contact-ref` | vuestro `external_ref` del contacto | `chat`: abre la conversación de ese cliente |
| `conversation-id` | `cnv_…` | `chat`: abre esa conversación |
| `tab` | `account`, `agents`, `keys`, `events`, `prefs`, `license` | `settings`: pestaña inicial |
| `theme` | `auto`, `light`, `dark` | Tema. Se puede cambiar en caliente |
| `accent` | `#RRGGBB` | Color de acento |
| `locale` | `es`, `en` | Idioma |
| `autoheight` | (presencia) | Ajusta la altura del iframe al contenido |

#### Eventos DOM (en el propio componente)

| Evento | `detail` | Cuándo |
| --- | --- | --- |
| `wa-ready` | `{ screen }` | La pantalla cargó |
| `wa-unread` | `{ total }` | Cambia el nº de no leídos (para un globo en vuestro menú) |
| `wa-open-in-app` | `{ contact_ref, conversation_id }` | El agente pulsó «Abrir en la aplicación»: abrid la ficha del cliente (solo aparece si el contacto tiene `external_ref`) |
| `wa-signup-completed` | `{ number_id, status }` | Se conectó un número |
| `wa-error` | `{ code, message }` | Sin sesión, módulo no contratado, token no obtenido… |

```js
const el = document.querySelector('wa-gateway-embed');
el.addEventListener('wa-unread', (e) => badge.textContent = e.detail.total || '');
el.addEventListener('wa-open-in-app', (e) => router.push(`/clientes/${e.detail.contact_ref}`));
el.addEventListener('wa-error', (e) => console.warn(e.detail));
```

#### Métodos

```js
el.navigate({ screen: 'chat', params: { contact_ref: 'cli-0042' } }); // abre esa conversación sin recargar el iframe
el.navigate({ screen: 'settings', params: { tab: 'keys' } });         // otra pantalla: token nuevo y recarga
el.setTheme('dark', '#0b7a5a');
```

## Sin el componente: integración manual

Si no podéis usar Custom Elements, crear el `<iframe>` con la `url` del paso 2 y atender el contrato `postMessage`.

Sobre de todos los mensajes: `{ "source": "wa-gateway", "version": 1, "type": "wa:…", "payload": { … } }`.

**Siempre** comprobad `event.origin === 'https://wa.vuestrodominio.com'` y `event.source === iframe.contentWindow`, y enviad con `targetOrigin` explícito (nunca `*`).

| Dirección | `type` | `payload` | Para qué |
| --- | --- | --- | --- |
| iframe → página | `wa:ready` | `{ screen }` | Pantalla lista |
| iframe → página | `wa:resize` | `{ height }` | Ajustar la altura |
| iframe → página | `wa:token_expiring` | `{ expires_in }` | Pedir un token nuevo (repetid los pasos 2–3) |
| iframe → página | `wa:request_token` | `{ screen }` | Pide un token de otra pantalla (la usa `settings` para abrir `signup`) |
| iframe → página | `wa:unread_count` | `{ total }` | Globo de no leídos |
| iframe → página | `wa:open_in_app` | `{ contact_ref, conversation_id }` | Abrir la ficha en vuestra app |
| iframe → página | `wa:signup_completed` | `{ number_id, status }` | Número conectado |
| iframe → página | `wa:error` | `{ code, message }` | Fallo o módulo no contratado |
| página → iframe | `wa:token` | `{ token, screen }` | Entregar el token nuevo (solo el valor tras `#token=`) |
| página → iframe | `wa:navigate` | `{ params }` | Navegar dentro de la pantalla actual |
| página → iframe | `wa:theme` | `{ mode, accent }` | Cambiar el tema |

Referencia de implementación: [`apps/ui/public/loader.js`](../apps/ui/public/loader.js) (≈130 líneas).

## Qué ve cada rol

| Rol | Conversaciones | Plantillas | Configuración |
| --- | --- | --- | --- |
| `agent` | Las **suyas y las sin asignar** (las asignadas a otros devuelven `404`) | Ver | — |
| `supervisor` | Todas; puede reasignar | Ver | — (puede ver agentes y uso por API) |
| `admin` | Todas | Crear, editar, borrar, biblioteca | Todo |

## Seguridad en vuestra página

- Si usáis CSP, permitid `script-src` y `frame-src` del origen del gateway.
- La pantalla `signup` se abre en una **ventana propia** (el flujo de Meta no puede ir en un iframe): permitid ventanas emergentes iniciadas por el usuario.
- Las pantallas se sirven con CSP estricta (sin `eval` ni scripts en línea), `Referrer-Policy: no-referrer` y `Cache-Control: no-store`. Todo texto de vuestros clientes finales se pinta como texto, nunca como HTML.

## Problemas frecuentes

| Síntoma | Causa probable |
| --- | --- |
| Iframe en blanco y `Refused to frame` en consola | Falta vuestro origen en `allowed_origins` de la app |
| `wa-error` con `unauthorized` | El token caducó o ya se usó: no cachéis la respuesta del endpoint |
| `wa-error` con `feature_not_licensed` | La licencia no incluye `embed` (o `chat`/`templates`/`costs` de esa pantalla) |
| `403 forbidden` al pedir el token | El `role` enviado no alcanza para esa pantalla (`settings` y `costs` exigen `admin`) |
| «Sesión caducada» tras un rato | Vuestro endpoint no respondió a `wa:token_expiring` (error de red o de vuestra sesión) |
| El chat no se actualiza | Hoy la bandeja sondea cada 4 s; las conversaciones nuevas aparecen en unos segundos |
