WIP: mejoras de fidelidad, editor, lógica y suite e2e (checkpoint tras reinicio)
This commit is contained in:
+1241
-158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,330 @@
|
||||
/* =====================================================================
|
||||
js/escenarios.js — Escenarios de arranque por industria (editor, C).
|
||||
Objetos JS planos (sin fetch: funciona por file://). Cada escenario
|
||||
devuelve un estado que se pasa por WAM.normalizar antes de usarse.
|
||||
Todos los negocios, personas, direcciones y cifras son FICTICIOS y
|
||||
de ejemplo: el equipo debe revisar los textos antes de usarlos con
|
||||
un cliente. No toca el DOM al cargar.
|
||||
===================================================================== */
|
||||
(function () {
|
||||
"use strict";
|
||||
window.WAM = window.WAM || {};
|
||||
|
||||
function escXml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||||
});
|
||||
}
|
||||
|
||||
// Ilustración SVG ligera en línea (sin red): fondo + círculo + rótulo
|
||||
function ilus(texto, fondo, acento, simbolo) {
|
||||
var svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320" viewBox="0 0 480 320">' +
|
||||
'<rect width="480" height="320" fill="' + fondo + '"/>' +
|
||||
'<circle cx="240" cy="130" r="74" fill="' + acento + '" opacity=".9"/>' +
|
||||
'<text x="240" y="152" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="62" fill="#fff">' + escXml(simbolo || "") + "</text>" +
|
||||
'<text x="240" y="266" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="25" fill="#33414a">' + escXml(texto || "") + "</text>" +
|
||||
"</svg>";
|
||||
return "data:image/svg+xml," + encodeURIComponent(svg);
|
||||
}
|
||||
|
||||
function avatar(letra, color) {
|
||||
var svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">' +
|
||||
'<circle cx="48" cy="48" r="48" fill="' + color + '"/>' +
|
||||
'<text x="48" y="62" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="40" font-weight="700" fill="#fff">' + escXml(letra) + "</text>" +
|
||||
"</svg>";
|
||||
return "data:image/svg+xml," + encodeURIComponent(svg);
|
||||
}
|
||||
|
||||
// Atajos de construcción
|
||||
function t(dir, hora, texto, extra) {
|
||||
var m = { dir: dir, tipo: "text", hora: hora, texto: texto };
|
||||
if (extra) Object.keys(extra).forEach(function (k) { m[k] = extra[k]; });
|
||||
return m;
|
||||
}
|
||||
function r(texto) { return { dir: "in", tipo: "text", hora: "", texto: texto }; }
|
||||
function msg(dir, tipo, hora, campos) {
|
||||
var m = { dir: dir, tipo: tipo, hora: hora, estado: dir === "out" ? "read" : "sent" };
|
||||
Object.keys(campos || {}).forEach(function (k) { m[k] = campos[k]; });
|
||||
return m;
|
||||
}
|
||||
function estado(cuenta, mensajes) {
|
||||
return { version: 1, tema: "claro", cuenta: cuenta, mensajes: mensajes };
|
||||
}
|
||||
|
||||
var ESCENARIOS = [
|
||||
{
|
||||
id: "blanco",
|
||||
nombre: "En blanco",
|
||||
descripcion: "Solo la cuenta, sin mensajes. Para construir desde cero.",
|
||||
datos: function () {
|
||||
return estado({ nombre: "Mi negocio", avatar: "", subtitulo: "en línea", verificado: false }, []);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "restaurante",
|
||||
nombre: "Restaurante · reservas",
|
||||
descripcion: "Reserva de mesa con plantilla de confirmación, menú en lista y ubicación.",
|
||||
datos: function () {
|
||||
return estado({ nombre: "La Terraza del Puerto", avatar: avatar("T", "#b5523b"), subtitulo: "en línea", verificado: true }, [
|
||||
t("out", "13:02", "Hola, ¿tienen mesa para este sábado?"),
|
||||
msg("in", "buttons", "13:02", {
|
||||
body: "¡Hola! 👋 Con gusto te ayudo con tu reserva. ¿Para cuántas personas sería?",
|
||||
footer: "La Terraza del Puerto",
|
||||
botones: [
|
||||
{ texto: "2 personas", respuestas: [r("Perfecto, mesa para 2. ¿Prefieres comida (14:00) o cena (21:00)?")] },
|
||||
{ texto: "3 a 5 personas", respuestas: [r("Genial. Para grupos de 3 a 5 tenemos terraza o salón. Te comparto el menú mientras eliges.")] },
|
||||
{ texto: "Más de 5", respuestas: [r("Para grupos grandes te atiende nuestro equipo de eventos. Te escriben en unos minutos.")] }
|
||||
]
|
||||
}),
|
||||
t("out", "13:03", "Somos 2, para cenar"),
|
||||
msg("in", "template", "13:03", {
|
||||
headerTipo: "imagen",
|
||||
headerMedia: ilus("Tu mesa está lista", "#f6e7df", "#b5523b", "✓"),
|
||||
body: "Hola Ana, tu mesa para *2 personas* quedó reservada el sábado a las *21:00*. ¿Confirmas la reserva?",
|
||||
footer: "Tolerancia de 15 minutos",
|
||||
botones: [
|
||||
{ texto: "Confirmar", respuestas: [r("¡Listo! Reserva confirmada ✨ Te esperamos el sábado a las 21:00.")] },
|
||||
{ texto: "Cambiar horario", respuestas: [r("Claro. ¿A qué hora te acomoda mejor? Tenemos 20:00, 21:30 y 22:00.")] }
|
||||
]
|
||||
}),
|
||||
msg("in", "list", "13:04", {
|
||||
header: "Menú de la casa",
|
||||
body: "Mientras tanto, ¿quieres ver alguna sección del menú?",
|
||||
footer: "Precios con impuestos incluidos",
|
||||
boton: "Ver menú",
|
||||
secciones: [
|
||||
{ titulo: "Entradas", filas: [
|
||||
{ titulo: "Ceviche del día", descripcion: "Pesca local con cítricos", respuestas: [r("El ceviche del día cambia según la pesca. Te lo recomendamos 🐟")] },
|
||||
{ titulo: "Pan de la casa", descripcion: "Masa madre y mantequilla", respuestas: [r("Nuestro pan se hornea cada tarde 🥖")] }
|
||||
] },
|
||||
{ titulo: "Platos fuertes", filas: [
|
||||
{ titulo: "Pescado a la talla", descripcion: "Para compartir", respuestas: [r("Buena elección para 2 personas. Se prepara en unos 25 minutos.")] },
|
||||
{ titulo: "Pasta de mariscos", descripcion: "Receta de la casa", respuestas: [r("La pasta de mariscos es de las más pedidas 🍝")] }
|
||||
] }
|
||||
]
|
||||
}),
|
||||
msg("in", "location", "13:05", {
|
||||
latitud: "19.4326", longitud: "-99.1332",
|
||||
nombre: "La Terraza del Puerto", direccion: "Av. del Malecón 120, Centro"
|
||||
})
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "ecommerce",
|
||||
nombre: "E-commerce · pedido",
|
||||
descripcion: "Confirmación y seguimiento de pedido, catálogo en carrusel y enlace de rastreo.",
|
||||
datos: function () {
|
||||
return estado({ nombre: "Tienda Brisa", avatar: avatar("B", "#2f6fb5"), subtitulo: "Cuenta de empresa", verificado: true }, [
|
||||
msg("in", "template", "09:15", {
|
||||
headerTipo: "texto",
|
||||
headerTexto: "Pedido #10482 confirmado",
|
||||
body: "¡Gracias por tu compra, Carlos! 🎉 Tu pedido ya está en preparación. Te avisaremos por aquí cuando salga a reparto.",
|
||||
footer: "Tienda Brisa · Atención a clientes",
|
||||
botones: [
|
||||
{ texto: "Ver detalle", respuestas: [r("Tu pedido incluye: 1 × Sudadera Brisa (M) y 1 × Gorra clásica. Total: $1,250.")] },
|
||||
{ texto: "Cambiar dirección", respuestas: [r("Sin problema. Envíame la nueva dirección completa con código postal.")] }
|
||||
]
|
||||
}),
|
||||
t("out", "09:20", "¿Cuándo llega?"),
|
||||
msg("in", "cta_url", "09:20", {
|
||||
headerMedia: ilus("En camino", "#e3eef9", "#2f6fb5", "➜"),
|
||||
body: "Tu pedido salió a reparto 🚚 La entrega estimada es *mañana entre 10:00 y 14:00*.",
|
||||
footer: "Actualizado hace 5 minutos",
|
||||
textoBoton: "Rastrear pedido",
|
||||
url: "https://example.com/rastreo"
|
||||
}),
|
||||
msg("in", "carousel", "09:21", {
|
||||
body: "Mientras llega, esto combina con tu compra:",
|
||||
cards: [
|
||||
{ media: ilus("Chamarra ligera", "#e8f0f7", "#2f6fb5", "1"), texto: "Chamarra ligera · impermeable y plegable.", textoBoton: "Me interesa", url: "", respuestas: [r("¡Buena elección! Te aparto una en talla M por 24 h.")] },
|
||||
{ media: ilus("Mochila urbana", "#eef3ea", "#4f8a3c", "2"), texto: "Mochila urbana · 20 L con compartimento para laptop.", textoBoton: "Me interesa", url: "", respuestas: [r("La mochila tiene 10 % de descuento esta semana.")] },
|
||||
{ media: ilus("Termo 750 ml", "#f7efe3", "#c07a2c", "3"), texto: "Termo de acero · 750 ml, frío 24 h.", textoBoton: "Me interesa", url: "", respuestas: [r("El termo viene en 4 colores. ¿Cuál prefieres?")] }
|
||||
]
|
||||
}),
|
||||
msg("in", "buttons", "09:22", {
|
||||
body: "¿Hay algo más en lo que te pueda ayudar?",
|
||||
botones: [
|
||||
{ texto: "Devoluciones", respuestas: [r("Tienes 30 días para devoluciones sin costo. Solo responde con tu número de pedido.")] },
|
||||
{ texto: "Hablar con alguien", respuestas: [r("Te conecto con una persona del equipo. Tiempo de espera aproximado: 3 minutos.")] },
|
||||
{ texto: "No, gracias", respuestas: [r("¡Gracias a ti! Que disfrutes tu compra 💙")] }
|
||||
]
|
||||
})
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "clinica",
|
||||
nombre: "Clínica · citas",
|
||||
descripcion: "Agenda de citas por especialidad, recordatorio con plantilla y documento de indicaciones.",
|
||||
datos: function () {
|
||||
return estado({ nombre: "Clínica Bienestar", avatar: avatar("C", "#1f8a7a"), subtitulo: "en línea", verificado: true }, [
|
||||
t("out", "08:40", "Hola, quiero agendar una cita"),
|
||||
msg("in", "list", "08:40", {
|
||||
header: "Agenda tu cita",
|
||||
body: "¡Hola! Soy el asistente de Clínica Bienestar. ¿Con qué especialidad necesitas tu cita?",
|
||||
footer: "Horario: lunes a sábado",
|
||||
boton: "Especialidades",
|
||||
secciones: [
|
||||
{ titulo: "Consulta", filas: [
|
||||
{ titulo: "Medicina general", descripcion: "Consulta de 30 minutos", respuestas: [r("Tengo disponibles mañana a las 10:00 y a las 12:30. ¿Cuál prefieres?")] },
|
||||
{ titulo: "Pediatría", descripcion: "Niñas y niños de 0 a 15 años", respuestas: [r("Pediatría tiene espacio el jueves a las 9:00 y a las 16:00.")] }
|
||||
] },
|
||||
{ titulo: "Estudios", filas: [
|
||||
{ titulo: "Laboratorio", descripcion: "Análisis clínicos", respuestas: [r("Para laboratorio no necesitas cita: de 7:00 a 11:00, en ayuno.")] }
|
||||
] }
|
||||
]
|
||||
}),
|
||||
t("out", "08:42", "Mañana a las 10:00"),
|
||||
msg("in", "template", "08:42", {
|
||||
headerTipo: "texto",
|
||||
headerTexto: "Cita confirmada",
|
||||
body: "Hola Laura, tu cita de *Medicina general* quedó agendada para mañana a las *10:00*. Llega 10 minutos antes con una identificación.",
|
||||
footer: "Clínica Bienestar",
|
||||
botones: [
|
||||
{ texto: "Confirmar asistencia", respuestas: [r("¡Gracias! Tu asistencia quedó confirmada ✅")] },
|
||||
{ texto: "Reprogramar", respuestas: [r("Claro. ¿Qué día te acomoda mejor?")] },
|
||||
{ texto: "Cancelar cita", respuestas: [r("Tu cita fue cancelada. Si necesitas otra, escríbenos cuando quieras.")] }
|
||||
]
|
||||
}),
|
||||
msg("in", "document", "08:43", {
|
||||
caption: "Indicaciones para tu consulta",
|
||||
filename: "indicaciones-consulta.pdf"
|
||||
}),
|
||||
msg("in", "location", "08:43", {
|
||||
latitud: "20.6597", longitud: "-103.3496",
|
||||
nombre: "Clínica Bienestar", direccion: "Calle Robles 45, Col. Jardines"
|
||||
})
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "inmobiliaria",
|
||||
nombre: "Inmobiliaria · visitas",
|
||||
descripcion: "Presentación de propiedades en carrusel, filtros por zona y agenda de visita.",
|
||||
datos: function () {
|
||||
return estado({ nombre: "Horizonte Inmuebles", avatar: avatar("H", "#6b4fa3"), subtitulo: "Cuenta de empresa", verificado: false }, [
|
||||
t("out", "17:10", "Hola, vi un anuncio de departamentos en renta"),
|
||||
msg("in", "buttons", "17:10", {
|
||||
headerTipo: "imagen",
|
||||
headerMedia: ilus("Encuentra tu espacio", "#efe9f7", "#6b4fa3", "⌂"),
|
||||
body: "¡Hola! Gracias por escribirnos. ¿Qué tipo de propiedad buscas?",
|
||||
botones: [
|
||||
{ texto: "Departamento", respuestas: [r("Te muestro los departamentos disponibles esta semana 👇")] },
|
||||
{ texto: "Casa", respuestas: [r("Por ahora tenemos 2 casas en renta. Un asesor te comparte los detalles.")] },
|
||||
{ texto: "Oficina", respuestas: [r("Para oficinas te atiende nuestro equipo comercial. ¿Me compartes tu correo?")] }
|
||||
]
|
||||
}),
|
||||
msg("in", "carousel", "17:11", {
|
||||
body: "Departamentos disponibles:",
|
||||
cards: [
|
||||
{ media: ilus("2 recámaras · 78 m²", "#f1ecf8", "#6b4fa3", "A"), texto: "Zona norte · 2 recámaras, 1 baño, estacionamiento.", textoBoton: "Agendar visita", url: "", respuestas: [r("¿Qué día te gustaría visitarlo? Tenemos martes y jueves por la tarde.")] },
|
||||
{ media: ilus("1 recámara · 52 m²", "#e9f2f1", "#2f8a7e", "B"), texto: "Zona centro · 1 recámara, amueblado, cerca del metro.", textoBoton: "Agendar visita", url: "", respuestas: [r("Este se renta amueblado. ¿Te agendo una visita el sábado a las 11:00?")] },
|
||||
{ media: ilus("3 recámaras · 110 m²", "#f7efe6", "#b0763a", "C"), texto: "Zona sur · 3 recámaras, 2 baños, jardín común.", textoBoton: "Ver fotos", url: "https://example.com/propiedad", respuestas: [] }
|
||||
]
|
||||
}),
|
||||
t("out", "17:13", "El de 2 recámaras, el jueves"),
|
||||
msg("in", "template", "17:13", {
|
||||
body: "Listo, tu visita al departamento de *2 recámaras* quedó agendada el jueves a las *18:00*. Te acompaña Daniel, tu asesor.",
|
||||
footer: "Horizonte Inmuebles",
|
||||
botones: [
|
||||
{ texto: "Confirmar", respuestas: [r("¡Perfecto! Te enviamos la ubicación exacta un día antes.")] },
|
||||
{ texto: "Cambiar hora", respuestas: [r("¿Qué horario te funciona mejor el jueves?")] }
|
||||
]
|
||||
}),
|
||||
msg("in", "contact", "17:14", {
|
||||
nombre: "Asesor de ejemplo", telefono: "+52 55 0000 0000", email: "[email protected]", empresa: "Horizonte Inmuebles"
|
||||
})
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "soporte",
|
||||
nombre: "Soporte · preguntas frecuentes",
|
||||
descripcion: "Menú de ayuda en lista, respuestas encadenadas y paso a una persona del equipo.",
|
||||
datos: function () {
|
||||
return estado({ nombre: "Conecta Soporte", avatar: avatar("S", "#0f7c9b"), subtitulo: "Responde en minutos", verificado: true }, [
|
||||
t("out", "11:00", "Hola, tengo un problema con mi cuenta"),
|
||||
msg("in", "list", "11:00", {
|
||||
header: "Centro de ayuda",
|
||||
body: "Hola 👋 Soy el asistente virtual. Elige el tema y te ayudo al momento.",
|
||||
footer: "Disponible 24/7",
|
||||
boton: "Ver temas",
|
||||
secciones: [
|
||||
{ titulo: "Cuenta", filas: [
|
||||
{ titulo: "Olvidé mi contraseña", descripcion: "Restablecer el acceso", respuestas: [
|
||||
r("Puedes restablecerla desde *Iniciar sesión › ¿Olvidaste tu contraseña?*"),
|
||||
r("Te llegará un correo con un enlace válido por 30 minutos.")
|
||||
] },
|
||||
{ titulo: "Cambiar mi correo", descripcion: "Actualizar datos de contacto", respuestas: [r("Ve a *Perfil › Datos de contacto* y confirma el cambio desde tu correo actual.")] }
|
||||
] },
|
||||
{ titulo: "Pagos", filas: [
|
||||
{ titulo: "Cobro no reconocido", descripcion: "Revisar un cargo", respuestas: [r("Lo revisamos contigo. Te paso con una persona del equipo de pagos.")] },
|
||||
{ titulo: "Descargar factura", descripcion: "Facturas de los últimos 12 meses", respuestas: [r("Tus facturas están en *Cuenta › Facturación*.")] }
|
||||
] }
|
||||
]
|
||||
}),
|
||||
msg("in", "buttons", "11:02", {
|
||||
body: "¿Te sirvió esta respuesta?",
|
||||
botones: [
|
||||
{ texto: "Sí, gracias", respuestas: [r("¡Qué bueno! Si necesitas algo más, aquí estamos 🙌")] },
|
||||
{ texto: "No, necesito ayuda", respuestas: [r("Te conecto con una persona del equipo. Tiempo de espera aproximado: 5 minutos.")] }
|
||||
]
|
||||
}),
|
||||
msg("in", "cta_url", "11:03", {
|
||||
body: "También puedes consultar las guías paso a paso en nuestro centro de ayuda.",
|
||||
textoBoton: "Abrir guías",
|
||||
url: "https://example.com/ayuda"
|
||||
})
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "cobranza",
|
||||
nombre: "Cobranza · recordatorio",
|
||||
descripcion: "Recordatorio de pago con plantilla, opciones de pago y comprobante en documento.",
|
||||
datos: function () {
|
||||
return estado({ nombre: "Servicios Claros", avatar: avatar("S", "#3b7d4f"), subtitulo: "Cuenta de empresa", verificado: true }, [
|
||||
msg("in", "template", "10:00", {
|
||||
headerTipo: "texto",
|
||||
headerTexto: "Recordatorio de pago",
|
||||
body: "Hola Marta, te recordamos que tu pago de *$850* vence el *15 de octubre*. Evita recargos pagando a tiempo.",
|
||||
footer: "Si ya pagaste, ignora este mensaje",
|
||||
botones: [
|
||||
{ texto: "Pagar ahora", url: "https://example.com/pago", respuestas: [] },
|
||||
{ texto: "Ya pagué", respuestas: [r("¡Gracias! En cuanto se refleje tu pago te enviamos el comprobante.")] },
|
||||
{ texto: "Necesito una prórroga", respuestas: [r("Entendido. Te comparto las opciones disponibles 👇")] }
|
||||
]
|
||||
}),
|
||||
t("out", "10:05", "Necesito una prórroga"),
|
||||
msg("in", "buttons", "10:05", {
|
||||
body: "Estas son las opciones que tenemos para ti:",
|
||||
footer: "Sujeto a aprobación",
|
||||
botones: [
|
||||
{ texto: "Pagar en 2 partes", respuestas: [r("Listo, dividimos tu saldo en 2 pagos quincenales. Te enviamos el detalle por aquí.")] },
|
||||
{ texto: "Mover la fecha", respuestas: [r("Podemos mover la fecha de vencimiento hasta 7 días. ¿Te funciona?")] },
|
||||
{ texto: "Hablar con asesor", respuestas: [r("Un asesor te contactará hoy en horario de oficina.")] }
|
||||
]
|
||||
}),
|
||||
t("out", "10:07", "Pagar en 2 partes"),
|
||||
msg("in", "document", "10:08", {
|
||||
caption: "Aquí está el detalle de tus 2 pagos",
|
||||
filename: "plan-de-pagos.pdf"
|
||||
})
|
||||
]);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
WAM.ESCENARIOS = ESCENARIOS;
|
||||
|
||||
// Devuelve un estado CRUDO (sin normalizar) del escenario id, o null
|
||||
WAM.datosEscenario = function (id) {
|
||||
for (var i = 0; i < ESCENARIOS.length; i++) {
|
||||
if (ESCENARIOS[i].id === id) return ESCENARIOS[i].datos();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
})();
|
||||
+560
-114
@@ -1,7 +1,15 @@
|
||||
/* WAM · interactivity.js — dueño D (núcleo).
|
||||
Flujos de clic (quick replies, filas de lista, botones de plantilla y de card),
|
||||
composer libre, indicador "escribiendo…", ticks automáticos y modo
|
||||
"reproducir demo" paso a paso. No toca el DOM al cargar. */
|
||||
"reproducir demo" paso a paso (con autoPulsar y velocidad). No toca el DOM
|
||||
al cargar.
|
||||
|
||||
Guion frente a sesión: WAM.estado.mensajes es el GUION (persistente, lo que
|
||||
edita el editor y reproduce la demo). Lo que ocurre al pulsar botones o
|
||||
escribir en el composer es la SESIÓN (WAM.sesion, solo en memoria): se pinta
|
||||
con anadirMensajeDOM sin tocar el estado ni localStorage. Cualquier
|
||||
WAM.render() vuelve al guion: vacía la sesión, cierra la hoja de lista y
|
||||
cancela flujos y demo en curso. */
|
||||
window.WAM = window.WAM || {};
|
||||
(function () {
|
||||
"use strict";
|
||||
@@ -11,42 +19,127 @@ window.WAM = window.WAM || {};
|
||||
"text", "image", "video", "audio", "document", "sticker", "location",
|
||||
"contact", "template", "buttons", "list", "cta_url", "carousel"
|
||||
];
|
||||
var CLAVE_VELOCIDAD = "wam-mockup-v1-velocidad";
|
||||
var VELOCIDADES = [0.5, 1, 2];
|
||||
|
||||
/* Todo temporizador pasa por WAM.espera(); cancelarTodo() aborta los flujos
|
||||
y la reproducción de la demo en curso. */
|
||||
var cancelaciones = [];
|
||||
var demoAndando = false;
|
||||
function nada() { /* noop */ }
|
||||
|
||||
function quitar(fn) {
|
||||
var i = cancelaciones.indexOf(fn);
|
||||
if (i !== -1) cancelaciones.splice(i, 1);
|
||||
/* Mensajes de la sesión en curso (efímeros). */
|
||||
WAM.sesion = [];
|
||||
var contSesion = 0;
|
||||
function idSesion() { contSesion++; return "s" + contSesion; }
|
||||
|
||||
/* ————————————————— temporizadores cancelables por grupo ————————————————— */
|
||||
|
||||
/* Dos grupos: "demo" (reproducción) y "flujo" (clics, composer, ticks).
|
||||
Cancelar un grupo sube su generación: los pasos pendientes de esa
|
||||
generación se rechazan con CANCELADO y ya no pueden programar más. */
|
||||
var grupos = {
|
||||
demo: { gen: 0, cancelar: [] },
|
||||
flujo: { gen: 0, cancelar: [] }
|
||||
};
|
||||
|
||||
function grupoDe(nombre) { return grupos[nombre] ? nombre : "flujo"; }
|
||||
|
||||
var velocidad = 1;
|
||||
try {
|
||||
var guardada = parseFloat(window.localStorage.getItem(CLAVE_VELOCIDAD));
|
||||
if (VELOCIDADES.indexOf(guardada) !== -1) velocidad = guardada;
|
||||
} catch (e) { /* sin localStorage */ }
|
||||
|
||||
WAM.getVelocidad = function () { return velocidad; };
|
||||
WAM.setVelocidad = function (v) {
|
||||
var n = parseFloat(v);
|
||||
if (VELOCIDADES.indexOf(n) === -1) n = 1;
|
||||
velocidad = n;
|
||||
try { window.localStorage.setItem(CLAVE_VELOCIDAD, String(n)); } catch (e) { /* idem */ }
|
||||
return velocidad;
|
||||
};
|
||||
|
||||
function esperaEn(nombre, ms, gen) {
|
||||
var g = grupos[grupoDe(nombre)];
|
||||
return new Promise(function (resolver, rechazar) {
|
||||
if (gen !== undefined && gen !== g.gen) { rechazar(CANCELADO); return; }
|
||||
var dur = Math.max(0, (Number(ms) || 0) / velocidad);
|
||||
var id = setTimeout(function () {
|
||||
quitar();
|
||||
if (gen !== undefined && gen !== g.gen) rechazar(CANCELADO);
|
||||
else resolver();
|
||||
}, dur);
|
||||
function cancelar() {
|
||||
clearTimeout(id);
|
||||
quitar();
|
||||
rechazar(CANCELADO);
|
||||
}
|
||||
function quitar() {
|
||||
var i = g.cancelar.indexOf(cancelar);
|
||||
if (i !== -1) g.cancelar.splice(i, 1);
|
||||
}
|
||||
g.cancelar.push(cancelar);
|
||||
});
|
||||
}
|
||||
|
||||
/* Promise cancelable (SPEC): todo temporizador pasa por aquí.
|
||||
grupo opcional: "flujo" (por defecto) o "demo". La duración se divide
|
||||
por la velocidad elegida (0,5x / 1x / 2x). */
|
||||
WAM.espera = function (ms, grupo) { return esperaEn(grupo, ms); };
|
||||
|
||||
/* Contexto de una secuencia: fija grupo y generación al empezar. */
|
||||
function contexto(nombre) {
|
||||
var g = grupoDe(nombre);
|
||||
var gen = grupos[g].gen;
|
||||
return {
|
||||
grupo: g,
|
||||
vivo: function () { return grupos[g].gen === gen; },
|
||||
esperar: function (ms) { return esperaEn(g, ms, gen); }
|
||||
};
|
||||
}
|
||||
|
||||
var colas = { demo: Promise.resolve(), flujo: Promise.resolve() };
|
||||
|
||||
function cancelarGrupo(nombre) {
|
||||
var g = grupos[grupoDe(nombre)];
|
||||
g.gen++;
|
||||
var copia = g.cancelar.slice();
|
||||
g.cancelar = [];
|
||||
for (var i = 0; i < copia.length; i++) copia[i]();
|
||||
colas[grupoDe(nombre)] = Promise.resolve();
|
||||
}
|
||||
|
||||
function cancelarTodo() {
|
||||
var copia = cancelaciones.slice();
|
||||
cancelaciones = [];
|
||||
for (var i = 0; i < copia.length; i++) copia[i]();
|
||||
cancelarGrupo("demo");
|
||||
cancelarGrupo("flujo");
|
||||
escritores.clear();
|
||||
pintarEscribiendo();
|
||||
}
|
||||
|
||||
function escribiendo(visible) {
|
||||
if (typeof WAM.setEscribiendo === "function") WAM.setEscribiendo(visible);
|
||||
/* ————————————————— indicador "escribiendo…" (contador de referencias) ————————————————— */
|
||||
|
||||
var escritores = new Set();
|
||||
|
||||
function pintarEscribiendo() {
|
||||
if (typeof WAM.setEscribiendo === "function") WAM.setEscribiendo(escritores.size > 0);
|
||||
}
|
||||
|
||||
/* Promise cancelable: se rechaza con CANCELADO si detenerDemo() la aborta. */
|
||||
WAM.espera = function (ms) {
|
||||
return new Promise(function (resolver, rechazar) {
|
||||
var id = setTimeout(function () {
|
||||
quitar(cancelar);
|
||||
resolver();
|
||||
}, ms);
|
||||
function cancelar() {
|
||||
clearTimeout(id);
|
||||
quitar(cancelar);
|
||||
rechazar(CANCELADO);
|
||||
}
|
||||
cancelaciones.push(cancelar);
|
||||
});
|
||||
};
|
||||
function empezarEscribir() {
|
||||
var ficha = {};
|
||||
escritores.add(ficha);
|
||||
pintarEscribiendo();
|
||||
return ficha;
|
||||
}
|
||||
|
||||
function dejarEscribir(ficha) {
|
||||
if (escritores.delete(ficha)) pintarEscribiendo();
|
||||
}
|
||||
|
||||
async function escribirDurante(ctx, ms) {
|
||||
var ficha = empezarEscribir();
|
||||
try { await ctx.esperar(ms); } finally { dejarEscribir(ficha); }
|
||||
}
|
||||
|
||||
/* ————————————————— estado de la demo ————————————————— */
|
||||
|
||||
var demoAndando = false;
|
||||
|
||||
WAM.demoActiva = function () { return demoAndando; };
|
||||
|
||||
@@ -56,23 +149,83 @@ window.WAM = window.WAM || {};
|
||||
}
|
||||
}
|
||||
|
||||
/* ————————————————— alta de mensajes (mutación + guardar + DOM incremental) ————————————————— */
|
||||
/* ————————————————— búsqueda de mensajes y filas ————————————————— */
|
||||
|
||||
function anadir(msg) {
|
||||
WAM.estado.mensajes.push(msg);
|
||||
WAM.guardar();
|
||||
if (typeof WAM.anadirMensajeDOM === "function") WAM.anadirMensajeDOM(msg);
|
||||
function cuerpoChat() { return document.getElementById("chat-cuerpo"); }
|
||||
|
||||
function selectorId(id) {
|
||||
return '.wa-row[data-id="' + String(id).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"]';
|
||||
}
|
||||
|
||||
function mensajeOut(texto) {
|
||||
return {
|
||||
id: WAM.generarId(),
|
||||
function filaDe(msg) {
|
||||
var cuerpo = cuerpoChat();
|
||||
if (!cuerpo || !msg || typeof msg.id !== "string" || !msg.id) return null;
|
||||
var filas = cuerpo.querySelectorAll(selectorId(msg.id));
|
||||
return filas.length ? filas[filas.length - 1] : null;
|
||||
}
|
||||
|
||||
function buscarPorId(id) {
|
||||
if (!id) return null;
|
||||
var guion = (WAM.estado && WAM.estado.mensajes) || [];
|
||||
for (var i = 0; i < guion.length; i++) if (guion[i] && guion[i].id === id) return guion[i];
|
||||
for (var j = WAM.sesion.length - 1; j >= 0; j--) if (WAM.sesion[j] && WAM.sesion[j].id === id) return WAM.sesion[j];
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Mensaje de la hoja de lista abierta (guion o sesión). */
|
||||
var hojaMsg = null;
|
||||
|
||||
/* Mensaje al que pertenece un elemento clicable del chat o de la hoja.
|
||||
Se resuelve por el data-id de su fila (inmune a índices obsoletos y
|
||||
válido para mensajes de sesión); el índice data-flujo queda de respaldo. */
|
||||
WAM.mensajeDeElemento = function (el) {
|
||||
if (!el || !el.closest) return null;
|
||||
var hoja = el.closest("#sheet");
|
||||
if (hoja) return hojaMsg;
|
||||
var fila = el.closest(".wa-row[data-id]");
|
||||
var msg = fila ? buscarPorId(fila.getAttribute("data-id")) : null;
|
||||
if (msg) return msg;
|
||||
var indice = parseInt(el.getAttribute("data-flujo"), 10);
|
||||
var guion = (WAM.estado && WAM.estado.mensajes) || [];
|
||||
return isNaN(indice) ? null : (guion[indice] || null);
|
||||
};
|
||||
|
||||
/* Abre la hoja de lista de un mensaje (del guion o de la sesión). */
|
||||
WAM.abrirLista = function (msg) {
|
||||
if (!msg || typeof WAM.abrirSheet !== "function") return;
|
||||
hojaMsg = msg;
|
||||
var guion = (WAM.estado && WAM.estado.mensajes) || [];
|
||||
var i = guion.indexOf(msg);
|
||||
WAM.abrirSheet(i >= 0 ? i : msg);
|
||||
};
|
||||
|
||||
/* ————————————————— pintado incremental ————————————————— */
|
||||
|
||||
/* grupo / cola (.grupo / .ultimo) los recalcula anadirMensajeDOM según el DOM. */
|
||||
function pintar(msg) {
|
||||
if (typeof WAM.anadirMensajeDOM !== "function") return null;
|
||||
WAM.anadirMensajeDOM(msg);
|
||||
var fila = filaDe(msg);
|
||||
if (fila && msg.cita) pintarCita(fila, msg.cita);
|
||||
return fila;
|
||||
}
|
||||
|
||||
function anadirSesion(msg) {
|
||||
WAM.sesion.push(msg);
|
||||
return pintar(msg);
|
||||
}
|
||||
|
||||
function mensajeOut(texto, cita) {
|
||||
var msg = {
|
||||
id: idSesion(),
|
||||
dir: "out",
|
||||
tipo: "text",
|
||||
hora: WAM.horaAhora(),
|
||||
estado: "sent",
|
||||
texto: String(texto == null ? "" : texto)
|
||||
};
|
||||
if (cita) msg.cita = cita;
|
||||
return msg;
|
||||
}
|
||||
|
||||
/* Copia una respuesta anidada (Mensaje completo) para poder reutilizarla
|
||||
@@ -88,68 +241,188 @@ window.WAM = window.WAM || {};
|
||||
}
|
||||
if (!copia || typeof copia !== "object" || Array.isArray(copia)) copia = { tipo: "text", texto: "" };
|
||||
if (TIPOS_VALIDOS.indexOf(copia.tipo) === -1) copia.tipo = "text";
|
||||
copia.id = WAM.generarId();
|
||||
copia.id = idSesion();
|
||||
copia.dir = "in";
|
||||
copia.hora = WAM.horaAhora();
|
||||
copia.estado = "sent";
|
||||
delete copia.cita;
|
||||
return copia;
|
||||
}
|
||||
|
||||
/* Ticks automáticos sent → delivered → read sobre la burbuja OUT indicada.
|
||||
Se localiza por data-id del msg (no "la última fila") para que dos envíos
|
||||
solapados avancen cada uno su propio tick. */
|
||||
/* ————————————————— cita de la respuesta (quoted reply) ————————————————— */
|
||||
|
||||
function vacio(v) { return v === undefined || v === null || String(v).trim() === ""; }
|
||||
|
||||
function sinFormato(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/```([\s\S]*?)```/g, "$1")
|
||||
.replace(/`([^`\n]+)`/g, "$1")
|
||||
.replace(/\*([^*\n]+)\*/g, "$1")
|
||||
.replace(/(^|[^\w])_([^_\n]+)_(?=[^\w]|$)/g, "$1$2")
|
||||
.replace(/(^|[^\w])~([^~\n]+)~(?=[^\w]|$)/g, "$1$2")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function miniaturaSegura(u) {
|
||||
if (vacio(u)) return "";
|
||||
var s = String(u);
|
||||
if (/^data:video\//i.test(s)) return "";
|
||||
if (typeof WAM.urlSegura === "function" && !WAM.urlSegura(s, "media")) return "";
|
||||
return s;
|
||||
}
|
||||
|
||||
/* { nombre, titulo, extracto, miniatura, foto } del mensaje original. */
|
||||
function citaDe(msg, opciones) {
|
||||
if (!msg) return null;
|
||||
var cuenta = (WAM.estado && WAM.estado.cuenta) || {};
|
||||
var cita = {
|
||||
nombre: vacio(cuenta.nombre) ? "Chat" : String(cuenta.nombre),
|
||||
titulo: "",
|
||||
extracto: "",
|
||||
miniatura: "",
|
||||
foto: false
|
||||
};
|
||||
var op = opciones || {};
|
||||
var partes = [];
|
||||
if (msg.tipo === "list") {
|
||||
cita.titulo = sinFormato(msg.header);
|
||||
partes = [msg.body, msg.footer];
|
||||
} else if (msg.tipo === "carousel") {
|
||||
var card = (msg.cards || [])[op.card] || {};
|
||||
partes = [vacio(card.texto) ? msg.body : card.texto];
|
||||
cita.miniatura = miniaturaSegura(card.media);
|
||||
if (!cita.miniatura && vacio(card.media) && typeof WAM.svgPlaceholder === "function") {
|
||||
cita.miniatura = WAM.svgPlaceholder("");
|
||||
}
|
||||
cita.foto = true;
|
||||
} else {
|
||||
if (msg.headerTipo === "texto") cita.titulo = sinFormato(msg.headerTexto);
|
||||
partes = [msg.body];
|
||||
if (msg.headerTipo === "imagen") {
|
||||
cita.miniatura = vacio(msg.headerMedia)
|
||||
? (typeof WAM.svgPlaceholder === "function" ? WAM.svgPlaceholder("") : "")
|
||||
: miniaturaSegura(msg.headerMedia);
|
||||
cita.foto = true;
|
||||
} else if (msg.headerTipo === "video") {
|
||||
cita.foto = true;
|
||||
}
|
||||
}
|
||||
cita.extracto = partes.filter(function (p) { return !vacio(p); }).map(sinFormato).join("\n");
|
||||
if (!cita.extracto && !cita.titulo) cita.extracto = cita.foto ? "Foto" : "Mensaje";
|
||||
return cita;
|
||||
}
|
||||
|
||||
var SVG_CAMARA = '<svg class="wa-cita-cam" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">' +
|
||||
'<path fill="currentColor" d="M5.6 2.5h4.8l1 1.5H13a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 13 12.5H3A1.5 1.5 0 0 1 1.5 11V5.5A1.5 1.5 0 0 1 3 4h1.6l1-1.5Zm2.4 3a2.75 2.75 0 1 0 0 5.5 2.75 2.75 0 0 0 0-5.5Z"/></svg>';
|
||||
|
||||
/* Bloque de cita dentro de la burbuja OUT; todo por textContent (sin XSS). */
|
||||
function pintarCita(fila, cita) {
|
||||
var burbuja = fila.querySelector(".bubble");
|
||||
if (!burbuja || burbuja.querySelector(".wa-cita")) return;
|
||||
var caja = document.createElement("div");
|
||||
caja.className = "wa-cita";
|
||||
var txt = document.createElement("div");
|
||||
txt.className = "wa-cita-txt";
|
||||
var nombre = document.createElement("div");
|
||||
nombre.className = "wa-cita-nombre";
|
||||
nombre.textContent = cita.nombre;
|
||||
txt.appendChild(nombre);
|
||||
if (cita.titulo) {
|
||||
var tit = document.createElement("div");
|
||||
tit.className = "wa-cita-titulo";
|
||||
tit.textContent = cita.titulo;
|
||||
txt.appendChild(tit);
|
||||
}
|
||||
if (cita.extracto) {
|
||||
var ext = document.createElement("div");
|
||||
ext.className = "wa-cita-extracto";
|
||||
if (cita.foto) {
|
||||
var cam = document.createElement("span");
|
||||
cam.innerHTML = SVG_CAMARA; /* constante propia, sin datos del usuario */
|
||||
if (cam.firstChild) ext.appendChild(cam.firstChild);
|
||||
}
|
||||
ext.appendChild(document.createTextNode(cita.extracto));
|
||||
txt.appendChild(ext);
|
||||
}
|
||||
caja.appendChild(txt);
|
||||
if (cita.miniatura) {
|
||||
var img = document.createElement("img");
|
||||
img.className = "wa-cita-mini";
|
||||
img.alt = "";
|
||||
img.draggable = false;
|
||||
img.src = cita.miniatura;
|
||||
caja.appendChild(img);
|
||||
}
|
||||
burbuja.insertBefore(caja, burbuja.firstChild);
|
||||
burbuja.classList.add("wa-con-cita");
|
||||
}
|
||||
|
||||
/* ————————————————— ticks automáticos ————————————————— */
|
||||
|
||||
/* Ticks sent → delivered → read sobre la burbuja OUT indicada (por data-id,
|
||||
para que dos envíos solapados avancen cada uno su propio tick). */
|
||||
function repintarTickDe(msg, estado) {
|
||||
var cuerpo = document.getElementById("chat-cuerpo");
|
||||
if (!cuerpo || typeof WAM.tick !== "function") return;
|
||||
if (!msg || typeof msg.id !== "string" || !msg.id) return;
|
||||
var seguro = msg.id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
var fila = cuerpo.querySelector('.wa-row[data-id="' + seguro + '"]');
|
||||
if (typeof WAM.tick !== "function") return;
|
||||
var fila = filaDe(msg);
|
||||
if (!fila || !fila.classList.contains("out")) return;
|
||||
var tick = fila.querySelector(".tick");
|
||||
if (tick && tick.parentNode) {
|
||||
/* WAM.tick devuelve un <svg> completo: se REEMPLAZA el nodo para no
|
||||
anidar un svg dentro de otro (se recortaría el segundo tick). */
|
||||
/* WAM.tick devuelve un <svg> completo: se REEMPLAZA el nodo. */
|
||||
var aux = document.createElement("span");
|
||||
aux.innerHTML = WAM.tick(estado);
|
||||
if (aux.firstChild) tick.parentNode.replaceChild(aux.firstChild, tick);
|
||||
}
|
||||
}
|
||||
|
||||
async function animarTics(msg) {
|
||||
await WAM.espera(800);
|
||||
msg.estado = "delivered";
|
||||
WAM.guardar();
|
||||
repintarTickDe(msg, "delivered");
|
||||
await WAM.espera(800);
|
||||
msg.estado = "read";
|
||||
WAM.guardar();
|
||||
repintarTickDe(msg, "read");
|
||||
/* Los mensajes de sesión no se guardan; si se cancelan los ticks, la
|
||||
burbuja salta a "read" para no quedar congelada en ✓ / ✓✓ gris. */
|
||||
async function animarTics(msg, ctx) {
|
||||
try {
|
||||
await ctx.esperar(800);
|
||||
msg.estado = "delivered";
|
||||
repintarTickDe(msg, "delivered");
|
||||
await ctx.esperar(800);
|
||||
msg.estado = "read";
|
||||
repintarTickDe(msg, "read");
|
||||
} catch (e) {
|
||||
if (msg.estado !== "read") {
|
||||
msg.estado = "read";
|
||||
repintarTickDe(msg, "read");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/* ————————————————— flujos ————————————————— */
|
||||
|
||||
/* Burbuja OUT con el texto (ticks auto), typing ~900 ms y, por cada respuesta,
|
||||
typing ~900 ms + anadirMensajeDOM (con scroll). Cancelable por detenerDemo(). */
|
||||
WAM.flujo = function (texto, respuestas) {
|
||||
/* Respuestas encadenadas: typing ~900 ms + burbuja IN por cada una.
|
||||
En la demo (enDemo) cada respuesta con autoPulsar se pulsa sola. */
|
||||
async function responder(lista, ctx, enDemo) {
|
||||
for (var i = 0; i < lista.length; i++) {
|
||||
await escribirDurante(ctx, 900);
|
||||
if (!ctx.vivo()) throw CANCELADO;
|
||||
var r = mensajeDesde(lista[i]);
|
||||
anadirSesion(r);
|
||||
if (enDemo) await autoPulsar(r, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/* Burbuja OUT con el texto (y cita opcional {nombre, extracto, miniatura}),
|
||||
ticks auto y respuestas. La burbuja sale al instante; las respuestas de
|
||||
flujos simultáneos se encolan para no intercalarse. Cancelable. */
|
||||
WAM.flujo = function (texto, respuestas, cita) {
|
||||
var lista = Array.isArray(respuestas) ? respuestas : [];
|
||||
var secuencia = (async function () {
|
||||
try {
|
||||
var out = mensajeOut(texto);
|
||||
anadir(out);
|
||||
await animarTics(out);
|
||||
for (var i = 0; i < lista.length; i++) {
|
||||
escribiendo(true);
|
||||
await WAM.espera(900);
|
||||
escribiendo(false);
|
||||
anadir(mensajeDesde(lista[i]));
|
||||
}
|
||||
escribiendo(false);
|
||||
} catch (e) {
|
||||
escribiendo(false);
|
||||
if (e !== CANCELADO) { /* el mockup nunca ensucia la consola */ }
|
||||
}
|
||||
})();
|
||||
return secuencia;
|
||||
var ctx = contexto("flujo");
|
||||
var out = mensajeOut(texto, cita || null);
|
||||
anadirSesion(out);
|
||||
var tics = animarTics(out, ctx);
|
||||
tics.catch(nada);
|
||||
var turno = colas.flujo.then(function () {
|
||||
if (!ctx.vivo()) throw CANCELADO;
|
||||
return tics.then(function () { return responder(lista, ctx, false); });
|
||||
});
|
||||
colas.flujo = turno.catch(nada);
|
||||
return turno.catch(nada);
|
||||
};
|
||||
|
||||
/* Envío libre del composer: burbuja OUT con hora actual y ticks automáticos. */
|
||||
@@ -157,69 +430,213 @@ window.WAM = window.WAM || {};
|
||||
var limpio = String(texto == null ? "" : texto).trim();
|
||||
if (!limpio) return;
|
||||
var out = mensajeOut(limpio);
|
||||
anadir(out);
|
||||
var tics = animarTics(out);
|
||||
if (typeof tics.catch === "function") tics.catch(function () { /* cancelado */ });
|
||||
anadirSesion(out);
|
||||
animarTics(out, contexto("flujo")).catch(nada);
|
||||
};
|
||||
|
||||
/* Resuelve { texto, respuestas } desde los data-* que estampa render.js:
|
||||
data-flujo (índice del mensaje) + data-btn (template/buttons),
|
||||
data-sec + data-fila (lista) o data-card (carousel). */
|
||||
/* Resuelve { texto, respuestas, cita, msg, tipo } desde los data-* que
|
||||
estampa render.js: data-btn (template/buttons), data-sec + data-fila
|
||||
(lista) o data-card (carousel). Etiquetas vacías = las del render. */
|
||||
WAM.resolverFlujo = function (el) {
|
||||
var vacio = { texto: "", respuestas: [] };
|
||||
if (!el || typeof el.getAttribute !== "function") return vacio;
|
||||
var indice = parseInt(el.getAttribute("data-flujo"), 10);
|
||||
var mensajes = (WAM.estado && WAM.estado.mensajes) ? WAM.estado.mensajes : [];
|
||||
var msg = isNaN(indice) ? null : mensajes[indice];
|
||||
if (!msg) return vacio;
|
||||
var vacioR = { texto: "", respuestas: [], cita: null, msg: null, tipo: "" };
|
||||
if (!el || typeof el.getAttribute !== "function") return vacioR;
|
||||
var msg = WAM.mensajeDeElemento(el);
|
||||
if (!msg) return vacioR;
|
||||
var ds = el.dataset || {};
|
||||
|
||||
if (ds.sec !== undefined || ds.fila !== undefined) {
|
||||
var seccion = (msg.secciones || [])[parseInt(ds.sec, 10)];
|
||||
var fila = ((seccion && seccion.filas) || [])[parseInt(ds.fila, 10)];
|
||||
return fila ? { texto: fila.titulo || "", respuestas: fila.respuestas || [] } : vacio;
|
||||
if (!fila) return vacioR;
|
||||
var titulo = vacio(fila.titulo) ? "Opción" : String(fila.titulo);
|
||||
return {
|
||||
texto: titulo + (vacio(fila.descripcion) ? "" : "\n" + fila.descripcion),
|
||||
respuestas: fila.respuestas || [],
|
||||
cita: citaDe(msg),
|
||||
msg: msg,
|
||||
tipo: "fila"
|
||||
};
|
||||
}
|
||||
if (ds.btn !== undefined) {
|
||||
var boton = (msg.botones || [])[parseInt(ds.btn, 10)];
|
||||
return boton ? { texto: boton.texto || "", respuestas: boton.respuestas || [] } : vacio;
|
||||
var n = parseInt(ds.btn, 10);
|
||||
var boton = (msg.botones || [])[n];
|
||||
if (!boton) return vacioR;
|
||||
return {
|
||||
texto: vacio(boton.texto) ? "Botón " + (n + 1) : String(boton.texto),
|
||||
respuestas: boton.respuestas || [],
|
||||
cita: citaDe(msg),
|
||||
msg: msg,
|
||||
tipo: "boton"
|
||||
};
|
||||
}
|
||||
if (ds.card !== undefined) {
|
||||
var card = (msg.cards || [])[parseInt(ds.card, 10)];
|
||||
return card ? { texto: card.textoBoton || "", respuestas: card.respuestas || [] } : vacio;
|
||||
var c = parseInt(ds.card, 10);
|
||||
var card = (msg.cards || [])[c];
|
||||
if (!card) return vacioR;
|
||||
return {
|
||||
texto: vacio(card.textoBoton) ? "Ver más" : String(card.textoBoton),
|
||||
respuestas: card.respuestas || [],
|
||||
cita: citaDe(msg, { card: c }),
|
||||
msg: msg,
|
||||
tipo: "card"
|
||||
};
|
||||
}
|
||||
/* Elemento plano de un mensaje list: es el botón que abre la sheet
|
||||
(main.js lo detecta y llama WAM.abrirSheet); sin flujo directo. */
|
||||
return vacio;
|
||||
/* Elemento plano de un mensaje list: es el botón que abre la hoja. */
|
||||
return vacioR;
|
||||
};
|
||||
|
||||
/* Deja el botón pulsado en gris y sin respuesta a más clics (como iOS). */
|
||||
WAM.marcarUsado = function (el) {
|
||||
if (!el || !el.classList) return;
|
||||
el.classList.add("usado");
|
||||
el.setAttribute("aria-disabled", "true");
|
||||
el.setAttribute("tabindex", "-1");
|
||||
};
|
||||
|
||||
/* Guarda la sesión actual como parte del guion (acción explícita). */
|
||||
WAM.sesionAGuion = function () {
|
||||
if (!WAM.sesion.length || !WAM.estado) return 0;
|
||||
var tomar = typeof WAM.crearAlocador === "function" ? WAM.crearAlocador() : WAM.generarId;
|
||||
var nuevos = WAM.sesion.map(function (m) {
|
||||
var copia = JSON.parse(JSON.stringify(m));
|
||||
delete copia.cita;
|
||||
(function reasignar(o) {
|
||||
if (!o || typeof o !== "object") return;
|
||||
if (Array.isArray(o)) { o.forEach(reasignar); return; }
|
||||
if (typeof o.id === "string") o.id = tomar();
|
||||
["respuestas", "botones", "secciones", "filas", "cards"].forEach(function (k) { reasignar(o[k]); });
|
||||
})(copia);
|
||||
return copia;
|
||||
});
|
||||
var n = nuevos.length;
|
||||
WAM.estado = WAM.normalizar ? WAM.normalizar({
|
||||
version: WAM.estado.version, tema: WAM.estado.tema, cuenta: WAM.estado.cuenta,
|
||||
mensajes: WAM.estado.mensajes.concat(nuevos)
|
||||
}) : WAM.estado;
|
||||
if (typeof WAM.guardar === "function") WAM.guardar();
|
||||
if (typeof WAM.render === "function") WAM.render();
|
||||
if (typeof WAM.renderEditor === "function") WAM.renderEditor();
|
||||
return n;
|
||||
};
|
||||
|
||||
/* ————————————————— reproducción de la demo ————————————————— */
|
||||
|
||||
function pulsacion(el, ctx) {
|
||||
el.classList.add("wam-pulsando");
|
||||
return ctx.esperar(260).then(function () { el.classList.remove("wam-pulsando"); },
|
||||
function (e) { el.classList.remove("wam-pulsando"); throw e; });
|
||||
}
|
||||
|
||||
function centrarCard(el) {
|
||||
var car = el.closest(".carousel");
|
||||
var card = el.closest(".card");
|
||||
if (!car || !card) return;
|
||||
var delta = card.getBoundingClientRect().left - car.getBoundingClientRect().left;
|
||||
try { car.scrollTo({ left: car.scrollLeft + delta - 8, behavior: "smooth" }); }
|
||||
catch (e) { car.scrollLeft += delta - 8; }
|
||||
}
|
||||
|
||||
/* Pulsa sola la opción msg.autoPulsar (índice de botón, card o fila
|
||||
aplanada de la lista) 1 s después de que aparezca el mensaje. */
|
||||
async function autoPulsar(msg, ctx) {
|
||||
if (!msg || ["template", "buttons", "list", "carousel"].indexOf(msg.tipo) === -1) return;
|
||||
var idx = msg.autoPulsar;
|
||||
if (idx === null || idx === undefined || idx === "") return;
|
||||
idx = Number(idx);
|
||||
if (!isFinite(idx) || idx < 0) return;
|
||||
var fila = filaDe(msg);
|
||||
if (!fila) return;
|
||||
await ctx.esperar(1000);
|
||||
var el = null;
|
||||
|
||||
if (msg.tipo === "list") {
|
||||
var s, f, cuenta = 0, destino = null;
|
||||
var secs = msg.secciones || [];
|
||||
for (s = 0; s < secs.length && !destino; s++) {
|
||||
var filas = (secs[s] && secs[s].filas) || [];
|
||||
for (f = 0; f < filas.length; f++) {
|
||||
if (cuenta === idx) { destino = [s, f]; break; }
|
||||
cuenta++;
|
||||
}
|
||||
}
|
||||
if (!destino) return;
|
||||
var abridor = fila.querySelector("[data-flujo]:not([data-btn]):not([data-card])");
|
||||
if (abridor) await pulsacion(abridor, ctx);
|
||||
WAM.abrirLista(msg);
|
||||
await ctx.esperar(900);
|
||||
var hoja = document.getElementById("sheet");
|
||||
el = hoja ? hoja.querySelector('[data-sec="' + destino[0] + '"][data-fila="' + destino[1] + '"]') : null;
|
||||
if (!el) { if (WAM.cerrarSheet) WAM.cerrarSheet(); return; }
|
||||
await pulsacion(el, ctx);
|
||||
var selL = WAM.resolverFlujo(el);
|
||||
if (WAM.cerrarSheet) WAM.cerrarSheet();
|
||||
await flujoEnDemo(selL, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
el = fila.querySelector(msg.tipo === "carousel" ? '[data-card="' + idx + '"]' : '[data-btn="' + idx + '"]');
|
||||
if (!el) return;
|
||||
if (el.hidden) {
|
||||
var plegado = fila.querySelector("[data-ver-todas]");
|
||||
if (plegado) { plegado.click(); await ctx.esperar(400); }
|
||||
/* B abre la hoja "Todas las opciones" (render.js): se pulsa su fila */
|
||||
var enHoja = document.querySelector('#sheet [data-opcion="' + idx + '"]');
|
||||
if (!enHoja) { if (WAM.cerrarSheet) WAM.cerrarSheet(); return; }
|
||||
await pulsacion(enHoja, ctx);
|
||||
if (WAM.cerrarSheet) WAM.cerrarSheet();
|
||||
}
|
||||
if (msg.tipo === "carousel") { centrarCard(el); await ctx.esperar(500); }
|
||||
await pulsacion(el, ctx);
|
||||
var sel = WAM.resolverFlujo(el);
|
||||
WAM.marcarUsado(el);
|
||||
await flujoEnDemo(sel, ctx);
|
||||
}
|
||||
|
||||
async function flujoEnDemo(sel, ctx) {
|
||||
if (!sel || !sel.msg) return;
|
||||
var out = mensajeOut(sel.texto, sel.cita);
|
||||
anadirSesion(out);
|
||||
await animarTics(out, ctx);
|
||||
await responder(Array.isArray(sel.respuestas) ? sel.respuestas : [], ctx, true);
|
||||
}
|
||||
|
||||
function vaciarChat() {
|
||||
var cuerpo = cuerpoChat();
|
||||
if (!cuerpo) return;
|
||||
var filas = cuerpo.querySelectorAll(".wa-row");
|
||||
for (var i = 0; i < filas.length; i++) {
|
||||
if (filas[i].id !== "chat-escribiendo") cuerpo.removeChild(filas[i]);
|
||||
}
|
||||
}
|
||||
|
||||
WAM.reproducirDemo = function () {
|
||||
if (demoAndando) return;
|
||||
/* aborta flujos, ticks y typing en curso: no se mezclan con la demo */
|
||||
cancelarTodo();
|
||||
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet();
|
||||
hojaMsg = null;
|
||||
WAM.sesion.length = 0;
|
||||
demoAndando = true;
|
||||
avisarDemo();
|
||||
var cuerpo = document.getElementById("chat-cuerpo");
|
||||
if (cuerpo) cuerpo.innerHTML = "";
|
||||
vaciarChat();
|
||||
var ctx = contexto("demo");
|
||||
var secuencia = ((WAM.estado && WAM.estado.mensajes) || []).slice();
|
||||
(async function () {
|
||||
try {
|
||||
for (var i = 0; i < secuencia.length; i++) {
|
||||
var msg = secuencia[i];
|
||||
if (!msg) continue;
|
||||
if (msg.dir === "in") {
|
||||
escribiendo(true);
|
||||
await WAM.espera(700);
|
||||
escribiendo(false);
|
||||
}
|
||||
if (typeof WAM.anadirMensajeDOM === "function") WAM.anadirMensajeDOM(msg);
|
||||
await WAM.espera(600);
|
||||
if (msg.dir === "in") await escribirDurante(ctx, 700);
|
||||
if (!ctx.vivo()) throw CANCELADO;
|
||||
pintar(msg);
|
||||
await ctx.esperar(600);
|
||||
await autoPulsar(msg, ctx);
|
||||
}
|
||||
demoAndando = false;
|
||||
escribiendo(false);
|
||||
avisarDemo();
|
||||
} catch (e) {
|
||||
/* cancelado: detenerDemo() / render() ya dejaron el chat coherente */
|
||||
}
|
||||
if (ctx.vivo()) {
|
||||
demoAndando = false;
|
||||
escribiendo(false);
|
||||
avisarDemo();
|
||||
}
|
||||
})();
|
||||
@@ -228,9 +645,38 @@ window.WAM = window.WAM || {};
|
||||
WAM.detenerDemo = function () {
|
||||
var estaba = demoAndando;
|
||||
demoAndando = false;
|
||||
cancelarTodo(); /* aborta también flujos y typing en curso */
|
||||
escribiendo(false);
|
||||
if (estaba && typeof WAM.render === "function") WAM.render();
|
||||
if (estaba) avisarDemo();
|
||||
cancelarGrupo("demo"); /* solo el grupo de la demo */
|
||||
if (estaba) {
|
||||
/* vuelve al guion completo (y descarta la sesión: ver reiniciarInteraccion) */
|
||||
if (typeof WAM.render === "function") WAM.render();
|
||||
avisarDemo();
|
||||
}
|
||||
};
|
||||
|
||||
/* Vuelve al guion: cancela todo, vacía la sesión y cierra la hoja. */
|
||||
function reiniciarInteraccion() {
|
||||
var estaba = demoAndando;
|
||||
demoAndando = false;
|
||||
cancelarTodo();
|
||||
WAM.sesion.length = 0;
|
||||
hojaMsg = null;
|
||||
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet();
|
||||
if (estaba) avisarDemo();
|
||||
}
|
||||
|
||||
WAM.reiniciarChat = function () {
|
||||
if (typeof WAM.render === "function") WAM.render();
|
||||
};
|
||||
|
||||
/* WAM.render() repinta el guion entero: antes se descarta la sesión y se
|
||||
cancelan demo y flujos para que nada añada filas sobre el repintado
|
||||
(evita data-id duplicados y la hoja con índices obsoletos). */
|
||||
if (typeof WAM.render === "function" && !WAM.render.__wamEnvuelto) {
|
||||
var renderBase = WAM.render;
|
||||
WAM.render = function () {
|
||||
reiniciarInteraccion();
|
||||
return renderBase.apply(this, arguments);
|
||||
};
|
||||
WAM.render.__wamEnvuelto = true;
|
||||
}
|
||||
})();
|
||||
|
||||
+175
-26
@@ -1,7 +1,8 @@
|
||||
/* WAM · main.js — dueño D (núcleo).
|
||||
Bootstrap: carga el estado, aplica tema, renderiza chat y editor,
|
||||
y enlaza la delegación de clics del chat, el composer, la reproducción
|
||||
de la demo y la toolbar. Único script con ejecución al cargar. */
|
||||
de la demo (velocidad, reiniciar chat) y la toolbar. Único script con
|
||||
ejecución al cargar. */
|
||||
window.WAM = window.WAM || {};
|
||||
(function () {
|
||||
"use strict";
|
||||
@@ -15,23 +16,97 @@ window.WAM = window.WAM || {};
|
||||
if (el) el.addEventListener(evento, manejador);
|
||||
}
|
||||
|
||||
/* ————————————————— aviso visible (toast) ————————————————— */
|
||||
|
||||
/* Aviso flotante accesible. Estilos por CSSOM (compatibles con una CSP
|
||||
style-src 'self'); no depende de ningún CSS ajeno. */
|
||||
var avisoTimer = null;
|
||||
WAM.avisar = function (texto, opciones) {
|
||||
var op = opciones || {};
|
||||
var caja = porId("wam-aviso");
|
||||
if (!caja) {
|
||||
caja = document.createElement("div");
|
||||
caja.id = "wam-aviso";
|
||||
caja.setAttribute("role", "alert");
|
||||
caja.setAttribute("aria-live", "assertive");
|
||||
var s = caja.style;
|
||||
s.position = "fixed";
|
||||
s.left = "50%";
|
||||
s.bottom = "20px";
|
||||
s.transform = "translateX(-50%)";
|
||||
s.zIndex = "9999";
|
||||
s.maxWidth = "min(560px, calc(100vw - 32px))";
|
||||
s.padding = "12px 40px 12px 16px";
|
||||
s.borderRadius = "12px";
|
||||
s.font = "500 14px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif";
|
||||
s.boxShadow = "0 8px 28px rgba(0,0,0,.28)";
|
||||
var cerrar = document.createElement("button");
|
||||
cerrar.type = "button";
|
||||
cerrar.setAttribute("aria-label", "Cerrar aviso");
|
||||
cerrar.textContent = "×";
|
||||
var c = cerrar.style;
|
||||
c.position = "absolute";
|
||||
c.top = "6px";
|
||||
c.right = "8px";
|
||||
c.border = "0";
|
||||
c.background = "transparent";
|
||||
c.color = "inherit";
|
||||
c.font = "600 20px/1 Arial, sans-serif";
|
||||
c.cursor = "pointer";
|
||||
cerrar.addEventListener("click", function () { caja.hidden = true; });
|
||||
var txt = document.createElement("span");
|
||||
txt.className = "wam-aviso-texto";
|
||||
caja.appendChild(txt);
|
||||
caja.appendChild(cerrar);
|
||||
document.body.appendChild(caja);
|
||||
}
|
||||
var error = op.tipo !== "info";
|
||||
caja.style.background = error ? "#b3261e" : "#1f2c33";
|
||||
caja.style.color = "#fff";
|
||||
caja.querySelector(".wam-aviso-texto").textContent = String(texto == null ? "" : texto);
|
||||
caja.hidden = false;
|
||||
if (avisoTimer) clearTimeout(avisoTimer);
|
||||
avisoTimer = null;
|
||||
if (op.duracion) {
|
||||
avisoTimer = setTimeout(function () { caja.hidden = true; }, op.duracion);
|
||||
}
|
||||
};
|
||||
|
||||
/* ————————————————— botón "Reproducir demo" ————————————————— */
|
||||
|
||||
function pintarBotonDemo() {
|
||||
var btn = porId("btn-reproducir");
|
||||
if (!btn) return;
|
||||
var activa = WAM.demoActiva();
|
||||
btn.setAttribute("aria-pressed", activa ? "true" : "false");
|
||||
var span = btn.querySelector("span");
|
||||
if (!span) return;
|
||||
if (!span.dataset.etiqueta) span.dataset.etiqueta = span.textContent;
|
||||
span.textContent = WAM.demoActiva() ? "■ Detener" : span.dataset.etiqueta;
|
||||
span.textContent = activa ? "■ Detener" : span.dataset.etiqueta;
|
||||
}
|
||||
|
||||
/* Evita que un doble clic arranque y detenga la demo en el mismo gesto. */
|
||||
var ultimoReproducir = 0;
|
||||
function alReproducir() {
|
||||
var ahora = Date.now();
|
||||
if (ahora - ultimoReproducir < 350) return;
|
||||
ultimoReproducir = ahora;
|
||||
if (WAM.demoActiva()) WAM.detenerDemo();
|
||||
else WAM.reproducirDemo();
|
||||
pintarBotonDemo();
|
||||
}
|
||||
|
||||
function alReiniciarChat() {
|
||||
if (typeof WAM.reiniciarChat === "function") WAM.reiniciarChat();
|
||||
else WAM.render();
|
||||
pintarBotonDemo();
|
||||
}
|
||||
|
||||
function alVelocidad(e) {
|
||||
var v = WAM.setVelocidad(e.currentTarget.value);
|
||||
e.currentTarget.value = String(v);
|
||||
}
|
||||
|
||||
/* ————————————————— toolbar ————————————————— */
|
||||
|
||||
function alTema() {
|
||||
@@ -50,19 +125,25 @@ window.WAM = window.WAM || {};
|
||||
}
|
||||
|
||||
function reRenderTotal() {
|
||||
WAM.setTema(WAM.estado.tema);
|
||||
WAM.render();
|
||||
WAM.renderEditor();
|
||||
paso(function () { WAM.setTema(WAM.estado.tema); });
|
||||
paso(function () { WAM.render(); });
|
||||
paso(function () { WAM.renderEditor(); });
|
||||
pintarBotonDemo();
|
||||
}
|
||||
|
||||
function alExportar() {
|
||||
if (typeof WAM.guardarYa === "function") WAM.guardarYa();
|
||||
if (typeof WAM.exportarJSON === "function") WAM.exportarJSON();
|
||||
}
|
||||
|
||||
function alImportar() {
|
||||
var archivo = porId("importar-archivo");
|
||||
if (archivo) archivo.click();
|
||||
var abrir = function () {
|
||||
var archivo = porId("importar-archivo");
|
||||
if (archivo) archivo.click();
|
||||
};
|
||||
/* Hook opcional de js/ux.js: <dialog> con «Exportar antes» */
|
||||
if (typeof WAM.confirmarReemplazo === "function") { WAM.confirmarReemplazo("importar", abrir); return; }
|
||||
abrir();
|
||||
}
|
||||
|
||||
function alArchivoImport(e) {
|
||||
@@ -71,15 +152,29 @@ window.WAM = window.WAM || {};
|
||||
input.value = ""; /* permite reimportar el mismo archivo */
|
||||
if (!archivo || typeof WAM.importarJSON !== "function") return;
|
||||
WAM.detenerDemo();
|
||||
var promesa = WAM.importarJSON(archivo);
|
||||
var promesa;
|
||||
try { promesa = WAM.importarJSON(archivo); } catch (err) { promesa = Promise.reject(err); }
|
||||
if (promesa && typeof promesa.then === "function") {
|
||||
promesa.then(reRenderTotal, reRenderTotal);
|
||||
promesa.then(reRenderTotal, function () {
|
||||
reRenderTotal();
|
||||
WAM.avisar("No se pudo importar: el archivo no es un JSON válido de la demo.", { duracion: 6000 });
|
||||
});
|
||||
} else {
|
||||
reRenderTotal();
|
||||
}
|
||||
}
|
||||
|
||||
function alReset() {
|
||||
var ejecutar = function () {
|
||||
WAM.detenerDemo();
|
||||
WAM.resetear();
|
||||
reRenderTotal();
|
||||
};
|
||||
/* Hook opcional de js/ux.js: <dialog> con «Exportar antes» + deshacer */
|
||||
if (typeof WAM.confirmarReemplazo === "function") { WAM.confirmarReemplazo("reset", ejecutar); return; }
|
||||
var nombre = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "la demo";
|
||||
if (!window.confirm("¿Restablecer la demo «Café Aurora»? Se perderán los cambios no exportados de «" +
|
||||
nombre + "».")) return;
|
||||
WAM.detenerDemo();
|
||||
WAM.resetear();
|
||||
reRenderTotal();
|
||||
@@ -87,39 +182,76 @@ window.WAM = window.WAM || {};
|
||||
|
||||
/* ————————————————— chat: delegación de clics (contrato B→D) ————————————————— */
|
||||
|
||||
/* Enlaces del chat con esquema no permitido (javascript:, data:…) no se abren. */
|
||||
function bloquearEnlaceInseguro(e) {
|
||||
var a = (e.target && e.target.closest) ? e.target.closest("a[href]") : null;
|
||||
if (!a) return false;
|
||||
var href = a.getAttribute("href");
|
||||
if (typeof WAM.urlSegura === "function" && !WAM.urlSegura(href, "enlace")) {
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function alClicChat(e) {
|
||||
if (bloquearEnlaceInseguro(e)) return;
|
||||
var objetivo = e.target;
|
||||
var el = (objetivo && objetivo.closest) ? objetivo.closest("[data-flujo]") : null;
|
||||
if (!el) return;
|
||||
e.preventDefault(); /* protege frente a botones montados sobre <a href="#"> */
|
||||
var ds = el.dataset || {};
|
||||
if (ds.fila !== undefined) return; /* filas del sheet: se atienden en #pantalla */
|
||||
if (el.classList.contains("usado") || el.getAttribute("aria-disabled") === "true") return;
|
||||
|
||||
var indice = parseInt(el.getAttribute("data-flujo"), 10);
|
||||
var msg = (!isNaN(indice) && WAM.estado && WAM.estado.mensajes) ? WAM.estado.mensajes[indice] : null;
|
||||
var plano = ds.btn === undefined && ds.sec === undefined &&
|
||||
ds.fila === undefined && ds.card === undefined;
|
||||
var msg = WAM.mensajeDeElemento(el);
|
||||
var plano = ds.btn === undefined && ds.sec === undefined && ds.card === undefined;
|
||||
|
||||
if (plano && msg && msg.tipo === "list") {
|
||||
if (typeof WAM.abrirSheet === "function") WAM.abrirSheet(indice);
|
||||
if (plano) {
|
||||
if (msg && msg.tipo === "list") WAM.abrirLista(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
var flujo = WAM.resolverFlujo(el);
|
||||
if (flujo && flujo.texto) WAM.flujo(flujo.texto, flujo.respuestas);
|
||||
if (!flujo || !flujo.msg) return;
|
||||
WAM.marcarUsado(el); /* gris + bloquea el doble clic */
|
||||
WAM.flujo(flujo.texto, flujo.respuestas, flujo.cita);
|
||||
}
|
||||
|
||||
/* Opciones del sheet de lista (vive en #pantalla, fuera de #chat-cuerpo). */
|
||||
/* Opciones y cierre de la hoja de lista (vive en #pantalla, fuera de #chat-cuerpo). */
|
||||
function alClicPantalla(e) {
|
||||
var objetivo = e.target;
|
||||
var fila = (objetivo && objetivo.closest) ? objetivo.closest("[data-fila]") : null;
|
||||
if (!fila) return;
|
||||
if (!objetivo || !objetivo.closest) return;
|
||||
var hoja = porId("sheet");
|
||||
if (!hoja || !hoja.contains(fila)) return;
|
||||
if (!hoja || !hoja.contains(objetivo)) return;
|
||||
if (objetivo.closest(".sheet-cerrar")) {
|
||||
e.preventDefault();
|
||||
WAM.cerrarSheet();
|
||||
return;
|
||||
}
|
||||
var fila = objetivo.closest("[data-fila]");
|
||||
if (!fila) return;
|
||||
e.preventDefault();
|
||||
var flujo = WAM.resolverFlujo(fila);
|
||||
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet();
|
||||
if (flujo && flujo.texto) WAM.flujo(flujo.texto, flujo.respuestas);
|
||||
WAM.cerrarSheet();
|
||||
if (flujo && flujo.msg) WAM.flujo(flujo.texto, flujo.respuestas, flujo.cita);
|
||||
}
|
||||
|
||||
/* Teclado: Enter / Espacio activan los botones del teléfono (span role=button);
|
||||
Escape cierra la hoja de lista. */
|
||||
function alTeclaPantalla(e) {
|
||||
if (e.key === "Escape") {
|
||||
if (porId("sheet")) { e.preventDefault(); WAM.cerrarSheet(); }
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return;
|
||||
var t = e.target;
|
||||
if (!t || !t.closest || t.tagName === "A" || t.tagName === "BUTTON" ||
|
||||
t.tagName === "INPUT" || t.tagName === "TEXTAREA") return;
|
||||
var el = t.closest("[data-flujo],[data-ver-todas],.sheet-cerrar");
|
||||
if (!el || el !== t) return;
|
||||
e.preventDefault();
|
||||
el.click();
|
||||
}
|
||||
|
||||
/* ————————————————— composer ————————————————— */
|
||||
@@ -137,17 +269,34 @@ window.WAM = window.WAM || {};
|
||||
|
||||
/* ————————————————— bootstrap ————————————————— */
|
||||
|
||||
/* Un fallo en un módulo (render o editor) no debe dejar sin enlazar el
|
||||
resto: cada paso del arranque se aísla. */
|
||||
function paso(fn) {
|
||||
try { fn(); } catch (e) {
|
||||
setTimeout(function () { throw e; }, 0); /* visible en consola, sin cortar init */
|
||||
}
|
||||
}
|
||||
|
||||
WAM.init = function () {
|
||||
WAM.estado = WAM.cargar();
|
||||
WAM.setTema(WAM.estado.tema);
|
||||
WAM.render();
|
||||
WAM.renderEditor();
|
||||
WAM.onErrorGuardar = function (texto) { WAM.avisar(texto); };
|
||||
paso(function () { WAM.estado = WAM.cargar(); });
|
||||
if (!WAM.estado) WAM.estado = WAM.normalizar(WAM.datosDemo());
|
||||
paso(function () { WAM.setTema(WAM.estado.tema); });
|
||||
paso(function () { WAM.render(); });
|
||||
paso(function () { WAM.renderEditor(); });
|
||||
|
||||
WAM.onDemoChange = pintarBotonDemo;
|
||||
al("chat-cuerpo", "click", alClicChat);
|
||||
al("pantalla", "click", alClicPantalla);
|
||||
al("pantalla", "keydown", alTeclaPantalla);
|
||||
al("composer", "submit", alComposer);
|
||||
al("btn-reproducir", "click", alReproducir);
|
||||
al("btn-reiniciar", "click", alReiniciarChat);
|
||||
var vel = porId("sel-velocidad");
|
||||
if (vel) {
|
||||
vel.value = String(WAM.getVelocidad());
|
||||
vel.addEventListener("change", alVelocidad);
|
||||
}
|
||||
al("btn-tema", "click", alTema);
|
||||
al("btn-presentacion", "click", alPresentacion);
|
||||
al("btn-exportar", "click", alExportar);
|
||||
|
||||
+404
-311
File diff suppressed because it is too large
Load Diff
+212
-48
@@ -116,12 +116,60 @@ window.WAM = window.WAM || {};
|
||||
};
|
||||
}
|
||||
|
||||
/* IDs entregados por generarId() que aún pueden no estar en WAM.estado
|
||||
(p. ej. un clon con respuestas anidadas que se inserta al final): así dos
|
||||
llamadas seguidas nunca devuelven el mismo ID. Se vacía al sustituir el
|
||||
estado entero (cargar / resetear / normalizar de un import). */
|
||||
var entregados = new Set();
|
||||
|
||||
WAM.generarId = function () {
|
||||
var conjunto = new Set();
|
||||
var conjunto = new Set(entregados);
|
||||
recolectarIds(WAM.estado, conjunto);
|
||||
return crearAlocador(conjunto)();
|
||||
var id = crearAlocador(conjunto)();
|
||||
entregados.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
/* Alocador por lotes: devuelve una función tomar() que genera IDs únicos
|
||||
frente a WAM.estado, a los ya entregados y a los que ella misma reparte.
|
||||
`extra` (opcional): objeto/array cuyos id también se reservan. */
|
||||
WAM.crearAlocador = function (extra) {
|
||||
var conjunto = new Set(entregados);
|
||||
recolectarIds(WAM.estado, conjunto);
|
||||
if (extra) recolectarIds(extra, conjunto);
|
||||
var tomar = crearAlocador(conjunto);
|
||||
return function () {
|
||||
var id = tomar();
|
||||
entregados.add(id);
|
||||
return id;
|
||||
};
|
||||
};
|
||||
|
||||
/* ————————————————— URLs seguras ————————————————— */
|
||||
|
||||
/* true si la URL puede ir a un href (tipo "enlace") o a un src ("media").
|
||||
Enlaces: http(s), mailto, tel o sin esquema (relativa / "www…").
|
||||
Media: http(s), blob, data:image|video|audio o sin esquema.
|
||||
Rechaza javascript:, vbscript:, data:text/html, file:, etc. */
|
||||
WAM.urlSegura = function (u, tipo) {
|
||||
var s = String(u == null ? "" : u).replace(/[\u0000- \u007f-\u009f]/g, "");
|
||||
if (!s) return true;
|
||||
var m = /^([a-z][a-z0-9+.\-]*):/i.exec(s);
|
||||
if (!m) return true;
|
||||
var esquema = m[1].toLowerCase();
|
||||
if (esquema === "http" || esquema === "https") return true;
|
||||
if (tipo === "media") {
|
||||
if (esquema === "blob") return true;
|
||||
return esquema === "data" && /^data:(image|video|audio)\//i.test(s);
|
||||
}
|
||||
return esquema === "mailto" || esquema === "tel";
|
||||
};
|
||||
|
||||
function urlLimpia(v, tipo) {
|
||||
var s = cad(v).trim();
|
||||
return WAM.urlSegura(s, tipo) ? s : "";
|
||||
}
|
||||
|
||||
/* ————————————————— mensajes ————————————————— */
|
||||
|
||||
var CAMPOS = {
|
||||
@@ -159,65 +207,109 @@ window.WAM = window.WAM || {};
|
||||
|
||||
/* ————————————————— normalización ————————————————— */
|
||||
|
||||
/* Rellena los campos que faltan por tipo y devuelve un estado válido.
|
||||
Usado por cargar() y por editor.importarJSON (import / upgrade). */
|
||||
WAM.normalizar = function (o) {
|
||||
var src = (o && typeof o === "object" && !Array.isArray(o)) ? o : {};
|
||||
/* Profundidad máxima de respuestas encadenadas (defensa ante JSON hostil). */
|
||||
var MAX_PROFUNDIDAD = 12;
|
||||
var MAX_ITEMS = 10;
|
||||
var CLAVES_PROHIBIDAS = ["__proto__", "constructor", "prototype"];
|
||||
var RE_ID = /^[A-Za-z0-9_-]{1,40}$/;
|
||||
|
||||
/* Índice que la reproducción "pulsa" sola (entero ≥ 0) o null. */
|
||||
function indiceAuto(v) {
|
||||
if (v === null || v === undefined || v === "" || typeof v === "boolean") return null;
|
||||
var n = typeof v === "number" ? v : Number(String(v).trim());
|
||||
if (!isFinite(n) || n < 0 || Math.floor(n) !== n) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function lista(v, max) {
|
||||
return (Array.isArray(v) ? v : []).slice(0, max || MAX_ITEMS);
|
||||
}
|
||||
|
||||
/* Rellena los campos que faltan por tipo y devuelve un estado válido.
|
||||
Usado por cargar(), resetear() y editor.importarJSON (import / upgrade).
|
||||
- Acepta también un array de mensajes suelto como raíz.
|
||||
- IDs: conserva los válidos y únicos; duplicados o inválidos se reasignan.
|
||||
No reserva los IDs del WAM.estado vivo (el estado se sustituye entero).
|
||||
- URLs peligrosas (javascript:, data:text/html…) se vacían.
|
||||
- Respuestas en forma de cadena → mensaje de texto.
|
||||
- Conserva campos extra primitivos desconocidos (compatibilidad hacia
|
||||
delante con campos que añada el editor). */
|
||||
WAM.normalizar = function (o) {
|
||||
var src = Array.isArray(o) ? { mensajes: o }
|
||||
: ((o && typeof o === "object") ? o : {});
|
||||
|
||||
entregados = new Set();
|
||||
var conjunto = new Set();
|
||||
recolectarIds(src, conjunto);
|
||||
recolectarIds(WAM.estado, conjunto); /* evita colisiones con lo vivo */
|
||||
var tomar = crearAlocador(conjunto);
|
||||
var reclamados = new Set();
|
||||
|
||||
function normalizarMensaje(msj, forzarEntrada) {
|
||||
var m = (msj && typeof msj === "object") ? msj : {};
|
||||
function idDe(v) {
|
||||
if (typeof v === "number" && isFinite(v)) v = String(v);
|
||||
if (typeof v === "string" && RE_ID.test(v) && !reclamados.has(v)) {
|
||||
reclamados.add(v);
|
||||
return v;
|
||||
}
|
||||
var nuevo = tomar();
|
||||
reclamados.add(nuevo);
|
||||
return nuevo;
|
||||
}
|
||||
|
||||
function normalizarMensaje(msj, forzarEntrada, profundidad) {
|
||||
var m;
|
||||
if (typeof msj === "string") m = { tipo: "text", texto: msj };
|
||||
else m = (msj && typeof msj === "object" && !Array.isArray(msj)) ? msj : {};
|
||||
var tipo = TIPOS.indexOf(m.tipo) !== -1 ? m.tipo : "text";
|
||||
|
||||
var msg = {
|
||||
id: (typeof m.id === "string" && m.id) ? m.id : tomar(),
|
||||
id: idDe(m.id),
|
||||
dir: (!forzarEntrada && m.dir === "out") ? "out" : "in",
|
||||
tipo: tipo,
|
||||
hora: (typeof m.hora === "string" && m.hora) ? m.hora : WAM.horaAhora(),
|
||||
hora: (typeof m.hora === "string" && m.hora.trim()) ? m.hora.trim().slice(0, 12) : WAM.horaAhora(),
|
||||
estado: (m.estado === "delivered" || m.estado === "read") ? m.estado : "sent"
|
||||
};
|
||||
|
||||
function respuestas(valor) {
|
||||
var lista = Array.isArray(valor) ? valor : [];
|
||||
return lista.map(function (r) { return normalizarMensaje(r, true); });
|
||||
if (profundidad >= MAX_PROFUNDIDAD) return [];
|
||||
return lista(valor, 20).map(function (r) { return normalizarMensaje(r, true, profundidad + 1); });
|
||||
}
|
||||
|
||||
function botones(valor) {
|
||||
var lista = Array.isArray(valor) ? valor : [];
|
||||
return lista.map(function (b) {
|
||||
var boton = (b && typeof b === "object") ? b : {};
|
||||
return lista(valor).map(function (b) {
|
||||
var boton = (b && typeof b === "object") ? b : { texto: typeof b === "string" ? b : "" };
|
||||
var out = { texto: cad(boton.texto), respuestas: respuestas(boton.respuestas) };
|
||||
/* Botón URL de plantilla (research-api §7.2): se conserva si viene */
|
||||
if (typeof boton.url === "string") out.url = boton.url;
|
||||
if (typeof boton.url === "string") out.url = urlLimpia(boton.url, "enlace");
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function autoPulsar() {
|
||||
var n = indiceAuto(m.autoPulsar);
|
||||
if (n !== null) msg.autoPulsar = n;
|
||||
}
|
||||
|
||||
switch (tipo) {
|
||||
case "text":
|
||||
msg.texto = cad(m.texto);
|
||||
break;
|
||||
case "image":
|
||||
case "video":
|
||||
msg.src = cad(m.src);
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
msg.caption = cad(m.caption);
|
||||
break;
|
||||
case "audio":
|
||||
msg.src = cad(m.src);
|
||||
msg.voz = m.voz === true;
|
||||
msg.duracion = cad(m.duracion, "0:00");
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
msg.voz = m.voz === true || m.voz === "true";
|
||||
msg.duracion = cad(m.duracion, "0:00") || "0:00";
|
||||
break;
|
||||
case "document":
|
||||
msg.src = cad(m.src);
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
msg.caption = cad(m.caption);
|
||||
msg.filename = cad(m.filename, "documento.pdf");
|
||||
msg.filename = cad(m.filename, "documento.pdf") || "documento.pdf";
|
||||
break;
|
||||
case "sticker":
|
||||
msg.src = cad(m.src);
|
||||
msg.src = urlLimpia(m.src, "media");
|
||||
break;
|
||||
case "location":
|
||||
msg.latitud = cad(m.latitud);
|
||||
@@ -232,32 +324,26 @@ window.WAM = window.WAM || {};
|
||||
msg.empresa = cad(m.empresa);
|
||||
break;
|
||||
case "template":
|
||||
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
|
||||
msg.headerTexto = cad(m.headerTexto);
|
||||
msg.headerMedia = cad(m.headerMedia);
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.botones = botones(m.botones);
|
||||
break;
|
||||
case "buttons":
|
||||
msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : "";
|
||||
msg.headerTexto = cad(m.headerTexto);
|
||||
msg.headerMedia = cad(m.headerMedia);
|
||||
msg.headerMedia = urlLimpia(m.headerMedia, "media");
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.botones = botones(m.botones);
|
||||
autoPulsar();
|
||||
break;
|
||||
case "list":
|
||||
msg.header = cad(m.header);
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.boton = cad(m.boton);
|
||||
msg.secciones = (Array.isArray(m.secciones) ? m.secciones : []).map(function (s) {
|
||||
msg.secciones = lista(m.secciones).map(function (s) {
|
||||
var seccion = (s && typeof s === "object") ? s : {};
|
||||
return {
|
||||
titulo: cad(seccion.titulo),
|
||||
filas: (Array.isArray(seccion.filas) ? seccion.filas : []).map(function (f) {
|
||||
var fila = (f && typeof f === "object") ? f : {};
|
||||
filas: lista(seccion.filas).map(function (f) {
|
||||
var fila = (f && typeof f === "object") ? f : { titulo: typeof f === "string" ? f : "" };
|
||||
return {
|
||||
titulo: cad(fila.titulo),
|
||||
descripcion: cad(fila.descripcion),
|
||||
@@ -266,28 +352,39 @@ window.WAM = window.WAM || {};
|
||||
})
|
||||
};
|
||||
});
|
||||
autoPulsar();
|
||||
break;
|
||||
case "cta_url":
|
||||
msg.headerMedia = cad(m.headerMedia);
|
||||
msg.headerMedia = urlLimpia(m.headerMedia, "media");
|
||||
msg.body = cad(m.body);
|
||||
msg.footer = cad(m.footer);
|
||||
msg.textoBoton = cad(m.textoBoton);
|
||||
msg.url = cad(m.url);
|
||||
msg.url = urlLimpia(m.url, "enlace");
|
||||
break;
|
||||
case "carousel":
|
||||
msg.body = cad(m.body);
|
||||
msg.cards = (Array.isArray(m.cards) ? m.cards : []).map(function (c) {
|
||||
msg.cards = lista(m.cards).map(function (c) {
|
||||
var card = (c && typeof c === "object") ? c : {};
|
||||
return {
|
||||
media: cad(card.media),
|
||||
media: urlLimpia(card.media, "media"),
|
||||
texto: cad(card.texto),
|
||||
textoBoton: cad(card.textoBoton),
|
||||
url: cad(card.url),
|
||||
url: urlLimpia(card.url, "enlace"),
|
||||
respuestas: respuestas(card.respuestas)
|
||||
};
|
||||
});
|
||||
autoPulsar();
|
||||
break;
|
||||
}
|
||||
|
||||
/* Campos extra primitivos (p. ej. headerFilename) se conservan. */
|
||||
for (var k in m) {
|
||||
if (!Object.prototype.hasOwnProperty.call(m, k)) continue;
|
||||
if (Object.prototype.hasOwnProperty.call(msg, k) || k === "autoPulsar") continue;
|
||||
if (CLAVES_PROHIBIDAS.indexOf(k) !== -1 || k === "cita") continue;
|
||||
var v = m[k];
|
||||
if (typeof v === "string" || typeof v === "boolean" || (typeof v === "number" && isFinite(v))) msg[k] = v;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -297,18 +394,48 @@ window.WAM = window.WAM || {};
|
||||
tema: src.tema === "oscuro" ? "oscuro" : "claro",
|
||||
cuenta: {
|
||||
nombre: cad(cuentaSrc.nombre, "Café Aurora"),
|
||||
avatar: cad(cuentaSrc.avatar),
|
||||
avatar: urlLimpia(cuentaSrc.avatar, "media"),
|
||||
subtitulo: cad(cuentaSrc.subtitulo, "en línea"),
|
||||
verificado: cuentaSrc.verificado === true
|
||||
verificado: cuentaSrc.verificado === true || cuentaSrc.verificado === "true",
|
||||
/* B: fecha "Hoy" + aviso de cifrado al inicio del chat (por defecto sí) */
|
||||
avisoCifrado: !(cuentaSrc.avisoCifrado === false || cuentaSrc.avisoCifrado === "false")
|
||||
},
|
||||
mensajes: (Array.isArray(src.mensajes) ? src.mensajes : []).map(function (x) {
|
||||
return normalizarMensaje(x, false);
|
||||
mensajes: lista(src.mensajes, 500).map(function (x) {
|
||||
return normalizarMensaje(x, false, 0);
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
/* ————————————————— persistencia ————————————————— */
|
||||
|
||||
/* Aviso de error de guardado: se emite UNA vez hasta el siguiente guardado
|
||||
correcto, por el hook opcional WAM.onErrorGuardar(texto, error). */
|
||||
var avisoEmitido = false;
|
||||
var ultimoOk = true;
|
||||
|
||||
function esCuota(e) {
|
||||
return !!e && (e.name === "QuotaExceededError" || e.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
|
||||
e.code === 22 || e.code === 1014);
|
||||
}
|
||||
|
||||
function escribir() {
|
||||
try {
|
||||
window.localStorage.setItem(WAM.CLAVE, JSON.stringify(WAM.estado));
|
||||
avisoEmitido = false;
|
||||
ultimoOk = true;
|
||||
} catch (e) {
|
||||
ultimoOk = false;
|
||||
if (avisoEmitido) return;
|
||||
avisoEmitido = true;
|
||||
var texto = esCuota(e)
|
||||
? "No se pudo guardar: el almacenamiento del navegador está lleno (imágenes muy pesadas). Exporta la demo a JSON para no perder los cambios."
|
||||
: "No se pudo guardar en este navegador (almacenamiento local bloqueado). Exporta la demo a JSON para no perder los cambios.";
|
||||
if (typeof WAM.onErrorGuardar === "function") {
|
||||
try { WAM.onErrorGuardar(texto, e); } catch (e2) { /* hook opcional */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WAM.cargar = function () {
|
||||
var bruto = null;
|
||||
try { bruto = window.localStorage.getItem(WAM.CLAVE); } catch (e) { bruto = null; }
|
||||
@@ -316,20 +443,57 @@ window.WAM = window.WAM || {};
|
||||
if (bruto) {
|
||||
try { dato = JSON.parse(bruto); } catch (e) { dato = null; }
|
||||
}
|
||||
if (!dato) dato = WAM.datosDemo();
|
||||
if (!dato || typeof dato !== "object") dato = WAM.datosDemo();
|
||||
WAM.estado = null;
|
||||
WAM.estado = WAM.normalizar(dato);
|
||||
return WAM.estado;
|
||||
};
|
||||
|
||||
/* Guardado con coalescencia: la primera llamada escribe al instante; las
|
||||
que llegan en los 250 ms siguientes (p. ej. cada tecla del editor) se
|
||||
agrupan en una sola escritura final. pagehide/visibilitychange vacían
|
||||
lo pendiente para no perder nada al recargar o cerrar. */
|
||||
var VENTANA = 250;
|
||||
var temporizador = null;
|
||||
var pendiente = false;
|
||||
|
||||
WAM.guardar = function () {
|
||||
try {
|
||||
window.localStorage.setItem(WAM.CLAVE, JSON.stringify(WAM.estado));
|
||||
} catch (e) { /* sin localStorage disponible (cuota/file://): se ignora */ }
|
||||
if (temporizador) { pendiente = true; return; }
|
||||
escribir();
|
||||
temporizador = setTimeout(function fin() {
|
||||
temporizador = null;
|
||||
if (pendiente) {
|
||||
pendiente = false;
|
||||
escribir();
|
||||
temporizador = setTimeout(fin, VENTANA);
|
||||
}
|
||||
}, VENTANA);
|
||||
};
|
||||
|
||||
/* Escritura inmediata de lo pendiente (true si quedó guardado). */
|
||||
WAM.guardarYa = function () {
|
||||
if (temporizador) { clearTimeout(temporizador); temporizador = null; }
|
||||
pendiente = false;
|
||||
escribir();
|
||||
return ultimoOk;
|
||||
};
|
||||
|
||||
function vaciarPendiente() { if (pendiente) WAM.guardarYa(); }
|
||||
if (typeof window !== "undefined" && window.addEventListener) {
|
||||
window.addEventListener("pagehide", vaciarPendiente);
|
||||
window.addEventListener("beforeunload", vaciarPendiente);
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.visibilityState === "hidden") vaciarPendiente();
|
||||
});
|
||||
}
|
||||
|
||||
WAM.resetear = function () {
|
||||
if (temporizador) { clearTimeout(temporizador); temporizador = null; }
|
||||
pendiente = false;
|
||||
try { window.localStorage.removeItem(WAM.CLAVE); } catch (e) { /* idem */ }
|
||||
WAM.estado = null; /* los IDs de la demo vuelven a empezar en m1 */
|
||||
WAM.estado = WAM.normalizar(WAM.datosDemo());
|
||||
avisoEmitido = false;
|
||||
};
|
||||
|
||||
/* ————————————————— demo por defecto: "Café Aurora" ————————————————— */
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
/* =====================================================================
|
||||
js/ux.js — Capa de experiencia de la app (editor, C). No toca el DOM
|
||||
al cargar: WAM.initUX() se ejecuta una sola vez desde WAM.renderEditor().
|
||||
· Historial deshacer / rehacer (WAM.puntoDeshacer / deshacer / rehacer)
|
||||
· Indicador de guardado en la toolbar (#estado-guardado)
|
||||
· Confirmación de Restablecer / Importar (WAM.confirmarReemplazo)
|
||||
· Diálogo «Demos»: escenarios por industria + demos guardadas (wam-demos)
|
||||
· Atajos de teclado globales y diálogo de ayuda (?)
|
||||
· Pestañas Editor / Vista previa en ventana estrecha y menú «⋯»
|
||||
· Enlace vista previa → tarjeta (Alt+clic o doble clic en una burbuja)
|
||||
· Región sr-only que anuncia los mensajes nuevos del chat
|
||||
===================================================================== */
|
||||
(function () {
|
||||
"use strict";
|
||||
window.WAM = window.WAM || {};
|
||||
|
||||
var LIMITE_HISTORIAL = 30;
|
||||
var CLAVE_DEMOS = "wam-demos";
|
||||
var PREFIJO_DEMO = "wam-demo-";
|
||||
var CLAVE_ACTIVA = "wam-demo-activa";
|
||||
var CLAVE_VISTA = "wam-ui-vista";
|
||||
var MQ_ESTRECHA = "(max-width: 900px)";
|
||||
|
||||
var pilaDeshacer = [];
|
||||
var pilaRehacer = [];
|
||||
var iniciado = false;
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Utilidades
|
||||
------------------------------------------------------------------ */
|
||||
function porId(id) { return document.getElementById(id); }
|
||||
|
||||
function h(etiqueta, attrs, hijos) {
|
||||
var el = document.createElement(etiqueta);
|
||||
Object.keys(attrs || {}).forEach(function (k) {
|
||||
var v = attrs[k];
|
||||
if (v === null || v === undefined || v === false) return;
|
||||
if (k === "class") el.className = v;
|
||||
else if (k === "text") el.textContent = v;
|
||||
else if (k === "value") el.value = v;
|
||||
else el.setAttribute(k, v === true ? "" : v);
|
||||
});
|
||||
(Array.isArray(hijos) ? hijos : (hijos ? [hijos] : [])).forEach(function (x) {
|
||||
if (x) el.appendChild(typeof x === "string" ? document.createTextNode(x) : x);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function toast(texto, opts) {
|
||||
if (typeof WAM.toast === "function") return WAM.toast(texto, opts);
|
||||
return function () {};
|
||||
}
|
||||
|
||||
function lsGet(k) { try { return window.localStorage.getItem(k); } catch (e) { return null; } }
|
||||
function lsSet(k, v) { try { window.localStorage.setItem(k, v); return true; } catch (e) { return false; } }
|
||||
function lsDel(k) { try { window.localStorage.removeItem(k); } catch (e) { /* nada */ } }
|
||||
|
||||
function esEstrecha() {
|
||||
return !!(window.matchMedia && window.matchMedia(MQ_ESTRECHA).matches);
|
||||
}
|
||||
|
||||
function enCampoTexto(t) {
|
||||
if (!t || !t.tagName) return false;
|
||||
if (t.isContentEditable) return true;
|
||||
if (t.tagName === "TEXTAREA" || t.tagName === "SELECT") return true;
|
||||
if (t.tagName === "INPUT") {
|
||||
var tipo = (t.type || "text").toLowerCase();
|
||||
return ["checkbox", "radio", "button", "submit", "reset", "file", "range", "color"].indexOf(tipo) === -1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function esInteractivo(t) {
|
||||
if (!t || !t.closest) return false;
|
||||
return !!t.closest("button, a[href], input, textarea, select, summary, [role='button'], [role='tab'], [tabindex]:not([tabindex='-1'])");
|
||||
}
|
||||
|
||||
function nMensajes() {
|
||||
return (WAM.estado && Array.isArray(WAM.estado.mensajes)) ? WAM.estado.mensajes.length : 0;
|
||||
}
|
||||
|
||||
// Copia profunda estructural: los strings (dataURL incluidos) se comparten
|
||||
// por referencia, así 30 instantáneas no multiplican los MB en memoria.
|
||||
function clonar(v) {
|
||||
if (Array.isArray(v)) return v.map(clonar);
|
||||
if (v && typeof v === "object") {
|
||||
var o = {};
|
||||
for (var k in v) if (Object.prototype.hasOwnProperty.call(v, k)) o[k] = clonar(v[k]);
|
||||
return o;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function reRenderTotal() {
|
||||
if (typeof WAM.setTema === "function") WAM.setTema(WAM.estado.tema);
|
||||
if (typeof WAM.render === "function") WAM.render();
|
||||
if (typeof WAM.renderEditor === "function") WAM.renderEditor();
|
||||
if (typeof WAM.onDemoChange === "function") { try { WAM.onDemoChange(false); } catch (e) { /* opcional */ } }
|
||||
}
|
||||
|
||||
function guardarYa() {
|
||||
if (typeof WAM.guardarYa === "function") return WAM.guardarYa();
|
||||
if (typeof WAM.guardar === "function") WAM.guardar();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sustituye el estado entero (undo, escenario, demo guardada)
|
||||
function sustituirEstado(nuevo) {
|
||||
if (typeof WAM.demoActiva === "function" && WAM.demoActiva() && typeof WAM.detenerDemo === "function") WAM.detenerDemo();
|
||||
if (typeof WAM.cerrarSheet === "function") WAM.cerrarSheet();
|
||||
WAM.estado = nuevo;
|
||||
guardarYa();
|
||||
reRenderTotal();
|
||||
marcarEditado();
|
||||
}
|
||||
|
||||
function normalizado(crudo) {
|
||||
var previo = WAM.estado;
|
||||
WAM.estado = null; // los IDs empiezan en m1 (el estado se sustituye entero)
|
||||
try {
|
||||
return (typeof WAM.normalizar === "function") ? WAM.normalizar(crudo) : crudo;
|
||||
} finally {
|
||||
WAM.estado = previo;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Historial deshacer / rehacer
|
||||
------------------------------------------------------------------ */
|
||||
WAM.puntoDeshacer = function () {
|
||||
if (!WAM.estado) return;
|
||||
pilaDeshacer.push(clonar(WAM.estado));
|
||||
if (pilaDeshacer.length > LIMITE_HISTORIAL) pilaDeshacer.shift();
|
||||
pilaRehacer = [];
|
||||
pintarBotonesHistorial();
|
||||
};
|
||||
|
||||
WAM.puedeDeshacer = function () { return pilaDeshacer.length > 0; };
|
||||
|
||||
WAM.deshacer = function () {
|
||||
if (!pilaDeshacer.length) { toast("No hay nada que deshacer"); return false; }
|
||||
pilaRehacer.push(clonar(WAM.estado));
|
||||
sustituirEstado(pilaDeshacer.pop());
|
||||
pintarBotonesHistorial();
|
||||
toast("Cambio deshecho", { accion: { texto: "Rehacer", fn: WAM.rehacer } });
|
||||
return true;
|
||||
};
|
||||
|
||||
WAM.rehacer = function () {
|
||||
if (!pilaRehacer.length) { toast("No hay nada que rehacer"); return false; }
|
||||
pilaDeshacer.push(clonar(WAM.estado));
|
||||
sustituirEstado(pilaRehacer.pop());
|
||||
pintarBotonesHistorial();
|
||||
toast("Cambio rehecho");
|
||||
return true;
|
||||
};
|
||||
|
||||
function pintarBotonesHistorial() {
|
||||
var d = porId("btn-deshacer"), r = porId("btn-rehacer");
|
||||
if (d) d.disabled = !pilaDeshacer.length;
|
||||
if (r) r.disabled = !pilaRehacer.length;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Indicador de guardado (#estado-guardado)
|
||||
------------------------------------------------------------------ */
|
||||
var ultimoGuardado = 0;
|
||||
var tComprobar = 0;
|
||||
var errorGuardado = "";
|
||||
|
||||
function pintarGuardado(estadoTxt, clase, titulo) {
|
||||
var el = porId("estado-guardado");
|
||||
if (!el) return;
|
||||
el.setAttribute("data-estado", clase);
|
||||
var txt = el.querySelector(".tb-guardado-txt");
|
||||
if (txt) txt.textContent = estadoTxt;
|
||||
el.title = titulo || estadoTxt;
|
||||
pintarRelativo();
|
||||
}
|
||||
|
||||
function formatoTamano(bytes) {
|
||||
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1).replace(".", ",") + " MB";
|
||||
}
|
||||
|
||||
function pintarRelativo() {
|
||||
var el = porId("estado-guardado");
|
||||
var rel = el && el.querySelector(".tb-guardado-rel");
|
||||
if (!rel) return;
|
||||
if (el.getAttribute("data-estado") !== "ok" || !ultimoGuardado) { rel.textContent = ""; return; }
|
||||
var s = Math.max(0, Math.round((Date.now() - ultimoGuardado) / 1000));
|
||||
rel.textContent = s < 5 ? "· ahora" : (s < 60 ? "· hace " + s + " s" : "· hace " + Math.round(s / 60) + " min");
|
||||
}
|
||||
|
||||
function comprobarGuardado() {
|
||||
tComprobar = 0;
|
||||
var ok = guardarYa();
|
||||
if (ok === false) {
|
||||
pintarGuardado("No se pudo guardar", "error", errorGuardado || "No se pudo guardar en este navegador: exporta el JSON.");
|
||||
return;
|
||||
}
|
||||
errorGuardado = "";
|
||||
ultimoGuardado = Date.now();
|
||||
var bruto = lsGet(WAM.CLAVE || "wam-mockup-v1");
|
||||
var bytes = bruto ? bruto.length * 2 : 0; // UTF-16 en memoria del navegador
|
||||
var extra = bytes > 3 * 1024 * 1024 ? " · " + formatoTamano(bytes) : "";
|
||||
pintarGuardado("Guardado" + extra, "ok", "Guardado en este navegador" + (bytes ? " (" + formatoTamano(bytes) + ")" : "") +
|
||||
(bytes > 4 * 1024 * 1024 ? ". Te acercas al límite (~5 MB): usa URLs en vez de subir archivos pesados." : ""));
|
||||
}
|
||||
|
||||
// El editor la llama tras cada mutación: «Guardando…» y comprobación diferida
|
||||
function marcarEditado() {
|
||||
if (errorGuardado) return; // mantener el aviso en rojo hasta que vuelva a funcionar
|
||||
pintarGuardado("Guardando…", "pendiente");
|
||||
clearTimeout(tComprobar);
|
||||
tComprobar = setTimeout(comprobarGuardado, 700);
|
||||
}
|
||||
WAM.marcarEditado = marcarEditado;
|
||||
|
||||
function alErrorGuardar(texto) {
|
||||
errorGuardado = texto || "No se pudo guardar";
|
||||
pintarGuardado("No se pudo guardar", "error", errorGuardado);
|
||||
toast(errorGuardado, {
|
||||
tipo: "error", ms: 12000,
|
||||
accion: { texto: "Exportar JSON", fn: function () { if (typeof WAM.exportarJSON === "function") WAM.exportarJSON(); } }
|
||||
});
|
||||
// Reintento: al siguiente guardado correcto el indicador vuelve a verde
|
||||
setTimeout(function () { errorGuardado = ""; }, 4000);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Diálogos (<dialog> nativo; reserva con window.confirm)
|
||||
------------------------------------------------------------------ */
|
||||
var soportaDialog = typeof window.HTMLDialogElement === "function";
|
||||
|
||||
function crearDialogo(id, titulo, clase) {
|
||||
var viejo = porId(id);
|
||||
if (viejo) viejo.remove();
|
||||
var cerrar = h("button", { type: "button", class: "wam-dlg-cerrar", "aria-label": "Cerrar", text: "×" });
|
||||
var cuerpo = h("div", { class: "wam-dlg-cuerpo" });
|
||||
var pie = h("div", { class: "wam-dlg-pie" });
|
||||
var dlg = h("dialog", { id: id, class: "wam-dlg" + (clase ? " " + clase : ""), "aria-labelledby": id + "-titulo" }, [
|
||||
h("div", { class: "wam-dlg-cab" }, [h("h2", { id: id + "-titulo", class: "wam-dlg-titulo", text: titulo }), cerrar]),
|
||||
cuerpo,
|
||||
pie
|
||||
]);
|
||||
cerrar.addEventListener("click", function () { dlg.close(); });
|
||||
// Clic en el fondo (fuera de la caja) cierra
|
||||
dlg.addEventListener("click", function (e) {
|
||||
if (e.target !== dlg) return;
|
||||
var r = dlg.getBoundingClientRect();
|
||||
var dentro = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
|
||||
if (!dentro) dlg.close();
|
||||
});
|
||||
var foco = null;
|
||||
dlg.addEventListener("close", function () {
|
||||
setTimeout(function () { dlg.remove(); }, 0);
|
||||
if (foco && foco.isConnected && typeof foco.focus === "function") foco.focus();
|
||||
});
|
||||
document.body.appendChild(dlg);
|
||||
return {
|
||||
dlg: dlg, cuerpo: cuerpo, pie: pie,
|
||||
abrir: function () { foco = document.activeElement; dlg.showModal(); }
|
||||
};
|
||||
}
|
||||
|
||||
function boton(texto, clase, fn) {
|
||||
var b = h("button", { type: "button", class: "wam-dlg-btn " + (clase || ""), text: texto });
|
||||
if (fn) b.addEventListener("click", fn);
|
||||
return b;
|
||||
}
|
||||
|
||||
/* Confirmación de acciones que sustituyen la demo entera.
|
||||
tipo: "reset" | "importar". ejecutar(): la acción real. */
|
||||
WAM.confirmarReemplazo = function (tipo, ejecutar) {
|
||||
if (typeof ejecutar !== "function") return;
|
||||
var esReset = tipo === "reset";
|
||||
var hacer = function () {
|
||||
if (esReset) {
|
||||
WAM.puntoDeshacer();
|
||||
lsDel(CLAVE_ACTIVA);
|
||||
ejecutar();
|
||||
marcarEditado();
|
||||
toast("Demo de ejemplo restablecida", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
|
||||
} else {
|
||||
ejecutar(); // el punto de deshacer lo toma importarJSON al aplicar el archivo
|
||||
}
|
||||
};
|
||||
if (!nMensajes()) { hacer(); return; }
|
||||
var nombre = (WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "sin nombre";
|
||||
var resumen = "«" + nombre + "», " + nMensajes() + " mensaje" + (nMensajes() === 1 ? "" : "s");
|
||||
if (!soportaDialog) {
|
||||
if (window.confirm(esReset ? "¿Restablecer la demo de ejemplo? Se sustituirá " + resumen + "."
|
||||
: "¿Importar un JSON? Se sustituirá " + resumen + ".")) hacer();
|
||||
return;
|
||||
}
|
||||
var d = crearDialogo("dlg-confirmar", esReset ? "¿Restablecer la demo de ejemplo?" : "¿Importar una demo desde JSON?", "wam-dlg-chico");
|
||||
d.cuerpo.appendChild(h("p", { text: (esReset
|
||||
? "La demo actual (" + resumen + ") se sustituirá por la demo de ejemplo «Café Aurora»."
|
||||
: "El archivo que elijas sustituirá la demo actual (" + resumen + ").") }));
|
||||
d.cuerpo.appendChild(h("p", { class: "wam-dlg-nota", text: "Puedes exportarla antes. Mientras no recargues la página, también puedes deshacer con Ctrl+Z." }));
|
||||
var bExp = boton("Exportar antes", "", function () {
|
||||
if (typeof WAM.exportarJSON === "function") WAM.exportarJSON();
|
||||
bExp.textContent = "Exportada ✓";
|
||||
bExp.disabled = true;
|
||||
});
|
||||
var bOk = boton(esReset ? "Restablecer" : "Elegir archivo…", esReset ? "wam-dlg-peligro" : "wam-dlg-primario", function () {
|
||||
d.dlg.close();
|
||||
hacer();
|
||||
});
|
||||
d.pie.appendChild(boton("Cancelar", "", function () { d.dlg.close(); }));
|
||||
d.pie.appendChild(bExp);
|
||||
d.pie.appendChild(bOk);
|
||||
d.abrir();
|
||||
bOk.focus();
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Demos: escenarios + demos guardadas en localStorage
|
||||
------------------------------------------------------------------ */
|
||||
function indiceDemos() {
|
||||
var bruto = lsGet(CLAVE_DEMOS);
|
||||
try {
|
||||
var arr = JSON.parse(bruto || "[]");
|
||||
return Array.isArray(arr) ? arr.filter(function (x) { return x && typeof x.id === "string"; }) : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
function guardarIndice(arr) { return lsSet(CLAVE_DEMOS, JSON.stringify(arr)); }
|
||||
|
||||
function nuevoIdDemo() { return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
||||
|
||||
function demoActiva() {
|
||||
var id = lsGet(CLAVE_ACTIVA);
|
||||
if (!id) return null;
|
||||
var idx = indiceDemos();
|
||||
for (var i = 0; i < idx.length; i++) if (idx[i].id === id) return idx[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
WAM.nombreDemoActiva = function () {
|
||||
var d = demoActiva();
|
||||
return d ? d.nombre : "";
|
||||
};
|
||||
|
||||
function guardarDemoComo(nombre, id) {
|
||||
guardarYa();
|
||||
var idx = indiceDemos();
|
||||
var ahora = new Date().toISOString();
|
||||
var nuevoId = id || nuevoIdDemo();
|
||||
if (!lsSet(PREFIJO_DEMO + nuevoId, JSON.stringify(WAM.estado))) {
|
||||
toast("No se pudo guardar la demo: almacenamiento lleno. Exporta a JSON o borra demos guardadas.", { tipo: "error", ms: 9000 });
|
||||
return null;
|
||||
}
|
||||
var entrada = { id: nuevoId, nombre: nombre || "Demo sin nombre", fecha: ahora, n: nMensajes() };
|
||||
var pos = -1;
|
||||
for (var i = 0; i < idx.length; i++) if (idx[i].id === nuevoId) pos = i;
|
||||
if (pos >= 0) idx[pos] = entrada; else idx.unshift(entrada);
|
||||
guardarIndice(idx);
|
||||
lsSet(CLAVE_ACTIVA, nuevoId);
|
||||
return entrada;
|
||||
}
|
||||
|
||||
function cargarDemo(id) {
|
||||
var bruto = lsGet(PREFIJO_DEMO + id);
|
||||
var datos = null;
|
||||
try { datos = JSON.parse(bruto || "null"); } catch (e) { datos = null; }
|
||||
if (!datos) { toast("No se encontró esa demo guardada", { tipo: "error" }); return false; }
|
||||
WAM.puntoDeshacer();
|
||||
sustituirEstado(normalizado(datos));
|
||||
lsSet(CLAVE_ACTIVA, id);
|
||||
return true;
|
||||
}
|
||||
|
||||
function cargarEscenario(esc) {
|
||||
var crudo = WAM.datosEscenario ? WAM.datosEscenario(esc.id) : null;
|
||||
if (!crudo) return;
|
||||
var habiaAlgo = nMensajes() > 0;
|
||||
if (habiaAlgo) WAM.puntoDeshacer();
|
||||
sustituirEstado(normalizado(crudo));
|
||||
lsDel(CLAVE_ACTIVA);
|
||||
toast("Escenario «" + esc.nombre + "» cargado", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
|
||||
}
|
||||
|
||||
function fechaCorta(iso) {
|
||||
var d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
try {
|
||||
return d.toLocaleString("es", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
||||
} catch (e) { return d.toISOString().slice(0, 16).replace("T", " "); }
|
||||
}
|
||||
|
||||
WAM.abrirDemos = function (seccion) {
|
||||
if (!soportaDialog) { toast("Tu navegador no admite diálogos nativos", { tipo: "error" }); return; }
|
||||
var d = crearDialogo("dlg-demos", "Demos", "wam-dlg-grande");
|
||||
|
||||
// --- Escenarios ---
|
||||
var gridEsc = h("div", { class: "wam-esc-grid" });
|
||||
(WAM.ESCENARIOS || []).forEach(function (esc) {
|
||||
var n = 0;
|
||||
try { n = (esc.datos().mensajes || []).length; } catch (e) { n = 0; }
|
||||
var b = h("button", { type: "button", class: "wam-esc", "data-esc": esc.id }, [
|
||||
h("span", { class: "wam-esc-nombre", text: esc.nombre }),
|
||||
h("span", { class: "wam-esc-desc", text: esc.descripcion }),
|
||||
h("span", { class: "wam-esc-meta", text: n ? n + " mensajes" : "Vacía" })
|
||||
]);
|
||||
b.addEventListener("click", function () { d.dlg.close(); cargarEscenario(esc); });
|
||||
gridEsc.appendChild(b);
|
||||
});
|
||||
var bAurora = h("button", { type: "button", class: "wam-esc" }, [
|
||||
h("span", { class: "wam-esc-nombre", text: "Catálogo · Café Aurora" }),
|
||||
h("span", { class: "wam-esc-desc", text: "Los 13 tipos de mensaje de la Cloud API. Útil como referencia." }),
|
||||
h("span", { class: "wam-esc-meta", text: "Demo de ejemplo" })
|
||||
]);
|
||||
bAurora.addEventListener("click", function () {
|
||||
d.dlg.close();
|
||||
if (typeof WAM.datosDemo !== "function") return;
|
||||
var habiaAlgo = nMensajes() > 0;
|
||||
if (habiaAlgo) WAM.puntoDeshacer();
|
||||
sustituirEstado(normalizado(WAM.datosDemo()));
|
||||
lsDel(CLAVE_ACTIVA);
|
||||
toast("Demo «Café Aurora» cargada", habiaAlgo ? { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 } : null);
|
||||
});
|
||||
gridEsc.appendChild(bAurora);
|
||||
|
||||
var secEsc = h("section", { class: "wam-dlg-sec", "aria-labelledby": "dlg-sec-esc" }, [
|
||||
h("h3", { id: "dlg-sec-esc", class: "wam-dlg-subtitulo", text: "Empezar desde un escenario" }),
|
||||
h("p", { class: "wam-dlg-nota", text: "Sustituye la demo actual (puedes deshacer). Los textos son de ejemplo y ficticios: revísalos y adáptalos antes de enseñarlos a un cliente." }),
|
||||
gridEsc
|
||||
]);
|
||||
|
||||
// --- Demos guardadas ---
|
||||
var lista = h("ul", { class: "wam-demos-lista" });
|
||||
var activa = demoActiva();
|
||||
var inputNombre = h("input", {
|
||||
type: "text", class: "wam-dlg-input", id: "dlg-demo-nombre", maxlength: "60",
|
||||
value: activa ? activa.nombre : ((WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "Mi demo"),
|
||||
"aria-label": "Nombre de la demo"
|
||||
});
|
||||
var bGuardar = boton(activa ? "Actualizar" : "Guardar la actual", "wam-dlg-primario", function () {
|
||||
var nombre = String(inputNombre.value || "").trim() || "Mi demo";
|
||||
var misma = activa && activa.nombre === nombre;
|
||||
var e = guardarDemoComo(nombre, misma ? activa.id : null);
|
||||
if (e) { toast("Demo «" + e.nombre + "» guardada"); pintarLista(); activa = demoActiva(); }
|
||||
});
|
||||
inputNombre.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); bGuardar.click(); } });
|
||||
inputNombre.addEventListener("input", function () {
|
||||
bGuardar.textContent = (activa && activa.nombre === String(inputNombre.value).trim()) ? "Actualizar" : "Guardar como nueva";
|
||||
});
|
||||
|
||||
function pintarLista() {
|
||||
while (lista.firstChild) lista.removeChild(lista.firstChild);
|
||||
var idx = indiceDemos();
|
||||
var actId = lsGet(CLAVE_ACTIVA);
|
||||
if (!idx.length) {
|
||||
lista.appendChild(h("li", { class: "wam-demos-vacio", text: "Aún no has guardado ninguna demo. Guarda la actual para tener varias (una por cliente) sin exportar e importar." }));
|
||||
return;
|
||||
}
|
||||
idx.forEach(function (it) {
|
||||
var nombreEl = h("span", { class: "wam-demo-nombre", text: it.nombre });
|
||||
var li = h("li", { class: "wam-demo" + (it.id === actId ? " wam-demo-activa" : "") }, [
|
||||
h("div", { class: "wam-demo-info" }, [
|
||||
nombreEl,
|
||||
h("span", { class: "wam-demo-meta", text: (it.n || 0) + " mensajes · " + fechaCorta(it.fecha) + (it.id === actId ? " · en uso" : "") })
|
||||
])
|
||||
]);
|
||||
var acc = h("div", { class: "wam-demo-acc" });
|
||||
acc.appendChild(boton("Abrir", "wam-dlg-mini", function () {
|
||||
d.dlg.close();
|
||||
if (cargarDemo(it.id)) toast("Demo «" + it.nombre + "» abierta", { accion: { texto: "Deshacer", fn: WAM.deshacer }, ms: 8000 });
|
||||
}));
|
||||
acc.appendChild(boton("Renombrar", "wam-dlg-mini", function () {
|
||||
var inp = h("input", { type: "text", class: "wam-dlg-input", value: it.nombre, maxlength: "60", "aria-label": "Nuevo nombre" });
|
||||
nombreEl.replaceWith(inp);
|
||||
inp.focus(); inp.select();
|
||||
var hecho = false;
|
||||
var fin = function (guardar) {
|
||||
if (hecho) return; hecho = true;
|
||||
var v = String(inp.value || "").trim();
|
||||
if (guardar && v) {
|
||||
var arr = indiceDemos();
|
||||
arr.forEach(function (x) { if (x.id === it.id) x.nombre = v; });
|
||||
guardarIndice(arr);
|
||||
}
|
||||
pintarLista();
|
||||
};
|
||||
inp.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") { e.preventDefault(); fin(true); }
|
||||
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); fin(false); }
|
||||
});
|
||||
inp.addEventListener("blur", function () { fin(true); });
|
||||
}));
|
||||
acc.appendChild(boton("Duplicar", "wam-dlg-mini", function () {
|
||||
var bruto = lsGet(PREFIJO_DEMO + it.id);
|
||||
var nid = nuevoIdDemo();
|
||||
if (!bruto || !lsSet(PREFIJO_DEMO + nid, bruto)) { toast("No se pudo duplicar (almacenamiento lleno)", { tipo: "error" }); return; }
|
||||
var arr = indiceDemos();
|
||||
arr.unshift({ id: nid, nombre: it.nombre + " (copia)", fecha: new Date().toISOString(), n: it.n || 0 });
|
||||
guardarIndice(arr);
|
||||
pintarLista();
|
||||
}));
|
||||
var bBorrar = boton("Borrar", "wam-dlg-mini wam-dlg-peligro", function () {
|
||||
if (bBorrar.getAttribute("data-confirmar") !== "1") {
|
||||
bBorrar.setAttribute("data-confirmar", "1");
|
||||
bBorrar.textContent = "¿Seguro?";
|
||||
setTimeout(function () {
|
||||
if (bBorrar.isConnected) { bBorrar.removeAttribute("data-confirmar"); bBorrar.textContent = "Borrar"; }
|
||||
}, 3500);
|
||||
return;
|
||||
}
|
||||
lsDel(PREFIJO_DEMO + it.id);
|
||||
guardarIndice(indiceDemos().filter(function (x) { return x.id !== it.id; }));
|
||||
if (lsGet(CLAVE_ACTIVA) === it.id) lsDel(CLAVE_ACTIVA);
|
||||
activa = demoActiva();
|
||||
pintarLista();
|
||||
});
|
||||
acc.appendChild(bBorrar);
|
||||
li.appendChild(acc);
|
||||
lista.appendChild(li);
|
||||
});
|
||||
}
|
||||
pintarLista();
|
||||
|
||||
var secGuardadas = h("section", { class: "wam-dlg-sec", "aria-labelledby": "dlg-sec-guardadas" }, [
|
||||
h("h3", { id: "dlg-sec-guardadas", class: "wam-dlg-subtitulo", text: "Mis demos guardadas" }),
|
||||
h("div", { class: "wam-dlg-fila" }, [inputNombre, bGuardar]),
|
||||
lista,
|
||||
h("p", { class: "wam-dlg-nota", text: "Se guardan solo en este navegador. Para compartir una demo o hacer copia de seguridad, usa Exportar." })
|
||||
]);
|
||||
|
||||
d.cuerpo.appendChild(seccion === "guardadas" ? secGuardadas : secEsc);
|
||||
d.cuerpo.appendChild(seccion === "guardadas" ? secEsc : secGuardadas);
|
||||
d.pie.appendChild(boton("Cerrar", "", function () { d.dlg.close(); }));
|
||||
d.abrir();
|
||||
var primero = d.cuerpo.querySelector(".wam-esc, .wam-dlg-input");
|
||||
if (primero) primero.focus();
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Atajos de teclado
|
||||
------------------------------------------------------------------ */
|
||||
var ATAJOS = [
|
||||
["P", "Modo presentación"],
|
||||
["Espacio", "Reproducir / detener la demo"],
|
||||
["T", "Tema claro / oscuro"],
|
||||
["F", "Pantalla completa"],
|
||||
["N", "Demos y escenarios"],
|
||||
["Esc", "Cerrar la lista del chat o salir de la presentación"],
|
||||
["Ctrl + S", "Exportar JSON"],
|
||||
["Ctrl + Z", "Deshacer (fuera de un campo de texto)"],
|
||||
["Ctrl + Mayús + Z", "Rehacer"],
|
||||
["Alt + ↑ / ↓", "Mover el mensaje o elemento con el foco"],
|
||||
["Alt + clic en una burbuja", "Abrir su tarjeta en el editor (también doble clic)"],
|
||||
["?", "Esta ayuda"]
|
||||
];
|
||||
|
||||
WAM.abrirAtajos = function () {
|
||||
if (!soportaDialog) return;
|
||||
var d = crearDialogo("dlg-atajos", "Atajos de teclado", "wam-dlg-chico");
|
||||
var tabla = h("dl", { class: "wam-atajos" });
|
||||
ATAJOS.forEach(function (a) {
|
||||
tabla.appendChild(h("dt", {}, a[0].split(" + ").map(function (k, i, arr) {
|
||||
var frag = document.createDocumentFragment();
|
||||
frag.appendChild(h("kbd", { text: k }));
|
||||
if (i < arr.length - 1) frag.appendChild(document.createTextNode(" + "));
|
||||
return frag;
|
||||
})));
|
||||
tabla.appendChild(h("dd", { text: a[1] }));
|
||||
});
|
||||
d.cuerpo.appendChild(tabla);
|
||||
d.cuerpo.appendChild(h("p", { class: "wam-dlg-nota", text: "Los atajos de una letra no actúan mientras escribes en un campo." }));
|
||||
d.pie.appendChild(boton("Cerrar", "wam-dlg-primario", function () { d.dlg.close(); }));
|
||||
d.abrir();
|
||||
};
|
||||
|
||||
function clicEn(id) { var b = porId(id); if (b && !b.disabled) b.click(); }
|
||||
|
||||
function alternarPantallaCompleta() {
|
||||
var doc = document;
|
||||
try {
|
||||
if (doc.fullscreenElement) { var p = doc.exitFullscreen(); if (p && p.catch) p.catch(function () {}); }
|
||||
else if (doc.documentElement.requestFullscreen) {
|
||||
var q = doc.documentElement.requestFullscreen();
|
||||
if (q && q.catch) q.catch(function () { toast("El navegador no permitió la pantalla completa", { tipo: "error" }); });
|
||||
}
|
||||
} catch (e) { /* sin soporte */ }
|
||||
}
|
||||
|
||||
function sheetAbierto() {
|
||||
var s = porId("sheet");
|
||||
return !!(s && !s.hidden && s.offsetParent !== null);
|
||||
}
|
||||
|
||||
function alTecla(e) {
|
||||
if (e.defaultPrevented || e.isComposing) return;
|
||||
var k = e.key;
|
||||
var mod = e.ctrlKey || e.metaKey;
|
||||
var hayDialogo = !!document.querySelector("dialog[open]");
|
||||
|
||||
if (mod && !e.altKey && (k === "s" || k === "S")) {
|
||||
e.preventDefault();
|
||||
if (!hayDialogo && typeof WAM.exportarJSON === "function") WAM.exportarJSON();
|
||||
return;
|
||||
}
|
||||
if (hayDialogo) return; // el <dialog> gestiona Esc y el foco
|
||||
if (mod && !e.altKey && (k === "z" || k === "Z") && !enCampoTexto(e.target)) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) WAM.rehacer(); else WAM.deshacer();
|
||||
return;
|
||||
}
|
||||
if (mod && !e.altKey && (k === "y" || k === "Y") && !enCampoTexto(e.target)) {
|
||||
e.preventDefault();
|
||||
WAM.rehacer();
|
||||
return;
|
||||
}
|
||||
if (k === "Escape") {
|
||||
if (cerrarMenu()) return;
|
||||
if (sheetAbierto() && typeof WAM.cerrarSheet === "function") { WAM.cerrarSheet(); return; }
|
||||
if (document.body.classList.contains("presentacion") && !enCampoTexto(e.target)) clicEn("btn-presentacion");
|
||||
return;
|
||||
}
|
||||
if (mod || e.altKey || enCampoTexto(e.target)) return;
|
||||
switch (k) {
|
||||
case "p": case "P": e.preventDefault(); clicEn("btn-presentacion"); break;
|
||||
case "t": case "T": e.preventDefault(); clicEn("btn-tema"); break;
|
||||
case "f": case "F": e.preventDefault(); alternarPantallaCompleta(); break;
|
||||
case "n": case "N": e.preventDefault(); WAM.abrirDemos(); break;
|
||||
case "?": e.preventDefault(); WAM.abrirAtajos(); break;
|
||||
case " ":
|
||||
if (esInteractivo(e.target)) return; // Espacio activa el botón con foco
|
||||
e.preventDefault();
|
||||
clicEn("btn-reproducir");
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Menú «⋯» de la toolbar (ventana estrecha) y pestañas Editor / Vista
|
||||
------------------------------------------------------------------ */
|
||||
function cerrarMenu() {
|
||||
var tb = porId("toolbar");
|
||||
var mas = porId("btn-mas");
|
||||
if (!tb || !tb.classList.contains("tb-menu-abierto")) return false;
|
||||
tb.classList.remove("tb-menu-abierto");
|
||||
if (mas) mas.setAttribute("aria-expanded", "false");
|
||||
return true;
|
||||
}
|
||||
|
||||
function enlazarMenu() {
|
||||
var tb = porId("toolbar"), mas = porId("btn-mas"), menu = porId("tb-menu");
|
||||
if (!tb || !mas || !menu) return;
|
||||
mas.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
var abierto = tb.classList.toggle("tb-menu-abierto");
|
||||
mas.setAttribute("aria-expanded", abierto ? "true" : "false");
|
||||
if (abierto) { var b = menu.querySelector("button:not([disabled])"); if (b) b.focus(); }
|
||||
});
|
||||
menu.addEventListener("click", function (e) {
|
||||
if (e.target.closest && e.target.closest("button")) cerrarMenu();
|
||||
});
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!tb.classList.contains("tb-menu-abierto")) return;
|
||||
if (menu.contains(e.target) || mas.contains(e.target)) return;
|
||||
cerrarMenu();
|
||||
});
|
||||
}
|
||||
|
||||
var ultimoIdEditado = null;
|
||||
WAM.recordarEnfoque = function (id) { ultimoIdEditado = id || null; };
|
||||
|
||||
WAM.setVistaMovil = function (vista) {
|
||||
var previa = vista === "vista";
|
||||
document.body.classList.toggle("vista-movil", previa);
|
||||
var te = porId("tab-editor"), tv = porId("tab-vista");
|
||||
if (te) { te.setAttribute("aria-selected", previa ? "false" : "true"); te.tabIndex = previa ? -1 : 0; }
|
||||
if (tv) { tv.setAttribute("aria-selected", previa ? "true" : "false"); tv.tabIndex = previa ? 0 : -1; }
|
||||
lsSet(CLAVE_VISTA, previa ? "vista" : "editor");
|
||||
if (previa && ultimoIdEditado && typeof WAM.enfocarEnChat === "function") {
|
||||
var id = ultimoIdEditado;
|
||||
requestAnimationFrame(function () { requestAnimationFrame(function () { WAM.enfocarEnChat(id); }); });
|
||||
}
|
||||
};
|
||||
|
||||
function enlazarPestanas() {
|
||||
var te = porId("tab-editor"), tv = porId("tab-vista");
|
||||
if (te) te.addEventListener("click", function () { WAM.setVistaMovil("editor"); });
|
||||
if (tv) tv.addEventListener("click", function () { WAM.setVistaMovil("vista"); });
|
||||
[te, tv].forEach(function (t) {
|
||||
if (!t) return;
|
||||
t.addEventListener("keydown", function (e) {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
var otra = t === te ? tv : te;
|
||||
if (otra) { otra.click(); otra.focus(); }
|
||||
});
|
||||
});
|
||||
var ver = porId("btn-ver-telefono");
|
||||
if (ver) ver.addEventListener("click", function () { WAM.setVistaMovil("vista"); });
|
||||
var guardada = lsGet(CLAVE_VISTA);
|
||||
WAM.setVistaMovil(guardada === "editor" ? "editor" : "vista");
|
||||
}
|
||||
|
||||
// Altura real de la toolbar → variable CSS para el cálculo de escala
|
||||
function observarToolbar() {
|
||||
var tb = porId("toolbar");
|
||||
if (!tb) return;
|
||||
var fijar = function () {
|
||||
document.documentElement.style.setProperty("--wam-toolbar-alto", Math.ceil(tb.getBoundingClientRect().height) + "px");
|
||||
};
|
||||
fijar();
|
||||
if (typeof ResizeObserver === "function") new ResizeObserver(fijar).observe(tb);
|
||||
else window.addEventListener("resize", fijar);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Vista previa → tarjeta del editor
|
||||
------------------------------------------------------------------ */
|
||||
function editorOculto() {
|
||||
return document.body.classList.contains("presentacion");
|
||||
}
|
||||
|
||||
function abrirDesdeChat(id) {
|
||||
if (!id || editorOculto() || typeof WAM.abrirTarjeta !== "function") return false;
|
||||
if (esEstrecha()) WAM.setVistaMovil("editor");
|
||||
return WAM.abrirTarjeta(id);
|
||||
}
|
||||
|
||||
function enlazarChat() {
|
||||
var pantalla = porId("pantalla"), cuerpo = porId("chat-cuerpo");
|
||||
if (pantalla) {
|
||||
// Captura: Alt+clic no dispara el flujo del botón (lo gestiona main.js)
|
||||
pantalla.addEventListener("click", function (e) {
|
||||
if (!e.altKey) return;
|
||||
var fila = e.target.closest && e.target.closest("#chat-cuerpo .wa-row[data-id]");
|
||||
if (!fila || editorOculto()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
abrirDesdeChat(fila.getAttribute("data-id"));
|
||||
}, true);
|
||||
}
|
||||
if (cuerpo) {
|
||||
cuerpo.addEventListener("dblclick", function (e) {
|
||||
if (!e.target.closest || e.target.closest("[data-flujo], a, button, video, audio, input")) return;
|
||||
var fila = e.target.closest(".wa-row[data-id]");
|
||||
if (!fila || editorOculto()) return;
|
||||
if (window.getSelection) { try { window.getSelection().removeAllRanges(); } catch (err) { /* nada */ } }
|
||||
abrirDesdeChat(fila.getAttribute("data-id"));
|
||||
});
|
||||
// Las altas y re-render parciales no deben releer todo el chat
|
||||
cuerpo.removeAttribute("aria-live");
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Anuncios para lectores de pantalla (mensajes nuevos del chat)
|
||||
------------------------------------------------------------------ */
|
||||
function anunciar(msg) {
|
||||
var reg = porId("wam-anuncios");
|
||||
if (!reg || !msg) return;
|
||||
var nombre = (WAM.estado && WAM.estado.cuenta && WAM.estado.cuenta.nombre) || "el negocio";
|
||||
var txt = msg.texto || msg.body || msg.caption || msg.filename || msg.nombre || "";
|
||||
txt = String(txt).replace(/\s+/g, " ").slice(0, 140);
|
||||
reg.textContent = (msg.dir === "out" ? "Tú: " : "Nuevo mensaje de " + nombre + ": ") + (txt || "(" + (msg.tipo || "mensaje") + ")");
|
||||
}
|
||||
|
||||
function envolverAnuncios() {
|
||||
if (!porId("wam-anuncios")) {
|
||||
document.body.appendChild(h("div", { id: "wam-anuncios", class: "wam-sr", "aria-live": "polite", "aria-atomic": "true" }));
|
||||
}
|
||||
var orig = WAM.anadirMensajeDOM;
|
||||
if (typeof orig !== "function" || orig.__wamAnuncio) return;
|
||||
var envuelta = function (msg) {
|
||||
var r = orig.apply(this, arguments);
|
||||
try { anunciar(msg); } catch (e) { /* nunca rompe el flujo */ }
|
||||
return r;
|
||||
};
|
||||
envuelta.__wamAnuncio = true;
|
||||
WAM.anadirMensajeDOM = envuelta;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Estado de los botones de la toolbar
|
||||
------------------------------------------------------------------ */
|
||||
function sincronizarTema() {
|
||||
var b = porId("btn-tema");
|
||||
if (b) b.setAttribute("aria-pressed", document.body.getAttribute("data-tema") === "oscuro" ? "true" : "false");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Inicialización (una sola vez)
|
||||
------------------------------------------------------------------ */
|
||||
WAM.initUX = function () {
|
||||
if (iniciado) return;
|
||||
iniciado = true;
|
||||
|
||||
// Sustituye al aviso genérico: indicador rojo en la toolbar + aviso con «Exportar JSON»
|
||||
WAM.onErrorGuardar = function (texto) { alErrorGuardar(texto); };
|
||||
|
||||
document.addEventListener("keydown", alTecla);
|
||||
var bd = porId("btn-deshacer"), br = porId("btn-rehacer"), bdemos = porId("btn-demos"), bat = porId("btn-atajos");
|
||||
if (bd) bd.addEventListener("click", function () { WAM.deshacer(); });
|
||||
if (br) br.addEventListener("click", function () { WAM.rehacer(); });
|
||||
if (bdemos) bdemos.addEventListener("click", function () { WAM.abrirDemos(); });
|
||||
if (bat) bat.addEventListener("click", function () { WAM.abrirAtajos(); });
|
||||
pintarBotonesHistorial();
|
||||
|
||||
enlazarMenu();
|
||||
enlazarPestanas();
|
||||
observarToolbar();
|
||||
enlazarChat();
|
||||
envolverAnuncios();
|
||||
|
||||
sincronizarTema();
|
||||
if (typeof MutationObserver === "function") {
|
||||
new MutationObserver(sincronizarTema).observe(document.body, { attributes: true, attributeFilter: ["data-tema"] });
|
||||
}
|
||||
|
||||
// Estado inicial del indicador y refresco del «hace N s»
|
||||
ultimoGuardado = Date.now();
|
||||
pintarGuardado("Guardado", "ok", "Guardado en este navegador");
|
||||
setInterval(pintarRelativo, 5000);
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user