771 lines
30 KiB
JavaScript
771 lines
30 KiB
JavaScript
/* 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 =
|
||
'<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";
|
||
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: "[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 🍰" })]
|
||
}
|
||
]
|
||
})
|
||
]
|
||
};
|
||
};
|
||
})();
|