Arquitectura
Estructura del proyecto
widget/
├── build.mjs # build con esbuild (--watch para dev)
├── serve.mjs # servidor estático local de dist/
├── package.json
├── demo/
│ ├── index.html # sitio de prueba del widget
│ └── frame-crm.html # demo de SSO por iframe del CRM (otro mecanismo)
└── src/
├── widget-loader.js # entrada: todo el widget (IIFE)
├── attachments.js # lógica pura de adjuntos
├── presence.js # lógica pura de "leave intent"
├── attachments.test.js
├── presence.test.js
└── assets/notification.mp3
Módulos de src/
| Archivo | Responsabilidad |
|---|---|
widget-loader.js | Punto de entrada. Envuelve todo en una IIFE: configuración, identidad, UI (botón, panel, mensajes), socket, subida de archivos, badge y API pública |
attachments.js | Funciones puras: límites de tamaño (MAX_DOCUMENT_SIZE_BYTES 10 MB, MAX_IMAGE_SIZE_BYTES 5 MB), duración de audio (1 a 120 s), buildUploadUrl y formatDuration |
presence.js | Función pura shouldEmitLeaveIntent y constantes LEAVE_INTENT_EVENT (contact-leaving) y LEAVE_INTENT_SOURCE |
assets/notification.mp3 | Sonido de notificación; se incrusta como data URI al compilar |
attachments.js y presence.js se extrajeron como módulos hermanos para poder testearlos con vitest sin DOM ni socket; esbuild los incluye en el mismo bundle.
Estructura de widget-loader.js
El archivo (unas 2.600 líneas) se organiza en secciones numeradas por comentarios:
| Sección | Contenido |
|---|---|
| 1. Anti-duplicado | Si window.__WhaticketWidgetLoaded__ ya existe, el script termina sin hacer nada |
| 2. Configuración | Lee window.WhaticketWidgetConfig; sin token registra un error y aborta |
| 3. Origin del CRM | Constantes fijas CRM_ORIGIN, API_BASE (/api/v1), SOCKET_NS (/chat) y SOCKET_CLIENT_SRC (/socket.io/socket.io.js) |
| 3.b Identidad | resolveVisitor(): usuario declarado por el host o visitante anónimo según requiresAuth |
Tema y SETTINGS | Colores (THEME) y opciones efectivas (SETTINGS) con sus valores por defecto |
| 4. Estado | Variables de socket, timers de reintento, DOM (els), no leídos, ticket activo |
| 5. Socket.IO | ensureSocketIo (carga dinámica) y connect (handshake y eventos) |
| 6. Pre-check | preValidate: GET /widget/validate, no bloqueante |
| 7. Montaje | mountWidget y destroyWidget |
| 8. Auto-init | boot() al cargar el script |
API pública
Se expone antes de resolver la identidad:
window.WhaticketWidget = {
init: mountWidget,
destroy: destroyWidget,
open: openPanel,
close: closePanel,
};
Detalle de uso en Integración y configuración.
Ciclo de vida
boot() ──► fetchWidgetConfig() ──► resolveVisitor()
│
requiresAuth === false ─────────┤► visitante anónimo (localStorage)
requiresAuth true ──────────────┤► config.user (si trae externalId y username)
sin config / error ─────────────┘► no monta nada
▼
mountWidget(visitor)
buildPanel() · toggle · badge · connect() · preValidate()
mountWidgetes idempotente: con el mismoexternalIdya montado no hace nada; con otroexternalIdllama adestroyWidget()y remonta.destroyWidgetemite el aviso de salida, desconecta el socket (lo que frena la reconexión), cancela timers, aborta grabación y subida en curso, quita listeners (resize,orientationchange,pagehide,clickdel documento) y elimina del DOM panel, botón y badge.window.WhaticketWidgetpermanece intacto.- El panel se construye de forma anticipada (oculto) al montar, para recibir historial y mensajes aunque esté cerrado.
Aislamiento visual
El panel y el botón usan estilos inline con un reset por elemento (all: initial; box-sizing: border-box; y una pila de fuentes del sistema), de modo que no heredan fuentes ni colores del sitio anfitrión. No usa Shadow DOM ni iframe.
Persistencia en el navegador (localStorage)
| Clave | Contenido |
|---|---|
__WhaticketWidget_visitor_<token> | Id del visitante anónimo (user- más un UUID) |
__WhaticketWidget_unread_<token>_<externalId> | Contador de no leídos (o anon si no hay visitante) |
__WhaticketWidget_pos_<token>_<externalId> | Posición del botón (left, top) cuando draggable es true |
Todos los accesos están en try/catch; si el storage está bloqueado, la identidad anónima vale solo para la sesión.