(function() { var TENANT_SLUG = "chato"; var API = "https://api.heychato.com/api/chat/" + TENANT_SLUG; var SESSION_KEY = "chato_session_" + TENANT_SLUG; var CURSOR_KEY = "chato_cursor_" + TENANT_SLUG; var POLL_INTERVAL_MS = 2000; var STATUS_INTERVAL_MS = 60000; var CONTACT_KEY = "chato_contact_" + TENANT_SLUG; var sessionId = localStorage.getItem(SESSION_KEY) || null; var cursor = localStorage.getItem(CURSOR_KEY) || null; var pollTimer = null; var statusTimer = null; var cfg = null; var menuOpen = false; var chatOpen = false; var chatStatus = null; // { online: bool, reason: string } var sessionStatus = null; // "bot" | "human" var assignedAdminName = null; var typingByMe = false; var typingByAgent = false; var contactData = null; // { name, email } from pre-chat form var ICONS = { webchat: '', whatsapp: '', telegram: '', phone: '', email: '' }; var LABELS = { webchat: "Chat en vivo", whatsapp: "WhatsApp", telegram: "Telegram", phone: "Llamar", email: "Email" }; function bumpCursor(ts) { if (!ts) return; if (!cursor || ts > cursor) { cursor = ts; localStorage.setItem(CURSOR_KEY, ts); } } function loadContactData() { var raw = localStorage.getItem(CONTACT_KEY); if (!raw) return null; try { return JSON.parse(raw); } catch(_) { return null; } } function saveContactData(data) { contactData = data; localStorage.setItem(CONTACT_KEY, JSON.stringify(data)); } function needsPreChatForm() { return cfg && cfg.pre_chat_form && cfg.pre_chat_form.enabled && !contactData; } function dismissPreChatForm() { var pf = document.getElementById("chato-prechat"); if (pf) pf.style.display = "none"; var msgs = document.getElementById("chato-msgs"); var frm = document.getElementById("chato-form"); if (msgs) msgs.style.display = "flex"; if (frm) frm.style.display = "flex"; var inp = document.getElementById("chato-input"); if (inp) setTimeout(function() { inp.focus(); }, 60); } function showPreChatForm() { var msgs = document.getElementById("chato-msgs"); var frm = document.getElementById("chato-form"); var box = document.getElementById("chato-box"); if (!msgs || !frm || !box) return; msgs.style.display = "none"; frm.style.display = "none"; var pf = document.getElementById("chato-prechat"); if (!pf) { pf = document.createElement("div"); pf.id = "chato-prechat"; pf.style.cssText = "flex:1;overflow-y:auto;padding:20px 16px;display:flex;flex-direction:column;gap:14px;background:#f9fafb"; box.insertBefore(pf, frm); } else { pf.style.display = "flex"; } pf.innerHTML = '

Para comenzar, compartinos algunos datos:

' + '
' + '
' + '' + '' + '
' + '
' + '' + '' + '
' + '' + '' + '
'; var nameInput = document.getElementById("chato-pf-name"); if (nameInput) setTimeout(function() { nameInput.focus(); }, 80); function submitPrechat() { var nm = (document.getElementById("chato-pf-name").value || "").trim(); var em = (document.getElementById("chato-pf-email").value || "").trim(); var err = document.getElementById("chato-pf-err"); if (!nm) { if (err) err.style.display = "block"; var ni = document.getElementById("chato-pf-name"); if (ni) ni.focus(); return; } if (err) err.style.display = "none"; saveContactData({ name: nm, email: em || null }); dismissPreChatForm(); } document.getElementById("chato-pf-submit").onclick = submitPrechat; ["chato-pf-name", "chato-pf-email"].forEach(function(id) { var el = document.getElementById(id); if (el) el.onkeydown = function(e) { if (e.key === "Enter") submitPrechat(); }; }); } async function pollOnce() { if (!sessionId) return; try { var url = API + "/poll?session_id=" + encodeURIComponent(sessionId); if (cursor) url += "&last_delivered_at=" + encodeURIComponent(cursor); var res = await fetch(url, { method: "GET", credentials: "omit" }); if (!res.ok) return; var data = await res.json(); var msgs = data.messages || []; if (msgs.length) setTypingByMe(false); msgs.forEach(function(m) { showMessage(m, "bot"); }); bumpCursor(data.last_delivered_at); var prevStatus = sessionStatus, prevName = assignedAdminName; sessionStatus = data.session_status || null; assignedAdminName = data.assigned_admin_name || null; if (sessionStatus !== prevStatus || assignedAdminName !== prevName) { renderChatHeader(); } setTypingByAgent(!!data.agent_typing); } catch(_) {} } function startPolling() { if (pollTimer) return; pollOnce(); pollTimer = setInterval(pollOnce, POLL_INTERVAL_MS); } function channelHref(ch) { if (ch.kind === "whatsapp") return "https://wa.me/" + ch.value.replace(/[^0-9]/g, ""); if (ch.kind === "telegram") return "https://t.me/" + ch.value.replace(/^@/, ""); if (ch.kind === "phone") return "tel:" + ch.value; if (ch.kind === "email") return "mailto:" + ch.value; return "#"; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, function(c) { return { "&":"&","<":"<",">":">","\"":""","'":"'" }[c]; }); } // ---- Attachments (spec 0007) ---------------------------------------- // One pending file at a time: the real case is "here's the photo", and a // multi-file tray buys complexity nobody asked for. The backend accepts // up to 5 per message. var pendingAttachment = null; // shape: id / original_name / mime_type / size_bytes / url var uploading = false; function humanSize(bytes) { if (bytes < 1024) return bytes + " B"; if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + " KB"; return (bytes / (1024 * 1024)).toFixed(1) + " MB"; } function isImageMime(m) { return typeof m === "string" && m.indexOf("image/") === 0; } function setSendDisabled(v) { var btn = document.getElementById("chato-send"); if (btn) { btn.disabled = !!v; btn.style.opacity = v ? "0.5" : "1"; } } function renderAttachBar() { var bar = document.getElementById("chato-attach-bar"); if (!bar) return; if (!pendingAttachment && !uploading) { bar.style.display = "none"; bar.innerHTML = ""; return; } bar.style.display = "block"; if (uploading) { bar.innerHTML = '
Subiendo archivo…
'; return; } bar.innerHTML = '
' + '' + escapeHtml(pendingAttachment.original_name) + '' + '' + humanSize(pendingAttachment.size_bytes) + '' + '' + '
'; 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 '' + '' + escapeHtml(att.original_name) + ''; } 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'; } 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'; } 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 += ''; continue; } if (/^\s*\d+\.\s/.test(line)) { html += '
    '; while (i < lines.length && /^\s*\d+\.\s/.test(lines[i])) { html += '
  1. '+inline(lines[i].replace(/^\s*\d+\.\s+/,''))+'
  2. '; 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(); } })();