(function () { const boot = window.MaalFlows || {}; const apiBase = (boot.apiBase || "").replace(/\/$/, "") || new URL(document.currentScript.src).origin; const siteKey = boot.siteKey || "9b-plus"; const lang = (boot.lang || document.documentElement.lang || navigator.language || "cs").slice(0, 2).toLowerCase(); const storageKey = `maalflows:${siteKey}`; const session = loadSession(); const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]); const maxAttachmentBytes = 3_000_000; let lastActivityAt = Date.now(); fetch(`${apiBase}/api/widget/config?siteKey=${encodeURIComponent(siteKey)}`) .then((res) => res.json()) .then((config) => mount(config)) .catch(() => {}); function mount(config) { const site = config.site; const settings = site.settings; const copy = localized(settings, lang); const host = document.createElement("div"); host.id = "maalflows-widget"; document.body.append(host); const root = host.attachShadow({ mode: "open" }); root.innerHTML = `
${escapeHtml(copy.title)} ${escapeHtml(site.isOnline ? copy.intro : copy.offlineIntro)}
Powered by MaalFlows
`; const panel = root.querySelector(".mf-panel"); const launcher = root.querySelector(".mf-launcher"); const close = root.querySelector(".mf-close"); const { soundButton, infoButton } = createHeaderActions(root, close); const form = root.querySelector(".mf-form"); const messages = root.querySelector(".mf-messages"); const typing = root.querySelector(".mf-typing"); const dropzone = root.querySelector(".mf-dropzone"); const attachmentList = root.querySelector(".mf-attachment-list"); let selectedFiles = []; renderSoundState(soundButton); launcher.addEventListener("click", () => { panel.classList.toggle("open"); if (panel.classList.contains("open")) { launcher.classList.remove("mf-notify"); scrollMessagesToBottom(messages); } }); close.addEventListener("click", () => panel.classList.remove("open")); soundButton.addEventListener("click", () => { session.soundMuted = !session.soundMuted; saveSession(); renderSoundState(soundButton); }); infoButton.addEventListener("click", () => { root.querySelector(".mf-gdpr").hidden = !root.querySelector(".mf-gdpr").hidden; }); form.message.addEventListener("input", debounce(() => { if (!session.conversationId) return; fetch(`${apiBase}/api/widget/conversations/${session.conversationId}/typing`, { method: "POST" }).catch(() => {}); }, 700)); form.message.addEventListener("keydown", (event) => { if (event.key !== "Enter" || event.shiftKey) return; event.preventDefault(); form.requestSubmit(); }); form.attachments.addEventListener("change", () => { selectedFiles = mergeImageFiles(selectedFiles, [...form.attachments.files]); renderSelectedFiles(attachmentList, selectedFiles); form.attachments.value = ""; }); dropzone.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); form.attachments.click(); } }); for (const eventName of ["dragenter", "dragover"]) { dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.classList.add("dragging"); }); } for (const eventName of ["dragleave", "drop"]) { dropzone.addEventListener(eventName, (event) => { event.preventDefault(); dropzone.classList.remove("dragging"); }); } dropzone.addEventListener("drop", (event) => { selectedFiles = mergeImageFiles(selectedFiles, [...event.dataTransfer.files]); renderSelectedFiles(attachmentList, selectedFiles); }); attachmentList.addEventListener("click", (event) => { const removeButton = event.target.closest("[data-remove]"); if (!removeButton) return; selectedFiles.splice(Number(removeButton.dataset.remove), 1); renderSelectedFiles(attachmentList, selectedFiles); }); form.addEventListener("submit", async (event) => { event.preventDefault(); const body = await payloadFromForm(form, selectedFiles); if (!body.message.trim() && !body.attachments.length) return; const submitButton = root.querySelector(".mf-send"); submitButton.disabled = true; const url = session.conversationId ? `${apiBase}/api/widget/conversations/${session.conversationId}/messages` : `${apiBase}/api/widget/conversations`; try { const data = await postJson(url, withVisitorContext(body)); session.conversationId = data.conversation.id; saveSession(); form.message.value = ""; selectedFiles = []; renderSelectedFiles(attachmentList, selectedFiles); renderConversation(messages, data); connectEvents(messages, typing, panel, launcher, settings); } catch { showFormError(root, "Zpravu se nepodarilo odeslat. Zkuste to prosim znovu."); } finally { submitButton.disabled = false; } }); rememberPage(); trackVisitorActivity(root); pingVisitor(); setInterval(pingVisitor, 25_000); document.addEventListener("visibilitychange", () => { if (!document.hidden) { noteActivity(); pingVisitor(); } }); if (session.conversationId) { loadExistingConversation(messages).finally(() => connectEvents(messages, typing, panel, launcher, settings)); } } async function payloadFromForm(form, selectedFiles) { const fd = new FormData(form); return { message: fd.get("message") || "", attachments: await Promise.all(selectedFiles.slice(0, 3).map(fileToPayload)) }; } function connectEvents(messages, typing, panel, launcher, settings) { if (session.events || !session.conversationId) return; session.events = new EventSource(`${apiBase}/api/widget/conversations/${session.conversationId}/events`); session.events.addEventListener("message", (event) => { const data = JSON.parse(event.data); if (data.type === "typing" && data.actor === "admin") { typing.hidden = false; clearTimeout(connectEvents.typingTimer); connectEvents.typingTimer = setTimeout(() => { typing.hidden = true; }, 1800); } if (data.type === "message:new") { renderConversation(messages, data.payload); const eventMessages = data.payload?.messages || []; const lastMessage = eventMessages[eventMessages.length - 1]; if (settings.notifications?.pulseOnAdminReply !== false && lastMessage?.senderType === "admin" && !panel.classList.contains("open")) { launcher.classList.add("mf-notify"); playNotifySound(); } } }); } function createHeaderActions(root, closeButton) { const actions = document.createElement("div"); actions.className = "mf-header-actions"; const soundButton = document.createElement("button"); soundButton.className = "mf-sound"; soundButton.type = "button"; soundButton.innerHTML = ` `; const infoButton = document.createElement("button"); infoButton.className = "mf-info"; infoButton.type = "button"; infoButton.setAttribute("aria-label", "GDPR informace"); infoButton.title = "GDPR informace"; infoButton.innerHTML = ``; closeButton.innerHTML = ``; closeButton.setAttribute("aria-label", "Zmensit chat"); closeButton.title = "Zmensit chat"; closeButton.before(actions); actions.append(soundButton, infoButton, closeButton); const gdpr = document.createElement("p"); gdpr.className = "mf-gdpr"; gdpr.hidden = true; gdpr.textContent = "Odeslanim zpravy nam predavate udaje potrebne pro odpoved. Historii chatu uchovavame pro zakaznickou podporu."; root.querySelector(".mf-form").prepend(gdpr); return { soundButton, infoButton }; } function renderSoundState(button) { button.classList.toggle("muted", Boolean(session.soundMuted)); button.setAttribute("aria-label", session.soundMuted ? "Zapnout zvuk" : "Vypnout zvuk"); button.title = session.soundMuted ? "Zapnout zvuk" : "Vypnout zvuk"; } function playNotifySound() { if (session.soundMuted) return; const AudioCtor = window.AudioContext || window.webkitAudioContext; if (!AudioCtor) return; const audio = new AudioCtor(); const gain = audio.createGain(); gain.gain.value = 0.04; gain.connect(audio.destination); [0, 0.12].forEach((offset, index) => { const osc = audio.createOscillator(); osc.type = "sine"; osc.frequency.value = index ? 980 : 760; osc.connect(gain); osc.start(audio.currentTime + offset); osc.stop(audio.currentTime + offset + 0.09); }); setTimeout(() => audio.close().catch(() => {}), 420); } async function loadExistingConversation(messages) { try { const data = await fetchJson(`${apiBase}/api/widget/conversations/${session.conversationId}?visitorToken=${encodeURIComponent(session.visitorToken)}`); renderConversation(messages, data); } catch { session.conversationId = null; saveSession(); } } function renderConversation(messages, data) { messages.innerHTML = ""; let lastAdminName = null; for (const message of data.messages) { addMessage(messages, message.senderType, message.senderName, message.body, message.attachments || [], lastAdminName); if (message.senderType === "admin") lastAdminName = message.senderName || "Operator"; else lastAdminName = null; } scrollMessagesToBottom(messages); } function addMessage(messages, sender, senderName, body, attachments, lastAdminName) { if (sender === "admin" && (senderName || "Operator") !== lastAdminName) { const label = document.createElement("div"); label.className = "mf-operator-label"; label.textContent = `${senderName || "Operator"} odpovida`; messages.append(label); } const item = document.createElement("article"); item.className = `mf-message ${sender}`; item.innerHTML = `${body ? `

${escapeHtml(body)}

` : ""}${attachments.map((a) => `${escapeHtml(a.name)}`).join("")}`; messages.append(item); for (const image of item.querySelectorAll("img")) { image.addEventListener("load", () => scrollMessagesToBottom(messages), { once: true }); image.addEventListener("error", () => scrollMessagesToBottom(messages), { once: true }); } scrollMessagesToBottom(messages); } function scrollMessagesToBottom(messages) { const scroll = () => { messages.scrollTop = messages.scrollHeight; messages.lastElementChild?.scrollIntoView({ block: "end" }); }; scroll(); requestAnimationFrame(scroll); setTimeout(scroll, 80); setTimeout(scroll, 240); } function mergeImageFiles(currentFiles, incomingFiles) { const nextFiles = [...currentFiles]; for (const file of incomingFiles) { if (!isAllowedImageFile(file)) continue; const exists = nextFiles.some((item) => item.name === file.name && item.size === file.size && item.lastModified === file.lastModified); if (!exists) nextFiles.push(file); if (nextFiles.length === 3) break; } return nextFiles; } function renderSelectedFiles(container, files) { container.innerHTML = files.map((file, index) => ` ${escapeHtml(file.name)} `).join(""); } function withVisitorContext(body) { return { ...body, siteKey, visitorToken: session.visitorToken, currentUrl: location.href, referrer: document.referrer, browsingHistory: rememberPage(), lastActivityAt: new Date(lastActivityAt).toISOString(), language: navigator.language, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, device: { userAgent: navigator.userAgent, platform: navigator.platform, viewport: `${innerWidth}x${innerHeight}` } }; } function pingVisitor() { postJson(`${apiBase}/api/widget/visitors/ping`, withVisitorContext({})).catch(() => {}); } function trackVisitorActivity(root) { const passive = { passive: true, capture: true }; for (const eventName of ["pointerdown", "keydown", "mousemove", "touchstart", "scroll"]) { document.addEventListener(eventName, noteActivity, passive); root.addEventListener(eventName, noteActivity, passive); } } function noteActivity() { const now = Date.now(); if (now - lastActivityAt < 1_000) return; lastActivityAt = now; } function showFormError(root, message) { const note = root.querySelector(".mf-note"); note.textContent = message; note.hidden = false; note.classList.add("error"); clearTimeout(showFormError.timer); showFormError.timer = setTimeout(() => { note.textContent = ""; note.hidden = true; note.classList.remove("error"); }, 3500); } function isAllowedImageFile(file) { return allowedImageTypes.has(file.type) && file.size > 0 && file.size <= maxAttachmentBytes; } function localized(settings, language) { const pick = (obj) => obj?.[language] || obj?.en || obj?.cs || ""; return { title: pick(settings.title), intro: pick(settings.intro), offlineIntro: pick(settings.offlineIntro), placeholder: pick(settings.placeholder), sendLabel: pick(settings.sendLabel) }; } function styles(settings) { const c = settings.colors; const desktop = settings.desktop; const mobile = settings.mobile; return ` :host { all: initial; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: ${c.text}; } * { box-sizing: border-box; } button, input, textarea { font: inherit; } .mf-launcher { position: fixed; z-index: 2147483000; ${desktop.side}: ${desktop.sideOffsetPx}px; bottom: ${desktop.bottomPx}px; width: 58px; height: 58px; border: 0; border-radius: 50%; background: ${c.brand}; color: ${c.brandText}; box-shadow: 0 12px 30px rgb(0 0 0 / 22%); cursor: pointer; } .mf-launcher span, .mf-launcher span:before, .mf-launcher span:after { display: block; background: ${c.brandText}; height: 3px; border-radius: 4px; content: ""; } .mf-launcher span { width: 26px; margin: 0 auto; } .mf-launcher span:before { transform: translateY(-8px); } .mf-launcher span:after { transform: translateY(5px); width: 18px; } .mf-launcher.mf-notify { animation: mfPulse 1.15s ease-out infinite; } .mf-panel { position: fixed; z-index: 2147483001; ${desktop.side}: ${desktop.sideOffsetPx}px; bottom: ${desktop.bottomPx + 72}px; width: min(${desktop.widthPx}px, calc(100vw - 24px)); max-height: min(680px, calc(100vh - 110px)); display: none; background: ${c.surface}; border: 1px solid #dfe6e2; border-radius: 8px; overflow: hidden; box-shadow: 0 18px 50px rgb(0 0 0 / 18%); } .mf-panel.open { display: grid; grid-template-rows: auto minmax(130px, 1fr) auto auto; } header { position: relative; display: grid; gap: 3px; background: ${c.brand}; color: ${c.brandText}; padding: 15px 122px 15px 16px; } header strong { font-size: 16px; } header small { opacity: .92; font-size: 13px; line-height: 1.35; } .mf-header-actions { position: absolute; top: 9px; right: 9px; display: flex; gap: 6px; } .mf-close, .mf-sound, .mf-info { width: 30px; height: 30px; display: grid; place-items: center; border: 0; border-radius: 6px; background: rgb(255 255 255 / 16%); color: ${c.brandText}; cursor: pointer; padding: 0; } .mf-close svg, .mf-sound svg, .mf-info svg { width: 19px; height: 19px; fill: currentColor; } .mf-sound .mf-sound-off { display: none; } .mf-sound.muted .mf-sound-on { display: none; } .mf-sound.muted .mf-sound-off { display: block; } .mf-messages { padding: 14px; overflow: auto; display: flex; flex-direction: column; gap: 10px; } .mf-message { max-width: 86%; padding: 9px 11px; border-radius: 8px; background: #f1f5f2; color: ${c.text}; } .mf-message.admin { align-self: flex-start; background: #eef7f4; } .mf-message.visitor { align-self: flex-end; background: ${c.brand}; color: ${c.brandText}; } .mf-operator-label { align-self: flex-start; margin: 4px 0 -4px; color: ${c.muted}; font-size: 12px; font-weight: 700; } .mf-message p { margin: 0; white-space: pre-wrap; } .mf-message a { color: inherit; display: inline-block; margin-top: 6px; } .mf-photo img { display: block; width: min(180px, 100%); max-height: 130px; object-fit: cover; border-radius: 6px; } .mf-typing { padding: 0 14px 8px; color: ${c.muted}; font-size: 13px; } .mf-form { border-top: 1px solid #dfe6e2; padding: 12px; display: grid; gap: 9px; } .mf-composer { display: grid; grid-template-columns: minmax(0, 1fr) 48px; gap: 9px; align-items: stretch; } input, textarea { width: 100%; border: 1px solid #dfe6e2; border-radius: 6px; padding: 9px 10px; color: ${c.text}; background: white; } textarea { resize: vertical; min-height: 72px; } .mf-send { width: 48px; min-height: 72px; height: 100%; display: grid; place-items: center; border: 0; border-radius: 6px; background: ${c.brand}; color: ${c.brandText}; cursor: pointer; } .mf-send:disabled { opacity: .55; cursor: wait; } .mf-send svg { width: 22px; height: 22px; fill: currentColor; transform: translateX(1px); } .mf-dropzone { min-height: 64px; border: 1px dashed #b9c9c1; border-radius: 8px; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 13px 12px; color: ${c.muted}; background: #f8fbf9; font-size: 12px; cursor: pointer; text-align: center; } .mf-dropzone.dragging { border-color: ${c.brand}; background: #eef8f4; color: ${c.text}; } .mf-dropzone input { display: none; } .mf-dropzone svg { width: 18px; height: 18px; fill: currentColor; flex: 0 0 auto; } .mf-attachment-list { display: flex; flex-wrap: wrap; gap: 6px; } .mf-attachment-chip { max-width: 100%; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #dfe6e2; border-radius: 999px; background: #f8fbf9; color: ${c.text}; padding: 5px 5px 5px 9px; font-size: 12px; } .mf-attachment-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 210px; } .mf-attachment-chip button { width: 22px; height: 22px; border: 0; border-radius: 50%; background: #e8eee9; color: ${c.text}; cursor: pointer; padding: 0; } .mf-note { margin: 0; color: ${c.muted}; font-size: 11px; line-height: 1.3; } .mf-note.error { color: #b42318; } .mf-gdpr { margin: 0; padding: 9px 10px; border: 1px solid #dfe6e2; border-radius: 8px; background: #f8fbf9; color: ${c.muted}; font-size: 11px; line-height: 1.35; } .mf-powered { justify-self: center; color: ${c.muted}; font-size: 11px; text-decoration: none; } .mf-powered:hover { color: ${c.brand}; text-decoration: underline; } @keyframes mfPulse { 0% { box-shadow: 0 12px 30px rgb(0 0 0 / 22%), 0 0 0 0 rgb(15 143 111 / 42%); transform: scale(1); } 70% { box-shadow: 0 12px 30px rgb(0 0 0 / 22%), 0 0 0 14px rgb(15 143 111 / 0%); transform: scale(1.04); } 100% { box-shadow: 0 12px 30px rgb(0 0 0 / 22%), 0 0 0 0 rgb(15 143 111 / 0%); transform: scale(1); } } @media (max-width: 640px) { .mf-launcher { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx}px; } .mf-panel { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx + 68}px; width: min(${mobile.widthPx}px, calc(100vw - 24px)); max-height: calc(100vh - 96px); } } `; } function rememberPage() { const entry = { url: location.href, title: document.title, at: new Date().toISOString() }; session.history = (session.history || []).filter((item) => item.url !== entry.url).concat(entry).slice(-20); saveSession(); return session.history; } function loadSession() { try { const existing = JSON.parse(localStorage.getItem(storageKey) || "{}"); return { visitorToken: existing.visitorToken || randomId(), conversationId: existing.conversationId || null, history: existing.history || [], soundMuted: Boolean(existing.soundMuted) }; } catch { return { visitorToken: randomId(), conversationId: null, history: [], soundMuted: false }; } } function saveSession() { localStorage.setItem(storageKey, JSON.stringify({ visitorToken: session.visitorToken, conversationId: session.conversationId, history: session.history || [], soundMuted: Boolean(session.soundMuted) })); } function postJson(url, payload) { return fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }); } function fetchJson(url) { return fetch(url).then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }); } function fileToPayload(file) { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = () => resolve({ name: file.name, type: file.type, size: file.size, dataBase64: reader.result }); reader.readAsDataURL(file); }); } function debounce(fn, delay) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; } function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); } function randomId() { return `vis_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`; } })();