(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)}
Operator pise...
`;
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);
markConversationSeen();
}
});
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, panel).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);
if (panel.classList.contains("open")) markConversationSeen();
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, panel) {
try {
const data = await fetchJson(`${apiBase}/api/widget/conversations/${session.conversationId}?visitorToken=${encodeURIComponent(session.visitorToken)}`);
renderConversation(messages, data);
if (panel.classList.contains("open")) markConversationSeen();
} 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 markConversationSeen() {
if (!session.conversationId) return;
clearTimeout(markConversationSeen.timer);
markConversationSeen.timer = setTimeout(() => {
postJson(`${apiBase}/api/widget/conversations/${session.conversationId}/seen`, { visitorToken: session.visitorToken }).catch(() => {});
}, 250);
}
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) => `
`).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)}`;
}
})();