';
var clear = document.getElementById("chato-attach-clear");
if (clear) clear.onclick = function() { pendingAttachment = null; renderAttachBar(); };
}
async function uploadAttachment(file) {
if (!file || uploading) return;
uploading = true;
setSendDisabled(true);
renderAttachBar();
try {
var fd = new FormData();
fd.append("file", file);
if (sessionId) fd.append("session_id", sessionId);
var res = await fetch(API + "/attachments", { method: "POST", body: fd, credentials: "omit" });
if (!res.ok) {
var msg = "No pude subir el archivo.";
if (res.status === 413) msg = "El archivo supera 10 MB.";
else if (res.status === 429) msg = "Demasiados archivos. Esperá un momento.";
else if (res.status === 400) {
try { var b = await res.json(); if (b && b.detail) msg = b.detail; } catch(_) {}
}
addMsg(msg, "assistant");
return;
}
pendingAttachment = await res.json();
} catch(_) {
addMsg("No pude subir el archivo.", "assistant");
} finally {
uploading = false;
setSendDisabled(false);
renderAttachBar();
}
}
function attachmentHTML(att) {
if (typeof att.mime_type === "string" && att.mime_type.indexOf("video/") === 0) {
return '';
}
if (typeof att.mime_type === "string" && att.mime_type.indexOf("audio/") === 0) {
return '';
}
if (isImageMime(att.mime_type)) {
return '' +
'';
}
return '' +
'📎' +
'' +
escapeHtml(att.original_name) + '' +
'' + humanSize(att.size_bytes) + '';
}
function addAttachmentMsg(att, role) {
var msgs = document.getElementById("chato-msgs");
if (!msgs) return;
clearWelcome();
var d = document.createElement("div");
d.innerHTML = attachmentHTML(att);
// The visitor's own files sit on the right in the brand colour; an agent's on the left.
d.style.cssText = role === "user"
? "padding:6px;border-radius:14px;max-width:80%;align-self:flex-end;background:" +
cfg.color + ";color:#fff;border-bottom-right-radius:4px;font-size:13px"
: "padding:6px;border-radius:14px;max-width:80%;align-self:flex-start;background:#fff;" +
"color:#111827;border:1px solid #e5e7eb;border-bottom-left-radius:4px;font-size:13px";
msgs.appendChild(d);
msgs.scrollTop = msgs.scrollHeight;
}
// One message from the poll or the history: its text (unless that is only the label of
// a file that shows right below) and then each file it carries.
function showMessage(m, role) {
var files = m.attachments || [];
// ``assistant`` is the AI; ``human_agent`` is a person (spec 0317 tags only the former, and
// spec 0324 names only the latter: ``agent_name`` is null for the AI and the visitor).
if (m.content && !(m.media_placeholder && files.length)) addMsg(m.content, role, m.role === "assistant", m.agent_name);
files.forEach(function(att) { addAttachmentMsg(att, role); });
}
// Branding (spec 0005). ``title`` is what the visitor reads; ``name``
// stays the internal label used by the FAB and the channel menu.
function headerTitle() {
return (cfg && cfg.title) || (cfg && cfg.name) || "";
}
function headerLogoHTML() {
if (!cfg || !cfg.logo_url) return "";
return '';
}
// Shown only until the first message lands - a greeting shouldn't eat
// room in an ongoing conversation.
function welcomeHTML() {
if (!cfg || !cfg.description) return "";
return '
' +
escapeHtml(cfg.description) + '
';
}
function clearWelcome() {
var w = document.getElementById("chato-welcome");
if (w && w.parentNode) w.parentNode.removeChild(w);
}
// Who the visitor can count on right now (spec 0317): people first, then the AI receptionist,
// then nobody. "Fuera de línea" is about people; the AI is the channel's own entry mode, which
// /status reports as ``assistant_mode`` (``cfg.bot_enabled`` is only the plan flag).
function agentsAvailable() {
return !!(chatStatus && chatStatus.agents_available);
}
function aiAnswers() {
if (!cfg || !cfg.bot_enabled) return false;
return !(chatStatus && chatStatus.assistant_mode === "human");
}
function statusView(aiOk) {
if (agentsAvailable()) return { dot: "#10b981", text: "Agentes disponibles", live: true };
if (aiOk) return { dot: "#10b981", text: "Recepcionista IA 24/7", live: true };
return { dot: "#ef4444", text: "Fuera de línea", live: false };
}
// While people are what the header promises, the AI's replies carry an "IA" tag so the visitor
// can tell who is writing. Applied to the bubbles already on screen every time the state changes.
function applyAiTags() {
var msgs = document.getElementById("chato-msgs");
if (!msgs) return;
var show = agentsAvailable();
var bubbles = msgs.querySelectorAll("[data-chato-ai]");
for (var i = 0; i < bubbles.length; i++) {
var b = bubbles[i];
var tag = b.querySelector(".chato-ai-tag");
if (show && !tag) {
tag = document.createElement("span");
tag.className = "chato-ai-tag";
tag.textContent = "IA";
tag.style.cssText = "display:inline-block;margin-right:6px;padding:0 5px;border-radius:6px;background:#f3f4f6;color:#6b7280;font-size:10px;font-weight:600;line-height:16px;vertical-align:1px";
b.insertBefore(tag, b.firstChild);
} else if (!show && tag) {
b.removeChild(tag);
}
}
}
function renderChatHeader() {
var el = document.getElementById("chato-header-subtitle");
if (!el) return;
var view;
if (sessionStatus === "human" && assignedAdminName) {
view = { dot: "#10b981", text: "Atendido por " + escapeHtml(assignedAdminName), live: true };
} else {
// A conversation already handed to people is no longer answered by the AI.
view = statusView(sessionStatus !== "human" && aiAnswers());
}
ensureStatusStyle();
el.innerHTML =
'' +
'' + view.text + '';
applyAiTags();
}
// Soft expanding ripple on the green "online" dot (spec 0304). Static for the
// red offline dot, and off under prefers-reduced-motion.
function ensureStatusStyle() {
if (document.getElementById("chato-status-style")) return;
var s = document.createElement("style");
s.id = "chato-status-style";
s.textContent =
"@keyframes chato-ripple { 0% { box-shadow: 0 0 0 2px rgba(255,255,255,.25), 0 0 0 2px rgba(16,185,129,.55) } 70%,100% { box-shadow: 0 0 0 2px rgba(255,255,255,.25), 0 0 0 8px rgba(16,185,129,0) } }" +
"@keyframes chato-ripple-badge { 0% { box-shadow: 0 0 0 2px #10b98133, 0 0 0 2px rgba(16,185,129,.5) } 70%,100% { box-shadow: 0 0 0 2px #10b98133, 0 0 0 8px rgba(16,185,129,0) } }" +
".chato-online-dot { animation: chato-ripple 2.4s ease-out infinite; }" +
".chato-online-dot-badge { animation: chato-ripple-badge 2.4s ease-out infinite; }" +
"@keyframes chato-fab-ripple { 0% { transform: scale(1); opacity: .38 } 70%,100% { transform: scale(1.7); opacity: 0 } }" +
"#chato-fab-ring { animation: chato-fab-ripple 2.6s ease-out infinite; }" +
"#chato-input::placeholder, #chato-pf-name::placeholder, #chato-pf-email::placeholder { color: #9ca3af; -webkit-text-fill-color: #9ca3af; opacity: 1; }" +
"@media (prefers-reduced-motion: reduce) { .chato-online-dot, .chato-online-dot-badge, #chato-fab-ring { animation: none; } #chato-fab-ring { display: none !important; } }";
document.head.appendChild(s);
}
function statusBadgeHTML() {
if (!chatStatus) return "";
var view = statusView(aiAnswers());
ensureStatusStyle();
return (
'' +
'' +
view.text +
''
);
}
function renderMenu() {
var menu = document.getElementById("chato-menu");
if (!menu) return;
menu.innerHTML = "";
cfg.channels.forEach(function(ch) {
var icon = ICONS[ch.kind] || ICONS.webchat;
var label = ch.label || LABELS[ch.kind] || ch.kind;
var el;
if (ch.kind === "webchat") {
el = document.createElement("button");
el.type = "button";
el.onclick = function() { openChat(); };
} else {
el = document.createElement("a");
el.href = channelHref(ch);
el.target = "_blank";
el.rel = "noopener noreferrer";
}
el.style.cssText = "display:flex;align-items:center;gap:12px;background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:10px 14px;text-decoration:none;color:#374151;font-family:inherit;font-size:14px;font-weight:500;cursor:pointer;box-shadow:0 6px 18px rgba(0,0,0,.08);transition:background .15s ease,border-color .15s ease,transform .15s ease";
el.onmouseenter = function() {
el.style.background = "#f9fafb";
el.style.borderColor = "#d1d5db";
el.style.transform = "translateY(-1px)";
};
el.onmouseleave = function() {
el.style.background = "#fff";
el.style.borderColor = "#e5e7eb";
el.style.transform = "translateY(0)";
};
var subline = ch.kind === "webchat" ? statusBadgeHTML() : "";
el.innerHTML =
'' + icon + '' +
'' + label + '' + (subline ? subline : '') + '';
menu.appendChild(el);
});
}
var FAB_ICON_CHAT = '';
var FAB_ICON_CLOSE = '';
var CLIP_ICON = '';
function updateFab() {
var fab = document.getElementById("chato-fab");
if (!fab) return;
var active = menuOpen || chatOpen;
fab.style.transform = active ? "rotate(0deg) scale(0.95)" : "rotate(0deg) scale(1)";
fab.style.background = active ? "#374151" : cfg.color;
fab.innerHTML = active ? FAB_ICON_CLOSE : FAB_ICON_CHAT;
updateFabRing();
}
// Soft ripple in the brand colour behind the floating button while the chat
// is available and closed (spec 0304). Never covers the button: z-index sits
// below it and it ignores the pointer.
function updateFabRing() {
var ring = document.getElementById("chato-fab-ring");
if (!ring || !cfg) return;
var available = agentsAvailable() || aiAnswers();
var show = available && !menuOpen && !chatOpen;
ring.style.background = cfg.color;
ring.style.display = show ? "block" : "none";
}
function setMenuOpen(v) {
menuOpen = v;
var menu = document.getElementById("chato-menu");
var back = document.getElementById("chato-backdrop");
if (menu) {
if (v) {
menu.style.display = "flex";
// next tick → animar entrada
requestAnimationFrame(function() {
menu.style.opacity = "1";
menu.style.transform = "translateY(0)";
});
} else {
menu.style.opacity = "0";
menu.style.transform = "translateY(8px)";
setTimeout(function() {
if (!menuOpen && menu) menu.style.display = "none";
}, 180);
}
}
if (back) back.style.display = v ? "block" : "none";
updateFab();
}
function scrollMsgsToBottom() {
var msgs = document.getElementById("chato-msgs");
if (msgs) msgs.scrollTop = msgs.scrollHeight;
}
function setChatOpen(v) {
chatOpen = v;
var box = document.getElementById("chato-box");
if (box) {
if (v) {
box.style.display = "flex";
requestAnimationFrame(function() {
box.style.opacity = "1";
box.style.transform = "translateY(0) scale(1)";
// Container only has real scrollHeight after display:flex,
// so anchor at the bottom now (not while we were hidden).
scrollMsgsToBottom();
});
} else {
box.style.opacity = "0";
box.style.transform = "translateY(8px) scale(.98)";
setTimeout(function() {
if (!chatOpen && box) box.style.display = "none";
}, 180);
}
}
if (v && sessionId) startPolling();
updateFab();
}
async function fetchStatus() {
try {
var res = await fetch(API + "/status", { method: "GET", credentials: "omit" });
if (!res.ok) return;
chatStatus = await res.json();
renderMenu();
renderChatHeader();
updateFabRing();
} catch(_) {}
}
function startStatusPoll() {
if (statusTimer) return;
fetchStatus();
statusTimer = setInterval(fetchStatus, STATUS_INTERVAL_MS);
}
function openChat() {
setMenuOpen(false);
setChatOpen(true);
if (needsPreChatForm()) showPreChatForm();
}
function buildUI() {
// Backdrop
var back = document.createElement("div");
back.id = "chato-backdrop";
back.style.cssText = "display:none;position:fixed;inset:0;z-index:9998";
back.onclick = function() { setMenuOpen(false); };
document.body.appendChild(back);
// Compact menu of channels
var menu = document.createElement("div");
menu.id = "chato-menu";
menu.style.cssText = "display:none;position:fixed;bottom:96px;right:24px;flex-direction:column;gap:10px;align-items:flex-end;z-index:9999;opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease";
document.body.appendChild(menu);
// Chat panel
var box = document.createElement("div");
box.id = "chato-box";
box.style.cssText = "display:none;position:fixed;bottom:96px;right:24px;width:340px;max-width:calc(100vw - 32px);height:480px;max-height:calc(100vh - 120px);background:#fff;border-radius:18px;box-shadow:0 18px 48px rgba(0,0,0,.18);z-index:9999;overflow:hidden;flex-direction:column;font-family:system-ui,sans-serif;opacity:0;transform:translateY(8px) scale(.98);transition:opacity .18s ease,transform .18s ease";
box.innerHTML =
'
' +
headerLogoHTML() +
'
' +
'' + escapeHtml(headerTitle()) + '' +
'' +
'
' +
'' +
'
' +
'
' + welcomeHTML() + '
' +
'' +
'';
document.body.appendChild(box);
// FAB ripple (sits behind the button; shown by updateFabRing)
ensureStatusStyle();
var fabRing = document.createElement("span");
fabRing.id = "chato-fab-ring";
fabRing.setAttribute("aria-hidden", "true");
fabRing.style.cssText = "position:fixed;bottom:24px;right:24px;width:56px;height:56px;border-radius:50%;pointer-events:none;z-index:9998;display:none;background:" + cfg.color;
document.body.appendChild(fabRing);
// FAB
var fab = document.createElement("button");
fab.id = "chato-fab";
fab.type = "button";
fab.setAttribute("aria-label", "Contacto");
fab.style.cssText = "position:fixed;bottom:24px;right:24px;width:56px;height:56px;border-radius:50%;background:" + cfg.color + ";color:#fff;border:none;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,.18);z-index:9999;display:flex;align-items:center;justify-content:center;transition:background .2s ease,transform .2s ease,box-shadow .2s ease";
fab.innerHTML = FAB_ICON_CHAT;
fab.onmouseenter = function() {
if (!menuOpen && !chatOpen) {
fab.style.transform = "scale(1.06)";
fab.style.boxShadow = "0 12px 28px rgba(0,0,0,.22)";
}
};
fab.onmouseleave = function() {
if (!menuOpen && !chatOpen) {
fab.style.transform = "scale(1)";
fab.style.boxShadow = "0 8px 24px rgba(0,0,0,.18)";
}
};
fab.onclick = function() {
if (chatOpen) { setChatOpen(false); return; }
if (cfg.channels.length === 1 && cfg.channels[0].kind === "webchat") {
openChat();
return;
}
if (cfg.channels.length === 1) {
window.open(channelHref(cfg.channels[0]), "_blank");
return;
}
setMenuOpen(!menuOpen);
};
document.body.appendChild(fab);
document.getElementById("chato-close").onclick = function() { setChatOpen(false); };
var clip = document.getElementById("chato-clip");
var fileInput = document.getElementById("chato-file");
if (clip && fileInput) {
clip.onclick = function() { fileInput.click(); };
fileInput.onchange = async function() {
var f = fileInput.files && fileInput.files[0];
fileInput.value = ""; // let the same file be picked again
await uploadAttachment(f);
};
}
document.getElementById("chato-form").onsubmit = async function(e) {
e.preventDefault();
if (uploading) return;
var input = document.getElementById("chato-input");
var text = input.value.trim();
var att = pendingAttachment;
// A file on its own is a valid message - don't require text.
if (!text && !att) return;
input.value = "";
pendingAttachment = null;
renderAttachBar();
var payload = {text: text || null};
if (att) payload.attachment_ids = [att.id];
if (att) addAttachmentMsg(att, "user");
await sendPayload(payload, text, !!att);
};
renderMenu();
renderChatHeader();
updateFab();
// Status feeds the dot on the "Chat en vivo" item so the visitor
// sees "En línea" / "Fuera de línea" before opening the chat.
var hasWebchat = cfg.channels.some(function(c) { return c.kind === "webchat"; });
if (hasWebchat) startStatusPoll();
}
// ``skipBubble`` is set when the caller already rendered the user's side
// (an attachment-only message has no text to echo).
async function sendPayload(payload, userLabel, skipBubble) {
if (userLabel) addMsg(userLabel, "user");
else if (!skipBubble) addMsg(userLabel, "user");
setTypingByMe(true);
try {
var fullBody = Object.assign({session_id:sessionId}, payload);
if (contactData) {
if (contactData.name) fullBody.name = contactData.name;
if (contactData.email) fullBody.email = contactData.email;
}
var res = await fetch(API + "/messages", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(fullBody) });
var data = await res.json();
sessionId = data.session_id;
localStorage.setItem(SESSION_KEY, sessionId);
startPolling();
} catch(_) {
setTypingByMe(false);
addMsg("Error al conectar. Intentá de nuevo.", "bot");
}
}
function ensureTypingStyle() {
if (document.getElementById("chato-typing-style")) return;
var s = document.createElement("style");
s.id = "chato-typing-style";
s.textContent =
"@keyframes chato-bounce { 0%,80%,100% { transform: translateY(0); opacity:.5 } 40% { transform: translateY(-4px); opacity:1 } }" +
".chato-typing-dot { width:6px; height:6px; border-radius:50%; background:#9ca3af; display:inline-block; animation: chato-bounce 1s infinite ease-in-out; }";
document.head.appendChild(s);
}
function refreshTyping() {
var show = typingByMe || typingByAgent;
var d = document.getElementById("chato-typing");
if (show) {
ensureTypingStyle();
var msgs = document.getElementById("chato-msgs");
if (!msgs) return;
if (d) {
msgs.appendChild(d); // keep it last
msgs.scrollTop = msgs.scrollHeight;
return;
}
d = document.createElement("div");
d.id = "chato-typing";
d.style.cssText = "align-self:flex-start;background:#fff;border:1px solid #e5e7eb;border-bottom-left-radius:4px;padding:10px 14px;border-radius:14px;display:flex;gap:4px;align-items:center";
d.innerHTML =
'' +
'' +
'';
msgs.appendChild(d);
msgs.scrollTop = msgs.scrollHeight;
} else if (d) {
d.remove();
}
}
function setTypingByMe(v) { typingByMe = !!v; refreshTyping(); }
function setTypingByAgent(v) { typingByAgent = !!v; refreshTyping(); }
function linkify(escaped, role) {
// Escaped text in, HTML out. We turn http(s) URLs into safe
// tags so the visitor never
// leaves the page. Trailing punctuation that breaks URLs (.,;:!?)
// is left outside the anchor.
var linkColor = role === "user" ? "#fff" : cfg.color;
return escaped.replace(
/(https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]])/g,
function(url) {
return '' + url + '';
}
);
}
function renderMarkdown(raw, linkColor) {
function esc(s) {
return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
function inline(t) {
var s = esc(t);
s = s.replace(/\*\*\*(.+?)\*\*\*/g,'$1');
s = s.replace(/\*\*(.+?)\*\*/g,'$1');
s = s.replace(/\*([^*\n]+)\*/g,'$1');
s = s.replace(/`([^`]+)`/g,'$1');
if (linkColor) {
s = s.replace(/(https?:\/\/[^\s<>"'&]+[^\s<>"'&.,;:!?\)\]])/g,'$1');
}
return s;
}
var lines = raw.split('\n'), html = '', i = 0;
while (i < lines.length) {
var line = lines[i];
if (!line.trim()) { i++; continue; }
if (/^\s*\|/.test(line) && i+1 < lines.length && /^\s*\|[\s:|\-]+\|/.test(lines[i+1])) {
var t = '
';
var hc = line.split('|').slice(1,-1);
t += '
';
for (var j=0;j'+inline(hc[j].trim())+''; }
t += '
';
i += 2;
while (i < lines.length && /^\s*\|/.test(lines[i])) {
var rc = lines[i].split('|').slice(1,-1);
t += '
';
for (var k=0;k'+inline(rc[k].trim())+''; }
t += '
';
i++;
}
t += '
';
html += t; continue;
}
var hm = line.match(/^(###|##|#)\s+(.+)/);
if (hm) {
var fw = hm[1].length===1?'700;font-size:15px':hm[1].length===2?'700;font-size:14px':'600;font-size:13px';
html += '
'+inline(hm[2])+'
';
i++; continue;
}
if (/^\s*[-*]\s/.test(line)) {
html += '
';
while (i < lines.length && /^\s*[-*]\s/.test(lines[i])) {
html += '
'+inline(lines[i].replace(/^\s*[-*]\s+/,''))+'
';
i++;
}
html += '
'; continue;
}
if (/^\s*\d+\.\s/.test(line)) {
html += '';
while (i < lines.length && /^\s*\d+\.\s/.test(lines[i])) {
html += '
'+inline(lines[i].replace(/^\s*\d+\.\s+/,''))+'
';
i++;
}
html += ''; continue;
}
if (/^```/.test(line)) {
i++;
var cb = [];
while (i < lines.length && !/^```/.test(lines[i])) { cb.push(lines[i]); i++; }
i++;
html += '
'+esc(cb.join('\n'))+'
';
continue;
}
html += inline(line)+' ';
i++;
}
return html || esc(raw);
}
// ``author`` is the name of the person who wrote it (spec 0324), shown above their bubble so the
// visitor can tell who is answering even when the conversation changes hands. Escaped like any text.
function authorLabelHTML(author) {
if (!author) return "";
return '
' +
escapeHtml(author) + '
';
}
function addMsg(text, role, isAi, author) {
var msgs = document.getElementById("chato-msgs");
if (!msgs) return;
clearWelcome();
var d = document.createElement("div");
if (isAi) d.setAttribute("data-chato-ai", "1");
var hasMarkdown = role !== 'user' && /[*`|#]/.test(text);
d.innerHTML = role === 'user'
? linkify(escapeHtml(text), role).replace(/\n/g, " ")
: authorLabelHTML(author) + renderMarkdown(text, cfg.color);
var maxW = hasMarkdown ? '96%' : '80%';
var common = "padding:9px 13px;border-radius:14px;max-width:"+maxW+";font-size:14px;word-break:break-word;line-height:1.4";
d.style.cssText = role === "user"
? common + ";align-self:flex-end;background:" + cfg.color + ";color:#fff;border-bottom-right-radius:4px"
: common + ";align-self:flex-start;background:#fff;color:#111827;border:1px solid #e5e7eb;border-bottom-left-radius:4px";
msgs.appendChild(d);
if (isAi) applyAiTags();
msgs.scrollTop = msgs.scrollHeight;
}
async function hydrateHistory() {
if (!sessionId) return;
try {
var res = await fetch(
API + "/history?session_id=" + encodeURIComponent(sessionId),
{ method: "GET", credentials: "omit" }
);
if (!res.ok) return;
var data = await res.json();
var msgs = data.messages || [];
msgs.forEach(function(m) { showMessage(m, m.role === "user" ? "user" : "bot"); });
if (data.last_delivered_at) {
cursor = data.last_delivered_at;
localStorage.setItem(CURSOR_KEY, cursor);
}
sessionStatus = data.session_status || null;
assignedAdminName = data.assigned_admin_name || null;
renderChatHeader();
} catch(_) {}
}
async function boot() {
contactData = loadContactData();
try {
var res = await fetch(API + "/config", { method: "GET", credentials: "omit" });
if (!res.ok) throw new Error("config");
cfg = await res.json();
} catch(_) {
cfg = { name: "Cható", color: "#7c3aed", channels: [{ kind: "webchat" }], pre_chat_form: { enabled: true } };
}
if (!cfg.channels || !cfg.channels.length) {
cfg.channels = [{ kind: "webchat" }];
}
buildUI();
if (sessionId) {
await hydrateHistory();
startPolling();
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();