Initial MaalFlows live chat scaffold
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
(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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
})[char]);
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return `vis_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user