Files
MaalFlows/public/widget.js
T

890 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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 = `
<style>${styles(settings)}</style>
<button class="mf-launcher ${launcherEffectClasses(settings, site.isOnline)}" type="button" aria-label="${escapeHtml(copy.title)}">
<span class="mf-reply-pulse" aria-hidden="true"></span>
<span class="mf-status-dot" aria-hidden="true"></span>
<span class="mf-launcher-icon"></span>
<span class="mf-launcher-label">${escapeHtml(site.isOnline ? copy.onlineLabel : copy.offlineLabel)}</span>
<span class="mf-message-badge" hidden>0</span>
<span class="mf-message-label">${escapeHtml(copy.newReplyLabel)}</span>
</button>
<section class="mf-panel" aria-live="polite">
<header>
<strong>${escapeHtml(copy.title)}</strong>
<small>${escapeHtml(site.isOnline ? copy.intro : copy.offlineIntro)}</small>
<button class="mf-close" type="button" aria-label="Close">×</button>
</header>
<div class="mf-messages"></div>
<div class="mf-typing" hidden>${escapeHtml(copy.typingLabel)}</div>
<form class="mf-form">
<div class="mf-composer">
<textarea name="message" rows="3" placeholder="${escapeHtml(copy.placeholder)}"></textarea>
<button class="mf-send" type="submit" aria-label="${escapeHtml(copy.sendLabel)}" title="${escapeHtml(copy.sendLabel)}">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3.7 20.3 21 12 3.7 3.7l2.5 7.1L14 12l-7.8 1.2-2.5 7.1Z"></path>
</svg>
</button>
</div>
<label class="mf-dropzone" tabindex="0">
<input name="attachments" type="file" accept=".jpg,.jpeg,.png,.webp,.gif,.avif,image/jpeg,image/png,image/webp,image/gif,image/avif" multiple>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 17.5V6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5Zm3.3-1.2 2.9-3.4 2 2.4 2.7-3.2 3.1 4.2H7.3Zm1-6.8a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4Z"></path>
</svg>
<span>${escapeHtml(copy.dropzoneLabel)}</span>
</label>
<div class="mf-attachment-list"></div>
<p class="mf-note" hidden></p>
<a class="mf-powered" href="https://app.black-week.cz" target="_blank" rel="noopener noreferrer">Powered by MaalFlows</a>
</form>
</section>
`;
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 = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 10h2v7h-2v-7Zm0-3h2v2h-2V7Zm1-5a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16Z"></path></svg>`;
closeButton.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7.05 5.64 4.95 4.95 4.95-4.95 1.41 1.41L13.41 12l4.95 4.95-1.41 1.41L12 13.41l-4.95 4.95-1.41-1.41L10.59 12 5.64 7.05l1.41-1.41Z"></path></svg>`;
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 ? `<p>${escapeHtml(body)}</p>` : ""}${attachments.map((a) => {
const src = attachmentSrc(a);
return `<a class="mf-photo" href="${src}" target="_blank" rel="noreferrer"><img src="${src}" alt="${escapeHtml(a.name)}"></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);
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) => `
<span class="mf-attachment-chip">
<span>${escapeHtml(file.name)}</span>
<button type="button" data-remove="${index}" aria-label="Odebrat fotku">×</button>
</span>
`).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.statusDot ? "mf-effect-status-dot" : "",
effects.persistentLabel ? "mf-effect-persistent-label" : "",
isOnline && effects.sway ? "mf-effect-sway" : "",
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 || {};
launcher.classList.add("mf-has-message-alert");
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);
}
if (notifications.wiggleOnAdminReply !== false) {
launcher.classList.remove("mf-wiggle");
void launcher.offsetWidth;
launcher.classList.add("mf-wiggle");
}
}
function clearMessageEffects(launcher) {
launcher.classList.remove("mf-notify", "mf-wiggle", "mf-show-message-label", "mf-has-message-alert");
clearTimeout(applyAdminReplyEffects.labelTimer);
}
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-effect-persistent-label .mf-launcher-label { opacity: 1; transform: translateY(-50%) translateX(0); }
.mf-has-message-alert .mf-launcher-label { opacity: 0; transform: translateY(-50%) translateX(8px); }
.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-status-dot { position: absolute; top: 4px; right: 4px; width: 14px; height: 14px; display: none; border: 3px solid white; border-radius: 50%; background: #c9d4ce; box-shadow: 0 2px 6px rgb(0 0 0 / 14%); }
.mf-effect-status-dot .mf-status-dot { display: block; }
.mf-has-message-alert .mf-status-dot { display: none; }
.mf-online .mf-status-dot { background: #2f9e44; }
.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-online.mf-effect-sway .mf-launcher-icon { animation: mfWiggle 7.2s ease-in-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 7.2s ease-in-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 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%, 12%, 100% { transform: translateY(0) rotate(0deg); }
3% { transform: translateY(-3px) rotate(-4deg); }
6% { transform: translateY(2px) rotate(3deg); }
9% { 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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
})[char]);
}
function randomId(prefix = "vis") {
return `${prefix}_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
}
})();