feat: bucles de crecimiento y activación de uMind
Cuatro cosas que ya estaban a medio construir y no se usaban como canal. 1. El badge del widget ahora es un enlace con UTM. Cada cliente ya te estaba dando exposición en su sitio y no se capitalizaba. El host sale de la URL del propio script, así funciona igual en cualquier entorno. 2. Reporte del mes por agente en Excel: conversaciones atendidas, con qué las abrió el visitante, y el consumo desglosado. Es lo que el cliente necesita para justificar el gasto puertas adentro — un panel al que hay que entrar no sirve para eso, un archivo que se reenvía sí. Reusa el generador de xlsx del cronograma. 3. Aviso automático de agentes sin base de conocimiento (cron diario). Un agente sin fuentes responde de memoria e inventa datos, el cliente concluye que el producto no sirve y se va. Es el punto donde más gente se cae y se detecta solo. Se avisa UNA vez por agente, usando el log de auditoría como registro de envío para no necesitar tabla nueva ni convertir el recordatorio en spam. 4. Tarjeta de uMind en el dashboard del portal: atajo si el cliente ya lo tiene, oferta si no. Es el punto de contacto más barato que hay — ya entró, ya confía, y el cobro se suma a la factura que ya recibe. El mensaje lidera con notas de voz y fotos, que es lo más difícil de copiar de lo que tenemos. La consulta de conversaciones agrupa en SQL en vez de traerse el historial entero para agrupar en Go. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
cfa91a5c09
commit
8ef49c5169
@@ -113,7 +113,10 @@ const umindWidgetJS = `(function () {
|
||||
var script = document.currentScript;
|
||||
var siteKey = script.getAttribute('data-site');
|
||||
if (!siteKey) { console.error('[uMind] falta data-site en el <script>'); return; }
|
||||
var apiBase = script.src.replace(/\/widget\/umind\.js.*$/, '') + '/widget/' + siteKey;
|
||||
// El origen sale de la URL del propio script: así el badge apunta al mismo
|
||||
// host que sirve el widget, sin tener que inyectar la URL desde el backend.
|
||||
var origen = script.src.replace(/\/widget\/umind\.js.*$/, '');
|
||||
var apiBase = origen + '/widget/' + siteKey;
|
||||
var storageKey = 'umind_session_' + siteKey;
|
||||
var color = '#8eb02f';
|
||||
|
||||
@@ -140,7 +143,9 @@ const umindWidgetJS = `(function () {
|
||||
'.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;}',
|
||||
'.umind-badge{text-align:center;font-size:10.5px;padding:5px 0 9px;background:#fff;letter-spacing:.02em;flex-shrink:0;}',
|
||||
'.umind-badge a{color:#aaa;text-decoration:none;transition:color .15s;}',
|
||||
'.umind-badge a:hover{color:#666;}',
|
||||
].join('');
|
||||
document.head.appendChild(style);
|
||||
|
||||
@@ -172,7 +177,10 @@ const umindWidgetJS = `(function () {
|
||||
'<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>';
|
||||
'<div class="umind-badge">' +
|
||||
'<a href="' + origen + '/?utm_source=widget&utm_medium=badge&utm_campaign=umind" ' +
|
||||
'target="_blank" rel="noopener">✦ Impulsado por uMind</a>' +
|
||||
'</div>';
|
||||
document.body.appendChild(panel);
|
||||
panel.querySelector('#umind-send').innerHTML = sendSVG;
|
||||
|
||||
|
||||
@@ -166,6 +166,11 @@ func PortalDashboard(c *fiber.Ctx) error {
|
||||
}
|
||||
models.AplicarProgreso(proyectos)
|
||||
|
||||
// Si el cliente todavía no tiene uMind, el dashboard es el mejor lugar
|
||||
// para ofrecérselo: ya entró, ya confía, y el cobro se suma a la factura
|
||||
// que ya recibe. Si ya lo tiene, se le ofrece el atajo en vez del pitch.
|
||||
tenantsUmind, _ := models.GetUmindTenantsByClientes(clienteIDs)
|
||||
|
||||
// Si partner: agrupar proyectos por cliente
|
||||
type ClienteProyectos struct {
|
||||
Cliente models.Cliente
|
||||
@@ -191,6 +196,7 @@ func PortalDashboard(c *fiber.Ctx) error {
|
||||
"proyectos": proyectos,
|
||||
"grupos": grupos,
|
||||
"isPartner": fullUser.EsPartner(),
|
||||
"tieneUmind": len(tenantsUmind) > 0,
|
||||
}, "layouts/portal")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
@@ -164,9 +165,6 @@ func GetUmindAgentesHandler(c *fiber.Ctx) error {
|
||||
if err := accesoTenant(c, uint(tenantID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := accesoTenant(c, uint(tenantID)); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := models.GetUmindAgentesByTenant(uint(tenantID))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -767,3 +765,94 @@ func rangoFechas(desdeStr, hastaStr string) (time.Time, time.Time) {
|
||||
}
|
||||
return desde, hasta
|
||||
}
|
||||
|
||||
// GetUmindReporteHandler entrega el reporte de actividad de un agente en
|
||||
// Excel. Existe por una razón concreta: el cliente necesita algo que mostrar
|
||||
// puertas adentro para justificar el gasto. Un panel que solo se ve entrando
|
||||
// al portal no sirve para eso; un archivo que se reenvía, sí.
|
||||
// Ruta: GET /umind/reporte.xlsx?agente_id=&desde=&hasta=
|
||||
func GetUmindReporteHandler(c *fiber.Ctx) error {
|
||||
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
||||
if err != nil || agenteID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("agente_id requerido")
|
||||
}
|
||||
agente, errAcceso := accesoAgente(c, uint(agenteID))
|
||||
if errAcceso != nil {
|
||||
return errAcceso
|
||||
}
|
||||
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
|
||||
|
||||
negocio := ""
|
||||
if t, err := models.GetUmindTenantByID(agente.TenantID); err == nil {
|
||||
negocio = t.Nombre
|
||||
}
|
||||
|
||||
sesiones, _ := models.GetUmindSesionesRango(agente.ID, desde, hasta)
|
||||
resumen, _ := models.GetResumenUso(agente.TenantID, desde, hasta)
|
||||
|
||||
filas := [][]string{
|
||||
{"Reporte de actividad"},
|
||||
{"Negocio", negocio},
|
||||
{"Agente", agente.Nombre},
|
||||
{"Período", desde.Format("02/01/2006") + " al " + hasta.Format("02/01/2006")},
|
||||
{},
|
||||
{"Conversaciones atendidas", strconv.Itoa(len(sesiones))},
|
||||
{},
|
||||
{"Consumo del período"},
|
||||
{"Tipo", "Cantidad", "Unidad", "Usos"},
|
||||
}
|
||||
etiquetas := map[string]string{
|
||||
models.UsoTipoIA: "Inteligencia artificial",
|
||||
models.UsoTipoOCR: "Lectura de imágenes",
|
||||
models.UsoTipoWhisper: "Transcripción de audio",
|
||||
}
|
||||
for _, r := range resumen {
|
||||
nombre := etiquetas[r.Tipo]
|
||||
if nombre == "" {
|
||||
nombre = r.Tipo
|
||||
}
|
||||
filas = append(filas, []string{
|
||||
nombre,
|
||||
strconv.FormatFloat(r.Cantidad, 'f', 0, 64),
|
||||
r.Unidad,
|
||||
strconv.FormatInt(r.Eventos, 10),
|
||||
})
|
||||
}
|
||||
|
||||
filas = append(filas, []string{}, []string{"Conversaciones"}, []string{"Fecha", "Mensajes", "Primer mensaje del visitante"})
|
||||
for _, s := range sesiones {
|
||||
filas = append(filas, []string{
|
||||
s.Inicio.Format("02/01/2006 15:04"),
|
||||
strconv.FormatInt(s.Mensajes, 10),
|
||||
s.PrimerMensaje,
|
||||
})
|
||||
}
|
||||
|
||||
nombreArchivo := fmt.Sprintf("reporte-%s-%s.xlsx", slugSimple(agente.Nombre), hasta.Format("2006-01"))
|
||||
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
c.Set("Content-Disposition", `attachment; filename="`+nombreArchivo+`"`)
|
||||
var buf bytes.Buffer
|
||||
if err := services.EscribirXLSX(&buf, "Reporte", filas); err != nil {
|
||||
return c.Status(500).SendString("no se pudo generar el reporte")
|
||||
}
|
||||
return c.Send(buf.Bytes())
|
||||
}
|
||||
|
||||
// slugSimple deja solo minúsculas, números y guiones: un nombre de agente con
|
||||
// acentos o espacios rompe el filename en algunos navegadores.
|
||||
func slugSimple(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == ' ' || r == '-' || r == '_':
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" {
|
||||
return "agente"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -63,4 +63,5 @@ func RegistrarRutasUmind(g fiber.Router, scope fiber.Handler, escritura fiber.Ha
|
||||
g.Get("/umind/eventos", r(controllers.GetUmindEventosHandler)...)
|
||||
g.Get("/umind/uso", r(controllers.GetUmindUsoHandler)...)
|
||||
g.Get("/umind/ai-configs", r(controllers.GetUmindAiConfigsHandler)...)
|
||||
g.Get("/umind/reporte.xlsx", r(controllers.GetUmindReporteHandler)...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user