(function () { const boot = window.MaalFlows || {}; const apiBase = (boot.apiBase || "").replace(/\/$/, "") || new URL(document.currentScript.src).origin; const siteKey = boot.siteKey || "9b-plus"; const lang = normalizeLanguage(boot.lang); const storageKey = `maalflows:${siteKey}`; const pendingStorageKey = `${storageKey}:pending`; 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(); let activeCopy = {}; fetch(`${apiBase}/api/widget/config?${new URLSearchParams({ siteKey, lang })}`) .then((res) => res.json()) .then((config) => mount(config)) .catch(() => {}); function mount(config) { const site = config.site; const settings = site.settings; const copy = config.copy || localized(settings, config.language || lang); activeCopy = copy; 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"); close.setAttribute("aria-label", copy.closeLabel || "Close"); close.title = copy.closeLabel || "Close"; const { infoButton } = createHeaderActions(root, close, copy); 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 = []; const messageEffectState = { unreadAdminCount: 0, knownAdminMessageId: 0 }; const closePanel = () => { panel.classList.remove("open"); launcher.classList.remove("mf-panel-open"); }; const openPanel = () => { panel.classList.add("open"); launcher.classList.add("mf-panel-open"); launcher.classList.remove("mf-notify"); clearMessageEffects(launcher); messageEffectState.unreadAdminCount = 0; updateMessageBadge(launcher, messageEffectState.unreadAdminCount); scrollMessagesToBottom(messages); markConversationSeen(); }; launcher.addEventListener("click", () => { if (panel.classList.contains("open")) { closePanel(); } else { openPanel(); } }); document.addEventListener("pointerdown", (event) => { if (!panel.classList.contains("open")) return; if (!window.matchMedia("(min-width: 641px)").matches) return; if (event.composedPath().includes(panel) || event.composedPath().includes(launcher)) return; closePanel(); }); close.addEventListener("click", closePanel); 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 pendingMessage = queuePendingMessage(body); const submitButton = root.querySelector(".mf-send"); form.message.value = ""; selectedFiles = []; renderSelectedFiles(attachmentList, selectedFiles); addMessage(messages, "visitor", "Visitor", body.message, body.attachments || [], null); submitButton.disabled = true; try { const data = await sendPendingMessage(pendingMessage); removePendingMessage(pendingMessage.clientMessageId); session.conversationId = data.conversation.id; saveSession(); handleConversationUpdate(data, messages, panel, launcher, settings, messageEffectState, { notify: false }); connectEvents(messages, typing, panel, launcher, settings, messageEffectState); startConversationPolling(messages, panel, launcher, settings, messageEffectState); flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState); } 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(); flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState); } }); window.addEventListener("online", () => flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState)); if (session.conversationId) { loadExistingConversation(messages, panel) .then((data) => { messageEffectState.knownAdminMessageId = latestAdminMessageId(data?.messages || []); }) .finally(() => { connectEvents(messages, typing, panel, launcher, settings, messageEffectState); startConversationPolling(messages, panel, launcher, settings, messageEffectState); flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState); }); } else { flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState); } } 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, messageEffectState) { 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") { handleConversationUpdate(data.payload, messages, panel, launcher, settings, messageEffectState, { notify: true }); } }); session.events.onerror = () => { session.events?.close(); session.events = null; clearTimeout(connectEvents.reconnectTimer); connectEvents.reconnectTimer = setTimeout(() => { connectEvents(messages, typing, panel, launcher, settings, messageEffectState); }, 2500); }; } async function flushPendingMessages(messages, typing, panel, launcher, settings, messageEffectState) { if (session.flushingPending) return; const pendingMessages = loadPendingMessages(); if (!pendingMessages.length) return; session.flushingPending = true; try { for (const pendingMessage of pendingMessages) { try { const data = await sendPendingMessage(pendingMessage); removePendingMessage(pendingMessage.clientMessageId); session.conversationId = data.conversation.id; saveSession(); handleConversationUpdate(data, messages, panel, launcher, settings, messageEffectState, { notify: false }); connectEvents(messages, typing, panel, launcher, settings, messageEffectState); startConversationPolling(messages, panel, launcher, settings, messageEffectState); } catch { break; } } } finally { session.flushingPending = false; } } async function sendPendingMessage(pendingMessage) { const { conversationId, ...body } = pendingMessage; return postWidgetMessage(body, conversationId || session.conversationId); } async function postWidgetMessage(body, conversationId) { const url = conversationId ? `${apiBase}/api/widget/conversations/${conversationId}/messages` : `${apiBase}/api/widget/conversations`; try { return await postJson(url, withVisitorContext(body)); } catch (error) { if (conversationId && (error.status === 403 || error.status === 404)) { session.conversationId = null; saveSession(); return postJson(`${apiBase}/api/widget/conversations`, withVisitorContext(body)); } throw error; } } function createHeaderActions(root, closeButton, copy) { const actions = document.createElement("div"); actions.className = "mf-header-actions"; const infoButton = document.createElement("button"); infoButton.className = "mf-info"; infoButton.type = "button"; infoButton.setAttribute("aria-label", copy.infoTitle || "GDPR informace"); infoButton.title = copy.infoTitle || "GDPR informace"; infoButton.innerHTML = ``; closeButton.innerHTML = ``; closeButton.setAttribute("aria-label", copy.closeLabel || "Zmensit chat"); closeButton.title = copy.closeLabel || "Zmensit chat"; closeButton.before(actions); actions.append(infoButton, closeButton); const gdpr = document.createElement("p"); gdpr.className = "mf-gdpr"; gdpr.hidden = true; gdpr.textContent = copy.infoText || ""; root.querySelector(".mf-form").prepend(gdpr); return { infoButton }; } 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(); return data; } catch { session.conversationId = null; saveSession(); return null; } } function startConversationPolling(messages, panel, launcher, settings, messageEffectState) { if (session.pollTimer || !session.conversationId) return; session.pollTimer = setInterval(async () => { if (!session.conversationId) return; try { const data = await fetchJson(`${apiBase}/api/widget/conversations/${session.conversationId}?visitorToken=${encodeURIComponent(session.visitorToken)}`); handleConversationUpdate(data, messages, panel, launcher, settings, messageEffectState, { notify: true }); } catch {} }, 10_000); } function handleConversationUpdate(data, messages, panel, launcher, settings, messageEffectState, options = {}) { if (!data?.messages) return; const previousAdminMessageId = messageEffectState.knownAdminMessageId || 0; const latestAdminId = latestAdminMessageId(data.messages); const hasNewAdminMessage = latestAdminId > previousAdminMessageId; renderConversation(messages, data); messageEffectState.knownAdminMessageId = Math.max(previousAdminMessageId, latestAdminId); if (panel.classList.contains("open")) { markConversationSeen(); messageEffectState.unreadAdminCount = 0; updateMessageBadge(launcher, messageEffectState.unreadAdminCount); clearMessageEffects(launcher); return; } if (options.notify !== false && hasNewAdminMessage) { messageEffectState.unreadAdminCount += 1; applyAdminReplyEffects(launcher, settings, messageEffectState.unreadAdminCount); } } function latestAdminMessageId(messages) { return messages.reduce((max, message) => { if (message.senderType !== "admin") return max; return Math.max(max, Number(message.id) || 0); }, 0); } function renderConversation(messages, data) { messages.innerHTML = ""; let lastAdminName = null; for (const message of data.messages) { const body = message.senderType === "admin" ? (message.translatedBody || message.body) : message.body; addMessage(messages, message.senderType, message.senderName, 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"} ${activeCopy.operatorReplyLabel || "odpovida"}`; messages.append(label); } const item = document.createElement("article"); item.className = `mf-message ${sender}`; item.innerHTML = `${body ? `

${escapeHtml(body)}

` : ""}${attachments.map((a) => { const src = attachmentSrc(a); return `${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); return item; } function attachmentSrc(attachment) { if (attachment?.dataBase64) return attachment.dataBase64; if (attachment?.url) return `${apiBase}${attachment.url}`; return ""; } 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) { if (settings.widgetCopy?.[language]) return settings.widgetCopy[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), dropzoneLabel: "Pridat fotku nebo pretahnout", onlineLabel: "Jsme online", offlineLabel: "Offline", newReplyLabel: "Nova odpoved", typingLabel: "Operator pise...", infoTitle: "GDPR informace", infoText: "Zpravu a technicke udaje relace zpracujeme jen pro odpoved na vas dotaz.", closeLabel: "Zmensit chat", operatorReplyLabel: "odpovida" }; } function normalizeLanguage(value) { const text = String(value || "").trim().toLowerCase().replace("_", "-"); const match = text.match(/^[a-z]{2,3}/); return match ? match[0] : ""; } function launcherEffectClasses(settings, isOnline) { const effects = settings.launcherEffects || {}; const shape = launcherShape(effects.shape); return [ `mf-shape-${shape}`, !isOnline ? "mf-offline" : "", isOnline ? "mf-online" : "", isOnline && effects.breathe ? "mf-effect-breathe" : "", isOnline && effects.ring ? "mf-effect-ring" : "", effects.hoverLabel ? "mf-effect-hover-label" : "", ].filter(Boolean).join(" "); } function launcherShape(value) { return ["circle", "bubble", "messenger", "whatsapp"].includes(value) ? value : "bubble"; } function applyAdminReplyEffects(launcher, settings, unreadCount) { const notifications = settings.notifications || {}; if (notifications.pulseOnAdminReply !== false) { launcher.classList.remove("mf-notify"); void launcher.offsetWidth; launcher.classList.add("mf-notify"); } if (notifications.badgeOnAdminReply !== false) updateMessageBadge(launcher, unreadCount); if (notifications.labelOnAdminReply !== false) { launcher.classList.add("mf-show-message-label"); clearTimeout(applyAdminReplyEffects.labelTimer); applyAdminReplyEffects.labelTimer = setTimeout(() => launcher.classList.remove("mf-show-message-label"), 4200); } if (notifications.wiggleOnAdminReply !== false) { launcher.classList.remove("mf-wiggle"); void launcher.offsetWidth; launcher.classList.add("mf-wiggle"); clearTimeout(applyAdminReplyEffects.wiggleTimer); applyAdminReplyEffects.wiggleTimer = setTimeout(() => launcher.classList.remove("mf-wiggle"), 1400); } } function clearMessageEffects(launcher) { launcher.classList.remove("mf-notify", "mf-wiggle", "mf-show-message-label"); clearTimeout(applyAdminReplyEffects.labelTimer); clearTimeout(applyAdminReplyEffects.wiggleTimer); } function updateMessageBadge(launcher, unreadCount) { const badge = launcher.querySelector(".mf-message-badge"); if (!badge) return; badge.hidden = unreadCount <= 0; badge.textContent = unreadCount > 9 ? "9+" : String(unreadCount); } function styles(settings) { const c = settings.colors; const desktop = settings.desktop; const mobile = settings.mobile; const labelOffset = desktop.side === "left" ? "left: 68px; right: auto;" : "right: 68px; left: auto;"; 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; overflow: visible; } .mf-launcher-icon, .mf-launcher-icon:before, .mf-launcher-icon:after { display: block; content: ""; } .mf-launcher-icon { position: relative; margin: 0 auto; color: ${c.brandText}; } .mf-shape-circle .mf-launcher-icon, .mf-shape-circle .mf-launcher-icon:before, .mf-shape-circle .mf-launcher-icon:after { height: 3px; border-radius: 4px; background: ${c.brandText}; } .mf-shape-circle .mf-launcher-icon { width: 26px; } .mf-shape-circle .mf-launcher-icon:before { transform: translateY(-8px); } .mf-shape-circle .mf-launcher-icon:after { transform: translateY(5px); width: 18px; } .mf-shape-bubble .mf-launcher-icon { width: 30px; height: 23px; border: 3px solid ${c.brandText}; border-radius: 13px; background: transparent; } .mf-shape-bubble .mf-launcher-icon:before { position: absolute; left: 5px; top: 7px; width: 4px; height: 4px; border-radius: 50%; background: ${c.brandText}; box-shadow: 8px 0 0 ${c.brandText}, 16px 0 0 ${c.brandText}; } .mf-shape-bubble .mf-launcher-icon:after { position: absolute; right: 2px; bottom: -8px; width: 10px; height: 10px; border-left: 3px solid ${c.brandText}; border-bottom: 3px solid ${c.brandText}; border-bottom-left-radius: 8px; transform: rotate(-18deg); background: transparent; } .mf-shape-messenger .mf-launcher-icon { width: 32px; height: 32px; border-radius: 50%; background: ${c.brandText}; } .mf-shape-messenger .mf-launcher-icon:before { position: absolute; left: 8px; top: 8px; width: 16px; height: 16px; background: ${c.brand}; clip-path: polygon(0 56%, 42% 56%, 58% 22%, 100% 22%, 58% 78%, 42% 78%); } .mf-shape-messenger .mf-launcher-icon:after { position: absolute; right: 5px; bottom: 1px; width: 8px; height: 8px; border-radius: 0 0 8px 0; background: ${c.brandText}; transform: rotate(25deg); } .mf-shape-whatsapp .mf-launcher-icon { width: 32px; height: 32px; border: 3px solid ${c.brandText}; border-radius: 50%; background: transparent; } .mf-shape-whatsapp .mf-launcher-icon:before { position: absolute; left: 8px; top: 7px; width: 12px; height: 15px; border: 3px solid ${c.brandText}; border-left-color: transparent; border-top-color: transparent; border-radius: 0 0 12px 0; transform: rotate(-35deg); } .mf-shape-whatsapp .mf-launcher-icon:after { position: absolute; left: 1px; bottom: -4px; width: 10px; height: 10px; border-left: 3px solid ${c.brandText}; border-bottom: 3px solid ${c.brandText}; transform: rotate(12deg); background: transparent; } .mf-launcher-label { position: absolute; ${labelOffset} top: 50%; transform: translateY(-50%) translateX(8px); opacity: 0; pointer-events: none; white-space: nowrap; border-radius: 999px; padding: 7px 10px; background: ${c.text}; color: white; font-size: 12px; box-shadow: 0 10px 24px rgb(0 0 0 / 18%); transition: opacity .18s ease, transform .18s ease; } .mf-online.mf-effect-hover-label:hover .mf-launcher-label { opacity: 1; transform: translateY(-50%) translateX(0); } .mf-message-label { position: absolute; ${labelOffset} top: 50%; transform: translateY(-50%) translateX(8px); opacity: 0; pointer-events: none; white-space: nowrap; border-radius: 999px; padding: 7px 10px; background: ${c.text}; color: white; font-size: 12px; font-weight: 700; box-shadow: 0 10px 24px rgb(0 0 0 / 18%); transition: opacity .18s ease, transform .18s ease; } .mf-show-message-label .mf-message-label { opacity: 1; transform: translateY(-50%) translateX(0); } .mf-message-badge { position: absolute; top: -5px; right: -5px; min-width: 22px; height: 22px; display: grid; place-items: center; border-radius: 999px; border: 2px solid white; background: #ff3b30; color: white; font-size: 12px; font-weight: 800; line-height: 1; box-shadow: 0 8px 18px rgb(0 0 0 / 18%); } .mf-message-badge[hidden] { display: none !important; } .mf-reply-pulse { position: absolute; inset: 0; border-radius: 50%; pointer-events: none; opacity: 0; } .mf-online.mf-effect-breathe { animation: mfBreathe 2.8s ease-in-out infinite; } .mf-online.mf-effect-ring:before { position: absolute; inset: -5px; border: 2px solid ${c.brand}; border-radius: 50%; opacity: .62; content: ""; animation: mfRing 2.2s ease-out infinite; } .mf-offline { filter: grayscale(.2) saturate(.72); opacity: .86; } .mf-notify .mf-reply-pulse { animation: mfPulse 1.15s ease-out infinite; } .mf-wiggle .mf-launcher-icon { animation: mfWiggle .62s ease-in-out 2; } .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 118px 15px 16px; } header strong { font-size: 16px; } header small { opacity: .92; font-size: 13px; line-height: 1.35; } .mf-header-actions { position: absolute; top: 10px; right: 10px; display: flex; gap: 8px; } .mf-close, .mf-info { width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 8px; background: rgb(255 255 255 / 16%); color: ${c.brandText}; cursor: pointer; padding: 0; } .mf-close svg, .mf-info svg { width: 24px; height: 24px; fill: currentColor; } .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 0 0 0 rgb(255 59 48 / 46%); opacity: .9; transform: scale(1); } 70% { box-shadow: 0 0 0 18px rgb(255 59 48 / 0%); opacity: 0; transform: scale(1.08); } 100% { box-shadow: 0 0 0 0 rgb(255 59 48 / 0%); opacity: 0; transform: scale(1); } } @keyframes mfBreathe { 0%, 100% { transform: scale(1); box-shadow: 0 12px 30px rgb(0 0 0 / 22%); } 50% { transform: scale(1.035); box-shadow: 0 16px 36px rgb(0 0 0 / 20%), 0 0 0 8px rgb(15 143 111 / 10%); } } @keyframes mfRing { 0% { transform: scale(.92); opacity: .58; } 70% { transform: scale(1.22); opacity: 0; } 100% { transform: scale(.92); opacity: 0; } } @keyframes mfWiggle { 0%, 100% { transform: translateY(0) rotate(0deg); } 22% { transform: translateY(-3px) rotate(-4deg); } 48% { transform: translateY(2px) rotate(3deg); } 72% { transform: translateY(-1px) rotate(-2deg); } } @media (max-width: 640px) { .mf-launcher { ${mobile.side}: max(${mobile.sideOffsetPx}px, env(safe-area-inset-${mobile.side}, 0px)); bottom: max(${mobile.bottomPx}px, env(safe-area-inset-bottom, 0px)); } .mf-launcher.mf-panel-open { display: none; } .mf-panel { left: 0; right: 0; bottom: 0; top: 0; width: 100vw; max-width: none; height: 100dvh; max-height: none; border: 0; border-radius: 0; box-shadow: none; } .mf-panel.open { grid-template-rows: auto minmax(0, 1fr) auto auto; } header { padding-top: max(15px, env(safe-area-inset-top, 0px)); } .mf-header-actions { top: max(10px, env(safe-area-inset-top, 0px)); right: 10px; gap: 8px; } .mf-close, .mf-info { width: 44px; height: 44px; border-radius: 8px; } .mf-close svg, .mf-info svg { width: 26px; height: 26px; } .mf-messages { min-height: 0; padding: 14px 12px; } .mf-form { padding: 10px 12px max(12px, env(safe-area-inset-bottom, 0px)); gap: 8px; } textarea { min-height: 60px; max-height: 96px; } .mf-send { min-height: 60px; } .mf-dropzone { min-height: 54px; padding: 10px 12px; } } `; } 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 || [] }; } catch { return { visitorToken: randomId(), conversationId: null, history: [] }; } } function saveSession() { localStorage.setItem(storageKey, JSON.stringify({ visitorToken: session.visitorToken, conversationId: session.conversationId, history: session.history || [] })); } function queuePendingMessage(body) { const pendingMessage = { ...body, conversationId: session.conversationId, clientMessageId: body.clientMessageId || randomId("msg"), queuedAt: new Date().toISOString() }; const pendingMessages = loadPendingMessages().filter((item) => item.clientMessageId !== pendingMessage.clientMessageId); pendingMessages.push(pendingMessage); savePendingMessages(pendingMessages.slice(-10)); return pendingMessage; } function removePendingMessage(clientMessageId) { savePendingMessages(loadPendingMessages().filter((item) => item.clientMessageId !== clientMessageId)); } function loadPendingMessages() { try { const items = JSON.parse(localStorage.getItem(pendingStorageKey) || "[]"); return Array.isArray(items) ? items.filter((item) => item?.clientMessageId) : []; } catch { return []; } } function savePendingMessages(messages) { try { localStorage.setItem(pendingStorageKey, JSON.stringify(messages)); } catch {} } function postJson(url, payload) { return fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).then((res) => { if (!res.ok) { const error = new Error(`HTTP ${res.status}`); error.status = res.status; throw error; } 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(prefix = "vis") { return `${prefix}_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`; } })();