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:
co-authored by
Claude Sonnet 5
parent
c2e9d2c5a2
commit
8c1e754ab3
@@ -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
|
||||
|
||||
@@ -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
+6
-6
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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 · uMind</span>' +
|
||||
'<button class="umind-close" id="umind-close" aria-label="Cerrar">×</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">✦ 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 {
|
||||
|
||||
Reference in New Issue
Block a user