feat: rediseña el widget embebible (ícono AI, color configurable, animaciones)

El bubble era un emoji 💬 sobre un color fijo hardcodeado en el JS. Ahora:
- Ícono nuevo (spark/sparkle, el motivo visual estándar de "asistente IA"
  hoy) en vez del emoji, con anillo de pulso al cargar para llamar la
  atención sin ser invasivo.
- Color configurable por tenant (UmindTenant.Color, validado como hex en el
  server) — se aplica en tiempo real vía /widget/:site_key/init, así que
  cambiarlo desde el panel no requiere recopiar el <script>.
- Panel con animación de entrada, botón de cerrar explícito, subtítulo
  "Asistente con IA · uMind" y badge de marca — antes solo tenía el nombre
  del tenant y ningún indicio de qué lo potencia.
- El widget ahora se inicializa al cargar el script (no al primer click), así
  el color/nombre reales ya están listos antes de que el visitante abra el chat.

Selector de color agregado al form de tenant en el orquestador (input
type=color + hex).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-12 20:03:51 -05:00
co-authored by Claude Sonnet 5
parent c2e9d2c5a2
commit 8c1e754ab3
8 changed files with 113 additions and 29 deletions
+10
View File
@@ -21,6 +21,7 @@ function vacio() {
ai_config_id: null,
tono: '',
mensaje_bienvenida: '',
color: '#8eb02f',
activo: true,
}
}
@@ -56,6 +57,7 @@ function editarTenant(t) {
ai_config_id: t.ai_config_id,
tono: t.tono,
mensaje_bienvenida: t.mensaje_bienvenida,
color: t.color || '#8eb02f',
activo: t.activo,
}
showForm.value = true
@@ -210,6 +212,14 @@ onMounted(cargar)
class="w-full border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label class="text-xs text-gray-500 dark:text-gray-400">Color del widget</label>
<div class="flex items-center gap-2">
<input v-model="form.color" type="color" class="w-10 h-9 border border-gray-300 dark:border-gray-700 rounded cursor-pointer bg-white dark:bg-gray-800" />
<input v-model="form.color" type="text" pattern="#[0-9a-fA-F]{6}" class="flex-1 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-lg px-3 py-2 text-sm font-mono" />
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-500 mt-1">Se aplica al widget embebido automáticamente, no hace falta recopiar el código.</p>
</div>
<label class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<input v-model="form.activo" type="checkbox" />
Activo
+1
View File
@@ -24,6 +24,7 @@ type UmindTenant struct {
AiConfigID *uint `json:"ai_config_id" gorm:"column:ai_config_id"`
Tono string `json:"tono" gorm:"column:tono;type:text"` // instrucciones de personalidad/tono, se inyectan al system prompt
MensajeBienvenida string `json:"mensaje_bienvenida" gorm:"column:mensaje_bienvenida;type:text"`
Color string `json:"color" gorm:"column:color;size:7;default:'#8eb02f'"` // hex, ej: #8eb02f — color de marca del widget embebido
Activo bool `json:"activo" gorm:"column:activo;default:true"`
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uMind — Orquestador</title>
<script type="module" crossorigin src="/orchestrator/assets/index-DWdBSm9H.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-DJU1V6ZX.css">
<script type="module" crossorigin src="/orchestrator/assets/index-R5fD8918.js"></script>
<link rel="stylesheet" crossorigin href="/orchestrator/assets/index-D-Ppadmm.css">
</head>
<body class="bg-gray-50">
<div id="app"></div>
+77 -20
View File
@@ -36,10 +36,15 @@ func UmindWidgetInit(c *fiber.Ctx) error {
if bienvenida == "" {
bienvenida = "¡Hola! ¿En qué puedo ayudarte?"
}
color := strings.TrimSpace(tenant.Color)
if color == "" {
color = "#8eb02f"
}
return c.JSON(fiber.Map{
"session_id": nuevaSessionID(),
"nombre": tenant.Nombre,
"mensaje_bienvenida": bienvenida,
"color": color,
})
}
@@ -96,55 +101,104 @@ const umindWidgetJS = `(function () {
if (!siteKey) { console.error('[uMind] falta data-site en el <script>'); return; }
var apiBase = script.src.replace(/\/widget\/umind\.js.*$/, '') + '/widget/' + siteKey;
var storageKey = 'umind_session_' + siteKey;
var color = '#8eb02f';
var bubble = document.createElement('div');
bubble.innerHTML = '💬';
bubble.setAttribute('style', 'position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;background:#8eb02f;color:#fff;display:flex;align-items:center;justify-content:center;font-size:26px;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.2);z-index:999999;');
var style = document.createElement('style');
style.textContent = [
'.umind-ring{position:fixed;bottom:20px;right:20px;width:60px;height:60px;border-radius:50%;z-index:999998;pointer-events:none;animation:umind-pulse 2.4s ease-out 3;}',
'@keyframes umind-pulse{0%{box-shadow:0 0 0 0 var(--umind-color,#8eb02f);}100%{box-shadow:0 0 0 24px transparent;}}',
'.umind-bubble{position:fixed;bottom:20px;right:20px;width:60px;height:60px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,.22);z-index:999999;border:none;transition:transform .15s ease;padding:0;}',
'.umind-bubble:hover{transform:scale(1.06);}',
'.umind-bubble svg{width:26px;height:26px;display:block;}',
'.umind-panel{position:fixed;bottom:92px;right:20px;width:356px;max-width:92vw;height:480px;max-height:72vh;background:#fff;border-radius:16px;box-shadow:0 16px 40px rgba(0,0,0,.22);z-index:999999;display:flex;flex-direction:column;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;opacity:0;transform:translateY(14px) scale(.97);pointer-events:none;transition:opacity .18s ease,transform .18s ease;}',
'.umind-panel.umind-abierto{opacity:1;transform:translateY(0) scale(1);pointer-events:auto;}',
'.umind-header{padding:16px 40px 16px 16px;color:#fff;position:relative;flex-shrink:0;}',
'.umind-header b{display:block;font-size:14.5px;font-weight:600;}',
'.umind-header span{display:block;font-size:11px;opacity:.85;margin-top:2px;}',
'.umind-close{position:absolute;top:12px;right:12px;background:rgba(255,255,255,.22);border:none;color:#fff;width:24px;height:24px;border-radius:50%;cursor:pointer;font-size:15px;line-height:1;}',
'.umind-msgs{flex:1;overflow-y:auto;padding:14px;font-size:13px;line-height:1.45;background:#fafafa;}',
'.umind-msg{margin-bottom:10px;padding:9px 12px;border-radius:13px;max-width:82%;white-space:pre-wrap;}',
'.umind-msg-bot{background:#fff;color:#222;border:1px solid #ececec;border-bottom-left-radius:4px;}',
'.umind-msg-user{color:#fff;margin-left:auto;border-bottom-right-radius:4px;}',
'.umind-inputbar{display:flex;align-items:center;border-top:1px solid #eee;padding:8px;gap:6px;background:#fff;flex-shrink:0;}',
'.umind-inputbar input{flex:1;border:1px solid #e5e5e5;border-radius:20px;padding:9px 14px;font-size:13px;outline:none;}',
'.umind-inputbar button{border:none;color:#fff;width:34px;height:34px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;}',
'.umind-inputbar button svg{width:16px;height:16px;}',
'.umind-badge{text-align:center;font-size:10.5px;color:#aaa;padding:5px 0 9px;background:#fff;letter-spacing:.02em;flex-shrink:0;}',
].join('');
document.head.appendChild(style);
var sparkSVG = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' +
'<path d="M12 2.5c.6 3.6 2.4 5.4 6 6-3.6.6-5.4 2.4-6 6-.6-3.6-2.4-5.4-6-6 3.6-.6 5.4-2.4 6-6z" fill="#fff"/>' +
'<path d="M19 15c.3 1.8 1.1 2.7 2.9 3-1.8.3-2.6 1.2-2.9 3-.3-1.8-1.1-2.7-2.9-3 1.8-.3 2.6-1.2 2.9-3z" fill="#fff" opacity=".85"/>' +
'</svg>';
var sendSVG = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 19V5M12 5l-6 6M12 5l6 6" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
var ring = document.createElement('div');
ring.className = 'umind-ring';
document.body.appendChild(ring);
var bubble = document.createElement('button');
bubble.className = 'umind-bubble';
bubble.setAttribute('aria-label', 'Abrir chat');
bubble.innerHTML = sparkSVG;
document.body.appendChild(bubble);
var panel = document.createElement('div');
panel.setAttribute('style', 'display:none;position:fixed;bottom:86px;right:20px;width:340px;max-width:92vw;height:460px;max-height:70vh;background:#fff;border-radius:14px;box-shadow:0 8px 30px rgba(0,0,0,.25);z-index:999999;flex-direction:column;overflow:hidden;font-family:system-ui,sans-serif;');
panel.className = 'umind-panel';
panel.innerHTML =
'<div style="background:#8eb02f;color:#fff;padding:12px 14px;font-weight:600;font-size:14px;">' +
'<span id="umind-title">Asistente</span>' +
'<div class="umind-header" id="umind-header">' +
'<b id="umind-title">Asistente</b><span>Asistente con IA &middot; uMind</span>' +
'<button class="umind-close" id="umind-close" aria-label="Cerrar">&times;</button>' +
'</div>' +
'<div id="umind-msgs" style="flex:1;overflow-y:auto;padding:12px;font-size:13px;line-height:1.4;"></div>' +
'<div style="display:flex;border-top:1px solid #eee;">' +
'<input id="umind-input" type="text" placeholder="Escribe tu mensaje..." style="flex:1;border:none;padding:10px 12px;font-size:13px;outline:none;">' +
'<button id="umind-send" style="border:none;background:#8eb02f;color:#fff;padding:0 16px;cursor:pointer;">Enviar</button>' +
'</div>';
'<div class="umind-msgs" id="umind-msgs"></div>' +
'<div class="umind-inputbar">' +
'<input id="umind-input" type="text" placeholder="Escribe tu mensaje..." />' +
'<button id="umind-send" aria-label="Enviar"></button>' +
'</div>' +
'<div class="umind-badge">&#10022; Impulsado por uMind</div>';
document.body.appendChild(panel);
panel.querySelector('#umind-send').innerHTML = sendSVG;
function aplicarColor(c) {
if (!c) return;
color = c;
ring.style.setProperty('--umind-color', color);
bubble.style.background = 'radial-gradient(circle at 30% 28%, rgba(255,255,255,.35), transparent 55%), ' + color;
panel.querySelector('#umind-header').style.background = color;
panel.querySelector('#umind-send').style.background = color;
}
aplicarColor(color);
var msgsEl = panel.querySelector('#umind-msgs');
var inputEl = panel.querySelector('#umind-input');
var sendEl = panel.querySelector('#umind-send');
var sessionId = null;
var abierto = false;
var iniciado = false;
function agregarMensaje(texto, quien) {
var burbuja = document.createElement('div');
burbuja.textContent = texto;
var esUser = quien === 'user';
burbuja.setAttribute('style', 'margin-bottom:8px;padding:8px 10px;border-radius:10px;max-width:80%;white-space:pre-wrap;' +
(esUser ? 'background:#8eb02f;color:#fff;margin-left:auto;' : 'background:#f1f1f1;color:#222;'));
burbuja.className = 'umind-msg ' + (esUser ? 'umind-msg-user' : 'umind-msg-bot');
if (esUser) burbuja.style.background = color;
msgsEl.appendChild(burbuja);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
function iniciar() {
if (iniciado) return;
iniciado = true;
sessionId = sessionStorage.getItem(storageKey) || null;
fetch(apiBase + '/init')
.then(function (r) { return r.json(); })
.then(function (data) {
if (!sessionId) { sessionId = data.session_id; sessionStorage.setItem(storageKey, sessionId); }
if (data.nombre) panel.querySelector('#umind-title').textContent = data.nombre;
aplicarColor(data.color);
agregarMensaje(data.mensaje_bienvenida, 'bot');
})
.catch(function () { agregarMensaje('No se pudo conectar el asistente.', 'bot'); });
}
iniciar(); // arranca ya, así el color/nombre reales están listos antes del primer click
function enviar() {
var texto = inputEl.value.trim();
@@ -164,11 +218,14 @@ const umindWidgetJS = `(function () {
.catch(function () { agregarMensaje('Error de conexión, intenta de nuevo.', 'bot'); });
}
bubble.addEventListener('click', function () {
function alternar() {
abierto = !abierto;
panel.style.display = abierto ? 'flex' : 'none';
if (abierto) iniciar();
});
panel.classList.toggle('umind-abierto', abierto);
if (abierto) inputEl.focus();
}
bubble.addEventListener('click', alternar);
panel.querySelector('#umind-close').addEventListener('click', alternar);
sendEl.addEventListener('click', enviar);
inputEl.addEventListener('keydown', function (e) { if (e.key === 'Enter') enviar(); });
})();
@@ -48,9 +48,23 @@ type umindTenantReq struct {
AiConfigID *uint `json:"ai_config_id"`
Tono string `json:"tono"`
MensajeBienvenida string `json:"mensaje_bienvenida"`
Color string `json:"color"`
Activo bool `json:"activo"`
}
var umindColorHexRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// colorTenant valida el hex del color de marca del widget; si viene vacío o
// inválido cae al verde por defecto en vez de guardar basura (el valor se
// aplica tal cual como CSS custom property en el widget embebido).
func colorTenant(color string) string {
color = strings.TrimSpace(color)
if umindColorHexRegex.MatchString(color) {
return color
}
return "#8eb02f"
}
func (r umindTenantReq) dominiosLimpios() []string {
var out []string
for _, d := range r.DominiosPermitidos {
@@ -81,6 +95,7 @@ func CreateUmindTenantHandler(c *fiber.Ctx) error {
AiConfigID: req.AiConfigID,
Tono: req.Tono,
MensajeBienvenida: req.MensajeBienvenida,
Color: colorTenant(req.Color),
Activo: true,
CreadoPorID: extraerUserID(c),
}
@@ -106,6 +121,7 @@ func UpdateUmindTenantHandler(c *fiber.Ctx) error {
"ai_config_id": req.AiConfigID,
"tono": req.Tono,
"mensaje_bienvenida": req.MensajeBienvenida,
"color": colorTenant(req.Color),
"activo": req.Activo,
}
if err := models.UpdateUmindTenant(uint(id), updates); err != nil {