/* 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 =
'';
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 =
'';
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";
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");
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 🍰" })]
}
]
})
]
};
};
})();