Files
urieljarethandClaude Opus 5.5 4fe8f73959 Aplicar pendientes tras la integración
- render.js: botones y cards con urlBloqueada se pintan inertes.
- interactivity.js: sinFormato con la misma regla de marcadores que render.js; cita de documento con icono y nombre.
- Zoom manual (Ajustar / 100 % / 125 %) en #barra-movil, persistido en localStorage.
- Contador «Paso N/M» con estilo en iphone.css en lugar de estilos en línea.
- store/editor: campos opcionales tamano y headerTamano; editar la URL retira urlBloqueada;
  urlValida acepta URLs con forma de dominio; importar avisa de lo recortado.
- README y SPEC actualizados.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:39:09 -06:00

776 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 "&amp;";
if (c === "<") return "&lt;";
if (c === ">") return "&gt;";
if (c === '"') return "&quot;";
return "&apos;";
});
}
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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320" viewBox="0 0 480 320">' +
'<rect width="480" height="320" fill="#efe6dc"/>' +
'<path d="M204 96 l8 -16 m16 16 l8 -16 m16 16 l8 -16" fill="none" stroke="#c8a27a" stroke-width="5" stroke-linecap="round"/>' +
'<rect x="184" y="118" width="70" height="52" rx="10" fill="none" stroke="#a9825f" stroke-width="7"/>' +
'<path d="M254 130 h13 a13 13 0 0 1 0 26 h-13" fill="none" stroke="#a9825f" stroke-width="7"/>' +
(t ? '<text x="240" y="242" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="24" fill="#7f5539">' + t + '</text>' : '') +
'</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 =
'<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="132" r="76" fill="' + acento + '"/>' +
'<text x="240" y="156" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="68">' + escaparXml(emoji || "☕") + '</text>' +
'<text x="240" y="272" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="25" fill="#5b4232">' + escaparXml(texto || "") + '</text>' +
'</svg>';
return aDataUri(svg);
}
function svgAvatar() {
return aDataUri(
'<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="#6f4e37"/>' +
'<text x="48" y="63" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="42">☕</text>' +
'</svg>'
);
}
function svgSticker() {
return aDataUri(
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">' +
'<circle cx="160" cy="160" r="150" fill="#fff8ef" stroke="#e3cfae" stroke-width="10"/>' +
'<text x="160" y="198" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="130">☕</text>' +
'</svg>'
);
}
/* ————————————————— 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: "[email protected]",
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 → <a target="_blank">, 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 🍰" })]
}
]
})
]
};
};
})();