Files
MaalFlows/public/widget.js
T

278 lines
12 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();
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">
<details>
<summary>${escapeHtml(copy.detailsLabel)}</summary>
<input name="name" autocomplete="name" placeholder="Jmeno">
<input name="email" type="email" autocomplete="email" placeholder="Email">
<input name="phone" autocomplete="tel" placeholder="Telefon">
</details>
<textarea name="message" required rows="3" placeholder="${escapeHtml(copy.placeholder)}"></textarea>
<div class="mf-actions">
<input name="attachments" type="file" accept="image/*" multiple>
<button type="submit">${escapeHtml(copy.sendLabel)}</button>
</div>
<p class="mf-note">Odeslanim zpravy nam predavate udaje potrebne pro odpoved.</p>
</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");
launcher.addEventListener("click", () => panel.classList.toggle("open"));
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.addEventListener("submit", async (event) => {
event.preventDefault();
const body = await payloadFromForm(form);
if (!body.message.trim()) return;
addMessage(messages, "visitor", body.message, []);
form.message.value = "";
form.attachments.value = "";
const url = session.conversationId
? `${apiBase}/api/widget/conversations/${session.conversationId}/messages`
: `${apiBase}/api/widget/conversations`;
const data = await postJson(url, {
...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}`
}
});
session.conversationId = data.conversation.conversation.id;
session.visitorToken = data.visitorToken || session.visitorToken;
saveSession();
renderConversation(messages, data.conversation);
connectEvents(messages, typing);
});
rememberPage();
if (session.conversationId) connectEvents(messages, typing);
}
async function payloadFromForm(form) {
const fd = new FormData(form);
const files = [...form.attachments.files].slice(0, 3);
return {
message: fd.get("message") || "",
visitorInfo: {
name: fd.get("name") || "",
email: fd.get("email") || "",
phone: fd.get("phone") || ""
},
attachments: await Promise.all(files.map(fileToPayload))
};
}
function connectEvents(messages, typing) {
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);
});
}
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 = `<p>${escapeHtml(body)}</p>${attachments.map((a) => `<a href="${apiBase}${a.url}" target="_blank" rel="noreferrer">${escapeHtml(a.name)}</a>`).join("")}`;
messages.append(item);
messages.scrollTop = messages.scrollHeight;
}
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),
detailsLabel: pick(settings.detailsLabel)
};
}
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-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-typing { padding: 0 14px 8px; color: ${c.muted}; font-size: 13px; }
.mf-form { border-top: 1px solid #dfe6e2; padding: 12px; display: grid; gap: 9px; }
details { color: ${c.muted}; font-size: 13px; }
details[open] { display: grid; gap: 8px; }
summary { cursor: pointer; margin-bottom: 8px; }
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-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 9px; align-items: center; }
.mf-actions button { border: 0; border-radius: 6px; background: ${c.brand}; color: ${c.brandText}; padding: 10px 13px; cursor: pointer; }
input[type=file] { font-size: 12px; padding: 7px; }
.mf-note { margin: 0; color: ${c.muted}; font-size: 11px; line-height: 1.3; }
@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 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)}`;
}
})();