743 lines
34 KiB
JavaScript
743 lines
34 KiB
JavaScript
(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 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();
|
||
|
||
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 = `
|
||
<style>${styles(settings)}</style>
|
||
<button class="mf-launcher ${launcherEffectClasses(settings, site.isOnline)}" type="button" aria-label="${copy.title}">
|
||
<span class="mf-reply-pulse" aria-hidden="true"></span>
|
||
<span class="mf-launcher-icon"></span>
|
||
<span class="mf-launcher-label">${escapeHtml(site.isOnline ? "Jsme online" : "Offline")}</span>
|
||
<span class="mf-message-badge" hidden>0</span>
|
||
<span class="mf-message-label">Nova odpoved</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>Operator pise...</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>Pridat fotku nebo pretahnout</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");
|
||
const { 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 = [];
|
||
const messageEffectState = {
|
||
unreadAdminCount: 0,
|
||
knownAdminMessageId: 0
|
||
};
|
||
|
||
launcher.addEventListener("click", () => {
|
||
panel.classList.toggle("open");
|
||
if (panel.classList.contains("open")) {
|
||
launcher.classList.remove("mf-notify");
|
||
clearMessageEffects(launcher);
|
||
messageEffectState.unreadAdminCount = 0;
|
||
updateMessageBadge(launcher, messageEffectState.unreadAdminCount);
|
||
scrollMessagesToBottom(messages);
|
||
markConversationSeen();
|
||
}
|
||
});
|
||
close.addEventListener("click", () => panel.classList.remove("open"));
|
||
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");
|
||
submitButton.disabled = true;
|
||
|
||
try {
|
||
const data = await sendPendingMessage(pendingMessage);
|
||
removePendingMessage(pendingMessage.clientMessageId);
|
||
session.conversationId = data.conversation.id;
|
||
saveSession();
|
||
form.message.value = "";
|
||
selectedFiles = [];
|
||
renderSelectedFiles(attachmentList, selectedFiles);
|
||
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) {
|
||
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", "GDPR informace");
|
||
infoButton.title = "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="M6.7 8.7 12 14l5.3-5.3 1.4 1.4L12 16.8l-6.7-6.7 1.4-1.4Z"></path></svg>`;
|
||
closeButton.setAttribute("aria-label", "Zmensit chat");
|
||
closeButton.title = "Zmensit chat";
|
||
closeButton.before(actions);
|
||
actions.append(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 { 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) {
|
||
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 ? `<p>${escapeHtml(body)}</p>` : ""}${attachments.map((a) => `<a class="mf-photo" href="${apiBase}${a.url}" target="_blank" rel="noreferrer"><img src="${apiBase}${a.url}" 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);
|
||
}
|
||
|
||
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) {
|
||
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 launcherEffectClasses(settings, isOnline) {
|
||
const effects = settings.launcherEffects || {};
|
||
return [
|
||
isOnline ? "mf-online" : "mf-offline",
|
||
effects.breathe ? "mf-effect-breathe" : "",
|
||
effects.ring ? "mf-effect-ring" : "",
|
||
effects.hoverLabel ? "mf-effect-hover-label" : "",
|
||
effects.avatar ? "mf-effect-avatar" : ""
|
||
].filter(Boolean).join(" ");
|
||
}
|
||
|
||
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; background: ${c.brandText}; height: 3px; border-radius: 4px; content: ""; }
|
||
.mf-launcher-icon { width: 26px; margin: 0 auto; }
|
||
.mf-launcher-icon:before { transform: translateY(-8px); }
|
||
.mf-launcher-icon:after { transform: translateY(5px); width: 18px; }
|
||
.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-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-effect-avatar .mf-launcher-icon { width: 26px; height: 20px; border-radius: 12px 12px 10px 10px; background: rgb(255 255 255 / 24%); border: 2px solid ${c.brandText}; position: relative; }
|
||
.mf-effect-avatar .mf-launcher-icon:before { position: absolute; left: 6px; top: 6px; width: 4px; height: 4px; border-radius: 50%; background: ${c.brandText}; transform: none; box-shadow: 10px 0 0 ${c.brandText}; }
|
||
.mf-effect-avatar .mf-launcher-icon:after { position: absolute; left: 7px; top: 13px; width: 12px; height: 2px; border-radius: 999px; background: ${c.brandText}; transform: none; opacity: .88; }
|
||
.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 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-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-info svg { width: 19px; height: 19px; 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}: ${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 || [] };
|
||
} 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)}`;
|
||
}
|
||
})();
|