Files
MaalFlows/public/widget.js
T

411 lines
18 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 = (boot.lang || document.documentElement.lang || navigator.language || "cs").slice(0, 2).toLowerCase();
const storageKey = `maalflows:${siteKey}`;
const session = loadSession();
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
const maxAttachmentBytes = 3_000_000;
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" type="button" aria-label="${copy.title}">
<span></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">Odeslanim zpravy nam predavate udaje potrebne pro odpoved.</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 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 = [];
launcher.addEventListener("click", () => {
panel.classList.toggle("open");
if (panel.classList.contains("open")) launcher.classList.remove("mf-notify");
});
close.addEventListener("click", () => panel.classList.remove("open"));
form.message.addEventListener("input", debounce(() => {
if (!session.conversationId) return;
fetch(`${apiBase}/api/widget/conversations/${session.conversationId}/typing`, { method: "POST" }).catch(() => {});
}, 700));
form.attachments.addEventListener("change", () => {
selectedFiles = mergeImageFiles(selectedFiles, [...form.attachments.files]);
renderSelectedFiles(attachmentList, selectedFiles);
form.attachments.value = "";
});
dropzone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
form.attachments.click();
}
});
for (const eventName of ["dragenter", "dragover"]) {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.add("dragging");
});
}
for (const eventName of ["dragleave", "drop"]) {
dropzone.addEventListener(eventName, (event) => {
event.preventDefault();
dropzone.classList.remove("dragging");
});
}
dropzone.addEventListener("drop", (event) => {
selectedFiles = mergeImageFiles(selectedFiles, [...event.dataTransfer.files]);
renderSelectedFiles(attachmentList, selectedFiles);
});
attachmentList.addEventListener("click", (event) => {
const removeButton = event.target.closest("[data-remove]");
if (!removeButton) return;
selectedFiles.splice(Number(removeButton.dataset.remove), 1);
renderSelectedFiles(attachmentList, selectedFiles);
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const body = await payloadFromForm(form, selectedFiles);
if (!body.message.trim() && !body.attachments.length) return;
const submitButton = root.querySelector(".mf-send");
submitButton.disabled = true;
const url = session.conversationId
? `${apiBase}/api/widget/conversations/${session.conversationId}/messages`
: `${apiBase}/api/widget/conversations`;
try {
const data = await postJson(url, withVisitorContext(body));
session.conversationId = data.conversation.id;
saveSession();
form.message.value = "";
selectedFiles = [];
renderSelectedFiles(attachmentList, selectedFiles);
renderConversation(messages, data);
connectEvents(messages, typing, panel, launcher, settings);
} catch {
showFormError(root, "Zpravu se nepodarilo odeslat. Zkuste to prosim znovu.");
} finally {
submitButton.disabled = false;
}
});
rememberPage();
if (session.conversationId) {
loadExistingConversation(messages).finally(() => connectEvents(messages, typing, panel, launcher, settings));
}
}
async function payloadFromForm(form, selectedFiles) {
const fd = new FormData(form);
return {
message: fd.get("message") || "",
attachments: await Promise.all(selectedFiles.slice(0, 3).map(fileToPayload))
};
}
function connectEvents(messages, typing, panel, launcher, settings) {
if (session.events || !session.conversationId) return;
session.events = new EventSource(`${apiBase}/api/widget/conversations/${session.conversationId}/events`);
session.events.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
if (data.type === "typing" && data.actor === "admin") {
typing.hidden = false;
clearTimeout(connectEvents.typingTimer);
connectEvents.typingTimer = setTimeout(() => { typing.hidden = true; }, 1800);
}
if (data.type === "message:new") {
renderConversation(messages, data.payload);
const eventMessages = data.payload?.messages || [];
const lastMessage = eventMessages[eventMessages.length - 1];
if (settings.notifications?.pulseOnAdminReply !== false && lastMessage?.senderType === "admin" && !panel.classList.contains("open")) {
launcher.classList.add("mf-notify");
}
}
});
}
async function loadExistingConversation(messages) {
try {
const data = await fetchJson(`${apiBase}/api/widget/conversations/${session.conversationId}?visitorToken=${encodeURIComponent(session.visitorToken)}`);
renderConversation(messages, data);
} catch {
session.conversationId = null;
saveSession();
}
}
function renderConversation(messages, data) {
messages.innerHTML = "";
for (const message of data.messages) {
addMessage(messages, message.senderType, message.body, message.attachments || []);
}
messages.scrollTop = messages.scrollHeight;
}
function addMessage(messages, sender, body, attachments) {
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);
messages.scrollTop = messages.scrollHeight;
}
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(),
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
device: {
userAgent: navigator.userAgent,
platform: navigator.platform,
viewport: `${innerWidth}x${innerHeight}`
}
};
}
function showFormError(root, message) {
const note = root.querySelector(".mf-note");
note.textContent = message;
note.classList.add("error");
clearTimeout(showFormError.timer);
showFormError.timer = setTimeout(() => {
note.textContent = "Odeslanim zpravy nam predavate udaje potrebne pro odpoved.";
note.classList.remove("error");
}, 3500);
}
function isAllowedImageFile(file) {
return allowedImageTypes.has(file.type) && file.size > 0 && file.size <= maxAttachmentBytes;
}
function localized(settings, language) {
const pick = (obj) => obj?.[language] || obj?.en || obj?.cs || "";
return {
title: pick(settings.title),
intro: pick(settings.intro),
offlineIntro: pick(settings.offlineIntro),
placeholder: pick(settings.placeholder),
sendLabel: pick(settings.sendLabel)
};
}
function styles(settings) {
const c = settings.colors;
const desktop = settings.desktop;
const mobile = settings.mobile;
return `
:host { all: initial; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: ${c.text}; }
* { box-sizing: border-box; }
button, input, textarea { font: inherit; }
.mf-launcher {
position: fixed; z-index: 2147483000; ${desktop.side}: ${desktop.sideOffsetPx}px; bottom: ${desktop.bottomPx}px;
width: 58px; height: 58px; border: 0; border-radius: 50%; background: ${c.brand}; color: ${c.brandText};
box-shadow: 0 12px 30px rgb(0 0 0 / 22%); cursor: pointer;
}
.mf-launcher span, .mf-launcher span:before, .mf-launcher span:after { display: block; background: ${c.brandText}; height: 3px; border-radius: 4px; content: ""; }
.mf-launcher span { width: 26px; margin: 0 auto; }
.mf-launcher span:before { transform: translateY(-8px); }
.mf-launcher span:after { transform: translateY(5px); width: 18px; }
.mf-launcher.mf-notify { animation: mfPulse 1.15s ease-out infinite; }
.mf-panel {
position: fixed; z-index: 2147483001; ${desktop.side}: ${desktop.sideOffsetPx}px; bottom: ${desktop.bottomPx + 72}px;
width: min(${desktop.widthPx}px, calc(100vw - 24px)); max-height: min(680px, calc(100vh - 110px)); display: none;
background: ${c.surface}; border: 1px solid #dfe6e2; border-radius: 8px; overflow: hidden; box-shadow: 0 18px 50px rgb(0 0 0 / 18%);
}
.mf-panel.open { display: grid; grid-template-rows: auto minmax(130px, 1fr) auto auto; }
header { position: relative; display: grid; gap: 3px; background: ${c.brand}; color: ${c.brandText}; padding: 15px 46px 15px 16px; }
header strong { font-size: 16px; }
header small { opacity: .92; font-size: 13px; line-height: 1.35; }
.mf-close { position: absolute; top: 9px; right: 9px; width: 30px; height: 30px; border: 0; border-radius: 6px; background: rgb(255 255 255 / 16%); color: ${c.brandText}; cursor: pointer; }
.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-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) 42px; gap: 9px; align-items: end; }
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: 42px; height: 42px; display: grid; place-items: center; border: 0; border-radius: 50%; 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: 42px; border: 1px dashed #b9c9c1; border-radius: 8px; display: flex; align-items: center; gap: 8px; padding: 9px 10px; color: ${c.muted}; background: #f8fbf9; font-size: 12px; cursor: pointer; }
.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-powered { justify-self: center; color: ${c.muted}; font-size: 11px; text-decoration: none; }
.mf-powered:hover { color: ${c.brand}; text-decoration: underline; }
@keyframes mfPulse {
0% { box-shadow: 0 12px 30px rgb(0 0 0 / 22%), 0 0 0 0 rgb(15 143 111 / 42%); transform: scale(1); }
70% { box-shadow: 0 12px 30px rgb(0 0 0 / 22%), 0 0 0 14px rgb(15 143 111 / 0%); transform: scale(1.04); }
100% { box-shadow: 0 12px 30px rgb(0 0 0 / 22%), 0 0 0 0 rgb(15 143 111 / 0%); transform: scale(1); }
}
@media (max-width: 640px) {
.mf-launcher { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx}px; }
.mf-panel { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx + 68}px; width: min(${mobile.widthPx}px, calc(100vw - 24px)); max-height: calc(100vh - 96px); }
}
`;
}
function rememberPage() {
const entry = { url: location.href, title: document.title, at: new Date().toISOString() };
session.history = (session.history || []).filter((item) => item.url !== entry.url).concat(entry).slice(-20);
saveSession();
return session.history;
}
function loadSession() {
try {
const existing = JSON.parse(localStorage.getItem(storageKey) || "{}");
return { visitorToken: existing.visitorToken || randomId(), conversationId: existing.conversationId || null, history: existing.history || [] };
} catch {
return { visitorToken: randomId(), conversationId: null, history: [] };
}
}
function saveSession() {
localStorage.setItem(storageKey, JSON.stringify({
visitorToken: session.visitorToken,
conversationId: session.conversationId,
history: session.history || []
}));
}
function postJson(url, payload) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});
}
function fetchJson(url) {
return fetch(url).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});
}
function fileToPayload(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve({ name: file.name, type: file.type, size: file.size, dataBase64: reader.result });
reader.readAsDataURL(file);
});
}
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
})[char]);
}
function randomId() {
return `vis_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
}
})();