/* WAM · store.js — dueño D (núcleo). Estado único, persistencia en localStorage, normalización, generación de IDs y demo por defecto "Café Aurora" (los 13 tipos, respuestas encadenadas). No toca el DOM al cargar: solo define WAM.*. */ window.WAM = window.WAM || {}; (function () { "use strict"; WAM.CLAVE = "wam-mockup-v1"; WAM.estado = null; var TIPOS = [ "text", "image", "video", "audio", "document", "sticker", "location", "contact", "template", "buttons", "list", "cta_url", "carousel" ]; var HEADER_TIPOS = ["texto", "imagen", "video", "documento"]; /* ————————————————— utilidades ————————————————— */ function dos(n) { return (n < 10 ? "0" : "") + n; } WAM.horaAhora = function () { var d = new Date(); return dos(d.getHours()) + ":" + dos(d.getMinutes()); }; function cad(v, porDefecto) { if (v === undefined || v === null) return porDefecto || ""; return String(v); } function escaparXml(s) { return String(s).replace(/[&<>"']/g, function (c) { if (c === "&") return "&"; if (c === "<") return "<"; if (c === ">") return ">"; if (c === '"') return """; return "'"; }); } function aDataUri(svg) { return "data:image/svg+xml," + encodeURIComponent(svg); } /* Placeholder genérico (taza) para src/avatar/headerMedia vacíos. */ WAM.svgPlaceholder = function (texto) { var t = escaparXml(texto || ""); var svg = '' + '' + '' + '' + '' + (t ? '' + t + '' : '') + ''; return aDataUri(svg); }; /* Imagen SVG en línea y pequeña para las medias de la demo (sin red). */ function svgDemo(texto, emoji, fondo, acento) { var svg = '' + '' + '' + '' + escaparXml(emoji || "☕") + '' + '' + escaparXml(texto || "") + '' + ''; return aDataUri(svg); } function svgAvatar() { return aDataUri( '' + '' + '☕' + '' ); } function svgSticker() { return aDataUri( '' + '' + '☕' + '' ); } /* ————————————————— IDs secuenciales ————————————————— */ /* Recorre el estado (mensajes, respuestas anidadas, botones, filas, cards) y junta todos los id existentes para no colisionar nunca. */ function recolectarIds(o, conjunto) { if (!o || typeof o !== "object") return; if (Array.isArray(o)) { for (var i = 0; i < o.length; i++) recolectarIds(o[i], conjunto); return; } if (typeof o.id === "string" && o.id) conjunto.add(o.id); var claves = ["mensajes", "respuestas", "botones", "secciones", "filas", "cards"]; for (var c = 0; c < claves.length; c++) { var v = o[claves[c]]; if (v && typeof v === "object") recolectarIds(v, conjunto); } } function crearAlocador(usados) { var conjunto = usados || new Set(); return function tomar() { var n = 1; while (conjunto.has("m" + n)) n++; var id = "m" + n; conjunto.add(id); return id; }; } /* 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(entregados); recolectarIds(WAM.estado, 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 = { text: function () { return { texto: "" }; }, image: function () { return { src: "", caption: "" }; }, video: function () { return { src: "", caption: "" }; }, audio: function () { return { src: "", voz: true, duracion: "0:00" }; }, document: function () { return { src: "", caption: "", filename: "documento.pdf" }; }, sticker: function () { return { src: "" }; }, location: function () { return { latitud: "", longitud: "", nombre: "", direccion: "" }; }, contact: function () { return { nombre: "", telefono: "", email: "", empresa: "" }; }, template: function () { return { headerTipo: "", headerTexto: "", headerMedia: "", body: "", footer: "", botones: [] }; }, buttons: function () { return { headerTipo: "", headerTexto: "", headerMedia: "", body: "", footer: "", botones: [] }; }, list: function () { return { header: "", body: "", footer: "", boton: "", secciones: [] }; }, cta_url: function () { return { headerMedia: "", body: "", footer: "", textoBoton: "", url: "" }; }, carousel: function () { return { body: "", cards: [] }; } }; WAM.nuevoMensaje = function (tipo) { var valido = Object.prototype.hasOwnProperty.call(CAMPOS, tipo); var fabrica = valido ? CAMPOS[tipo] : CAMPOS.text; var msg = { id: WAM.generarId(), dir: "in", tipo: valido ? tipo : "text", hora: WAM.horaAhora(), estado: "sent" }; var campos = fabrica(); for (var k in campos) { if (Object.prototype.hasOwnProperty.call(campos, k)) msg[k] = campos[k]; } return msg; }; /* ————————————————— normalización ————————————————— */ /* 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; } /* Contadores de lo que recorta la normalización en curso (ver WAM.ultimosRecortes). null fuera de WAM.normalizar. */ var recortes = null; function lista(v, max, clave) { var arr = Array.isArray(v) ? v : []; var tope = max || MAX_ITEMS; if (recortes && clave && arr.length > tope) recortes[clave] += arr.length - tope; return arr.slice(0, tope); } /* URL de un botón/card de enlace: si venía escrita pero es insegura se vacía y se marca urlBloqueada, para que siga siendo un botón de enlace (inerte) y no se convierta en una respuesta rápida con flujo. */ function urlEnlace(destino, origen) { var bruta = cad(origen.url).trim(); destino.url = urlLimpia(bruta, "enlace"); if (!destino.url && (bruta || origen.urlBloqueada === true || origen.urlBloqueada === "true")) { destino.urlBloqueada = true; } } /* Texto en español con lo recortado, o "" si no se recortó nada. */ WAM.describirRecortes = function (r) { r = r || WAM.ultimosRecortes; if (!r) return ""; var partes = []; function n(c, uno, varios) { if (r[c] > 0) partes.push(r[c] + " " + (r[c] === 1 ? uno : varios)); } n("botones", "botón", "botones"); n("secciones", "sección", "secciones"); n("filas", "fila", "filas"); n("cards", "tarjeta", "tarjetas"); n("respuestas", "respuesta", "respuestas"); n("profundidad", "respuesta anidada (más de " + MAX_PROFUNDIDAD + " niveles)", "respuestas anidadas (más de " + MAX_PROFUNDIDAD + " niveles)"); var txt = ""; if (r.mensajesOriginales > r.mensajes) { txt = "Se importaron " + r.mensajes + " de " + r.mensajesOriginales + " mensajes"; } if (partes.length) { var total = 0; ["botones", "secciones", "filas", "cards", "respuestas", "profundidad"].forEach(function (c) { total += r[c] || 0; }); var verbo = total === 1 ? "se recortó " : "se recortaron "; txt += (txt ? "; " + verbo : verbo.charAt(0).toUpperCase() + verbo.slice(1)) + partes.join(", "); } return txt; }; /* 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 mensajesSrc = Array.isArray(src.mensajes) ? src.mensajes.length : 0; recortes = { mensajes: Math.min(mensajesSrc, 500), mensajesOriginales: mensajesSrc, botones: 0, secciones: 0, filas: 0, cards: 0, respuestas: 0, profundidad: 0 }; var conjunto = new Set(); recolectarIds(src, conjunto); var tomar = crearAlocador(conjunto); var reclamados = new Set(); 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: idDe(m.id), dir: (!forzarEntrada && m.dir === "out") ? "out" : "in", tipo: tipo, 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) { if (profundidad >= MAX_PROFUNDIDAD) { if (Array.isArray(valor) && valor.length) recortes.profundidad += valor.length; return []; } return lista(valor, 20, "respuestas").map(function (r) { return normalizarMensaje(r, true, profundidad + 1); }); } function botones(valor) { return lista(valor, MAX_ITEMS, "botones").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" || boton.urlBloqueada === true) urlEnlace(out, boton); 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 = urlLimpia(m.src, "media"); msg.caption = cad(m.caption); break; case "audio": 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 = urlLimpia(m.src, "media"); msg.caption = cad(m.caption); msg.filename = cad(m.filename, "documento.pdf") || "documento.pdf"; /* opcional: «243 KB» para el subtítulo «243 KB • pdf» (render.js) */ if (cad(m.tamano).trim()) msg.tamano = cad(m.tamano).trim().slice(0, 20); break; case "sticker": msg.src = urlLimpia(m.src, "media"); break; case "location": msg.latitud = cad(m.latitud); msg.longitud = cad(m.longitud); msg.nombre = cad(m.nombre); msg.direccion = cad(m.direccion); break; case "contact": msg.nombre = cad(m.nombre); msg.telefono = cad(m.telefono); msg.email = cad(m.email); msg.empresa = cad(m.empresa); break; case "template": case "buttons": msg.headerTipo = HEADER_TIPOS.indexOf(m.headerTipo) !== -1 ? m.headerTipo : ""; msg.headerTexto = cad(m.headerTexto); msg.headerMedia = urlLimpia(m.headerMedia, "media"); if (msg.headerTipo === "documento" && cad(m.headerTamano).trim()) { msg.headerTamano = cad(m.headerTamano).trim().slice(0, 20); } 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 = lista(m.secciones, MAX_ITEMS, "secciones").map(function (s) { var seccion = (s && typeof s === "object") ? s : {}; return { titulo: cad(seccion.titulo), filas: lista(seccion.filas, MAX_ITEMS, "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), respuestas: respuestas(fila.respuestas) }; }) }; }); autoPulsar(); break; case "cta_url": msg.headerMedia = urlLimpia(m.headerMedia, "media"); msg.body = cad(m.body); msg.footer = cad(m.footer); msg.textoBoton = cad(m.textoBoton); msg.url = urlLimpia(m.url, "enlace"); break; case "carousel": msg.body = cad(m.body); msg.cards = lista(m.cards, MAX_ITEMS, "cards").map(function (c) { var card = (c && typeof c === "object") ? c : {}; var salida = { media: urlLimpia(card.media, "media"), texto: cad(card.texto), textoBoton: cad(card.textoBoton), url: "", respuestas: respuestas(card.respuestas) }; urlEnlace(salida, card); return salida; }); 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; } var cuentaSrc = (src.cuenta && typeof src.cuenta === "object") ? src.cuenta : {}; var resultado = { version: 1, tema: src.tema === "oscuro" ? "oscuro" : "claro", cuenta: { nombre: cad(cuentaSrc.nombre, "Café Aurora"), avatar: urlLimpia(cuentaSrc.avatar, "media"), subtitulo: cad(cuentaSrc.subtitulo, "en línea"), 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: lista(src.mensajes, 500).map(function (x) { return normalizarMensaje(x, false, 0); }) }; /* Lo recortado queda en WAM.ultimosRecortes y, no enumerable (no se guarda ni se exporta), en resultado.__recortes. */ WAM.ultimosRecortes = recortes; try { Object.defineProperty(resultado, "__recortes", { value: recortes, enumerable: false, configurable: true }); } catch (e) { /* entorno sin defineProperty */ } recortes = null; return resultado; }; /* ————————————————— 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; } var dato = null; if (bruto) { try { dato = JSON.parse(bruto); } catch (e) { dato = null; } } 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 () { 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" ————————————————— */ function men(dir, tipo, hora, campos) { var msg = { id: "", dir: dir, tipo: tipo, hora: hora, estado: "sent" }; var k; for (k in campos) { if (Object.prototype.hasOwnProperty.call(campos, k)) msg[k] = campos[k]; } return msg; } WAM.datosDemo = function () { return { version: 1, tema: "claro", cuenta: { nombre: "Café Aurora", avatar: svgAvatar(), subtitulo: "en línea", verificado: true }, mensajes: [ /* 1 · text in (saludo) */ men("in", "text", "10:24", { texto: "¡Hola! 👋 Bienvenido a Café Aurora. Soy Luna, tu barista de guardia. ¿Te cuento las novedades?" }), /* 2 · text out (cliente) */ men("out", "text", "10:24", { estado: "read", texto: "¡Hola, Luna! Sí, cuéntame ☕" }), /* 3 · image */ men("in", "image", "10:25", { src: svgDemo("Nuevo horario", "⏰", "#f3e7d8", "#c8a27a"), caption: "Nuevo horario desde el 1 de octubre ☕" }), /* 4 · video (sin src → placeholder SVG) */ men("in", "video", "10:25", { src: "", caption: "Así tostamos nuestros granos cada mañana" }), /* 5 · location */ men("in", "location", "10:26", { latitud: "40.4168", longitud: "-3.7038", nombre: "Café Aurora", direccion: "Calle Mayor 12, 28013 Madrid" }), /* 6 · contact */ men("in", "contact", "10:26", { nombre: "Luna Mendoza", telefono: "+34 611 222 333", email: "luna@cafeaurora.es", empresa: "Café Aurora" }), /* 7 · document */ men("in", "document", "10:27", { src: "", caption: "Nuestra carta con granos y precios", filename: "carta-cafe-aurora.pdf" }), /* 8 · audio (nota de voz) */ men("in", "audio", "10:27", { src: "", voz: true, duracion: "0:12" }), /* 9 · sticker */ men("in", "sticker", "10:28", { src: svgSticker() }), /* 10 · template (header imagen + confirmación de reserva) */ men("in", "template", "10:29", { headerTipo: "imagen", headerTexto: "", headerMedia: svgDemo("Tu mesa te espera", "☕", "#e9d8c3", "#a9825f"), body: "Hola 👋 Tu mesa para 2 personas está reservada este viernes a las 19:30. ¿Confirmas la reserva?", footer: "Café Aurora · Reservas", botones: [ { texto: "Confirmar", respuestas: [ men("in", "text", "10:29", { texto: "¡Genial! Reserva confirmada para el viernes a las 19:30 ✨ Te esperamos." }) ] }, { texto: "Cambiar fecha", respuestas: [ men("in", "text", "10:29", { texto: "Sin problema. ¿Para qué día y hora te la cambio?" }) ] } ] }), /* 11 · buttons (quick replies, máx 3 · títulos ≤ 20) */ men("in", "buttons", "10:30", { body: "¿Qué te muestro ahora?", footer: "Elige una opción", botones: [ { texto: "Horarios", respuestas: [ men("in", "text", "10:30", { texto: "Abrimos de lunes a viernes de 7:30 a 20:00 y sábados de 9:00 a 14:00." }) ] }, { texto: "Promociones", respuestas: [ men("in", "text", "10:30", { texto: "🔥 Martes de cold brew: 2×1 todo el día." }), men("in", "text", "10:30", { texto: "🥐 Viernes: croissant gratis con tu café." }) ] }, { texto: "Hablar con el equipo", respuestas: [ men("in", "text", "10:30", { texto: "Te paso con Marta del equipo humano, un momento…" }) ] } ] }), /* 12 · list (filas ≤ 10 · títulos fila ≤ 24 · descripción ≤ 72) */ men("in", "list", "10:31", { header: "Menú de la casa", body: "¿Qué te apetece hoy?", footer: "Catálogo 2026", boton: "Ver opciones", secciones: [ { titulo: "Bebidas", filas: [ { titulo: "Espresso double", descripcion: "Intenso, 100 % arábica", respuestas: [men("in", "text", "10:31", { texto: "Un espresso double rumbo a tu mesa ☕" })] }, { titulo: "Latte de avena", descripcion: "Suave, con leche de avena", respuestas: [men("in", "text", "10:31", { texto: "¡Buen gusto! Latte de avena anotado 🌾" })] }, { titulo: "Cold brew 12 h", descripcion: "Macerado en frío durante 12 horas", respuestas: [men("in", "text", "10:31", { texto: "Cold brew bien frío, ¡perfecto para hoy! 🧊" })] } ] }, { titulo: "Postres", filas: [ { titulo: "Tarta de queso", descripcion: "La de siempre, casera", respuestas: [men("in", "text", "10:31", { texto: "Una porción de tarta de queso, ¡gran elección! 🍰" })] }, { titulo: "Croissant almendra", descripcion: "Horneado cada mañana", respuestas: [men("in", "text", "10:31", { texto: "Croissant de almendra recién hecho 🥐" })] } ] } ] }), /* 13 · cta_url (botón con URL → , sin flujo) */ men("in", "cta_url", "10:32", { headerMedia: svgDemo("Tienda online", "🛍️", "#efe0cd", "#b08968"), body: "Ya puedes pedir nuestros granos tostados a domicilio.", footer: "Envío en 24-48 h", textoBoton: "Abrir tienda", url: "https://cafeaurora.example/tienda" }), /* 14 · carousel (3 cards con respuestas encadenadas) */ men("in", "carousel", "10:33", { body: "Los favoritos de la casa ☕", cards: [ { media: svgDemo("Espresso Aurora", "☕", "#f0e3d0", "#a9825f"), texto: "Espresso Aurora · tueste medio con notas de cacao. Bolsa 250 g.", textoBoton: "¡Lo quiero!", url: "", respuestas: [men("in", "text", "10:33", { texto: "¡Excelente! El Espresso Aurora sale hoy mismo hacia ti 📦" })] }, { media: svgDemo("Cold brew botella", "🧋", "#e4ecf0", "#7f9cab"), texto: "Cold brew en botella de 1 L, listo para servir muy frío.", textoBoton: "Pedir", url: "", respuestas: [men("in", "text", "10:33", { texto: "Cold brew de 1 L añadido a tu pedido 🧊" })] }, { media: svgDemo("Tarta de queso", "🍰", "#f5ebe2", "#c9a227"), texto: "Tarta de queso casera, entera o por porciones.", textoBoton: "Probar", url: "", respuestas: [men("in", "text", "10:33", { texto: "La tarta de queso sale del horno a las 12:00 🍰" })] } ] }) ] }; }; })();