Initial MaalFlows live chat scaffold

This commit is contained in:
rajchales-monito
2026-07-23 20:29:57 +02:00
commit f5c32d3c2d
11 changed files with 2060 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--line: #dfe6e2;
--ink: #17211d;
--muted: #627067;
--brand: #0f8f6f;
--bg: #f5f7f4;
--panel: #ffffff;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); }
button, input, textarea, select { font: inherit; }
button {
border: 0;
background: var(--brand);
color: white;
border-radius: 6px;
padding: 10px 14px;
cursor: pointer;
}
button:hover { filter: brightness(0.96); }
input, textarea, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 9px 10px;
background: white;
color: var(--ink);
}
label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
.hidden { display: none !important; }
.shell { min-height: 100vh; }
.login { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.panel {
width: min(360px, 100%);
display: grid;
gap: 14px;
background: white;
border: 1px solid var(--line);
border-radius: 8px;
padding: 24px;
}
.panel h1 { margin: 0 0 6px; }
.error { min-height: 20px; color: #b42318; margin: 0; }
.app {
min-height: 100vh;
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
}
.sidebar {
border-right: 1px solid var(--line);
background: var(--panel);
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
}
.brand, .sitebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
border-bottom: 1px solid var(--line);
}
.brand h1 { margin: 0; font-size: 20px; }
.brand button, .sitebar button { background: #edf3ef; color: var(--ink); }
.switch { display: flex; align-items: center; gap: 8px; }
.switch input { width: auto; }
.conversation-list { overflow: auto; }
.conversation-item {
width: 100%;
text-align: left;
border: 0;
border-bottom: 1px solid var(--line);
background: white;
color: var(--ink);
border-radius: 0;
padding: 12px 14px;
display: grid;
gap: 5px;
}
.conversation-item.active { background: #eaf6f1; }
.conversation-item.fresh { animation: pulse 1.2s ease-in-out 4; }
.row { display: flex; justify-content: space-between; gap: 10px; align-items: center; }
.status {
font-size: 12px;
color: white;
background: #456257;
border-radius: 999px;
padding: 2px 8px;
}
.snippet, .preview, .meta { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conversation { display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto; min-width: 0; }
.conversation-header {
min-height: 74px;
padding: 16px 20px;
background: white;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
gap: 20px;
}
.conversation-header h2 { margin: 0 0 5px; font-size: 20px; }
.conversation-header p { margin: 0; color: var(--muted); font-size: 13px; }
.conversation-header select { width: 150px; align-self: start; }
.messages {
overflow: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
max-width: min(680px, 86%);
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: white;
}
.message.admin { align-self: flex-end; background: #ecf8f3; border-color: #c7e8dc; }
.message .by { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.message p { margin: 0; white-space: pre-wrap; }
.message a { color: var(--brand); display: inline-block; margin-top: 6px; }
.typing { padding: 0 20px 10px; color: var(--brand); font-size: 13px; }
.reply {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 14px;
background: white;
border-top: 1px solid var(--line);
}
.settings {
position: fixed;
inset: 0 0 0 auto;
width: min(430px, 100%);
overflow: auto;
background: white;
border-left: 1px solid var(--line);
padding: 16px;
box-shadow: -12px 0 30px rgb(0 0 0 / 12%);
}
.settings-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.settings h2 { margin: 0 0 12px; }
.settings h3 { margin: 20px 0 8px; }
.settings form { display: grid; gap: 12px; }
pre {
white-space: pre-wrap;
overflow: auto;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #f7faf8;
}
@keyframes pulse {
0%, 100% { background: white; }
50% { background: #dff7eb; }
}
@media (max-width: 820px) {
.app { grid-template-columns: 1fr; }
.sidebar { min-height: 42vh; border-right: 0; border-bottom: 1px solid var(--line); }
.conversation { min-height: 58vh; }
}
+84
View File
@@ -0,0 +1,84 @@
<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MaalFlows Admin</title>
<link rel="stylesheet" href="/admin.css">
</head>
<body>
<main class="shell">
<section id="loginView" class="login">
<form id="loginForm" class="panel">
<h1>MaalFlows</h1>
<label>Uzivatel <input name="username" autocomplete="username" required></label>
<label>Heslo <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit">Prihlasit</button>
<p id="loginError" class="error"></p>
</form>
</section>
<section id="appView" class="app hidden">
<aside class="sidebar">
<div class="brand">
<h1>MaalFlows</h1>
<button id="logoutButton" type="button">Odhlasit</button>
</div>
<div class="sitebar">
<label class="switch">
<input id="onlineToggle" type="checkbox">
<span>Online</span>
</label>
<button id="settingsButton" type="button">Nastaveni</button>
</div>
<div id="conversationList" class="conversation-list"></div>
</aside>
<section class="conversation">
<header id="conversationHeader" class="conversation-header">
<div>
<h2>Vyber konverzaci</h2>
<p>Nova konverzace se tady zvyrazni a pusti zvuk.</p>
</div>
<select id="statusSelect" class="hidden">
<option value="new">nove</option>
<option value="open">otevrene</option>
<option value="resolved">vyresene</option>
<option value="spam">spam</option>
</select>
</header>
<div id="messages" class="messages"></div>
<div id="typingNotice" class="typing hidden">Zakaznik pise...</div>
<form id="replyForm" class="reply hidden">
<textarea name="message" rows="3" required placeholder="Napsat odpoved..."></textarea>
<button type="submit">Odeslat</button>
</form>
</section>
<aside id="settingsPanel" class="settings hidden">
<div class="settings-head">
<h2>Nastaveni widgetu</h2>
<button id="closeSettingsButton" type="button">Zavrit</button>
</div>
<form id="settingsForm">
<label>Titulek CS <input name="titleCs"></label>
<label>Intro CS <textarea name="introCs" rows="2"></textarea></label>
<label>Titulek EN <input name="titleEn"></label>
<label>Intro EN <textarea name="introEn" rows="2"></textarea></label>
<label>Barva <input name="brand" type="color"></label>
<label>Pozice desktop <select name="desktopSide"><option>right</option><option>left</option></select></label>
<label>Desktop bottom px <input name="desktopBottom" type="number"></label>
<label>Desktop offset px <input name="desktopOffset" type="number"></label>
<label>Pozice mobil <select name="mobileSide"><option>right</option><option>left</option></select></label>
<label>Mobil bottom px <input name="mobileBottom" type="number"></label>
<label>Mobil offset px <input name="mobileOffset" type="number"></label>
<button type="submit">Ulozit</button>
</form>
<h3>GTM snippet</h3>
<pre id="snippet"></pre>
</aside>
</section>
</main>
<script src="/admin.js"></script>
</body>
</html>
+257
View File
@@ -0,0 +1,257 @@
const state = {
site: null,
snippet: "",
conversations: [],
activeId: null,
active: null,
events: null
};
const $ = (selector) => document.querySelector(selector);
boot();
async function boot() {
const me = await api("/api/admin/me").catch(() => null);
if (me?.user) showApp();
else showLogin();
}
$("#loginForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const result = await api("/api/admin/login", {
method: "POST",
body: { username: form.get("username"), password: form.get("password") }
}).catch(() => null);
if (!result?.ok) {
$("#loginError").textContent = "Prihlaseni se nepodarilo.";
return;
}
showApp();
});
$("#logoutButton").addEventListener("click", async () => {
await api("/api/admin/logout", { method: "POST" });
location.reload();
});
$("#settingsButton").addEventListener("click", () => $("#settingsPanel").classList.remove("hidden"));
$("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden"));
$("#onlineToggle").addEventListener("change", async () => {
await saveSite({ isOnline: $("#onlineToggle").checked });
});
$("#settingsForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const settings = structuredClone(state.site.settings);
settings.title.cs = form.get("titleCs");
settings.intro.cs = form.get("introCs");
settings.title.en = form.get("titleEn");
settings.intro.en = form.get("introEn");
settings.colors.brand = form.get("brand");
settings.desktop.side = form.get("desktopSide");
settings.desktop.bottomPx = Number(form.get("desktopBottom"));
settings.desktop.sideOffsetPx = Number(form.get("desktopOffset"));
settings.mobile.side = form.get("mobileSide");
settings.mobile.bottomPx = Number(form.get("mobileBottom"));
settings.mobile.sideOffsetPx = Number(form.get("mobileOffset"));
await saveSite({ settings, isOnline: state.site.isOnline });
});
$("#replyForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.activeId) return;
const form = new FormData(event.currentTarget);
await api(`/api/admin/conversations/${state.activeId}/messages`, {
method: "POST",
body: { message: form.get("message") }
});
event.currentTarget.reset();
await openConversation(state.activeId);
});
$("#replyForm textarea").addEventListener("input", debounce(() => {
if (!state.activeId) return;
api(`/api/admin/conversations/${state.activeId}/typing`, { method: "POST" }).catch(() => {});
}, 600));
$("#statusSelect").addEventListener("change", async () => {
if (!state.activeId) return;
await api(`/api/admin/conversations/${state.activeId}/status`, {
method: "PATCH",
body: { status: $("#statusSelect").value }
});
await loadConversations();
});
async function showApp() {
$("#loginView").classList.add("hidden");
$("#appView").classList.remove("hidden");
await loadBootstrap();
await loadConversations();
connectEvents();
}
function showLogin() {
$("#loginView").classList.remove("hidden");
$("#appView").classList.add("hidden");
}
async function loadBootstrap() {
const data = await api("/api/admin/bootstrap");
state.site = data.site;
state.snippet = data.snippet;
renderSite();
}
async function loadConversations() {
const data = await api("/api/admin/conversations");
state.conversations = data.conversations;
renderConversations();
}
async function openConversation(id) {
state.activeId = id;
const data = await api(`/api/admin/conversations/${id}`);
state.active = data;
renderConversations();
renderActive();
}
function connectEvents() {
if (state.events) state.events.close();
state.events = new EventSource("/api/admin/events");
state.events.addEventListener("message", async (event) => {
const data = JSON.parse(event.data);
if (data.type === "typing" && data.actor === "visitor" && data.conversationId !== state.activeId) return;
if (data.type === "typing" && data.actor === "visitor") return flashTyping();
if (data.type === "site:update") {
state.site = data.site;
renderSite();
return;
}
await loadConversations();
if (state.activeId) await openConversation(state.activeId);
if (data.type === "conversation:new" || data.type === "message:new") notify();
});
}
function renderSite() {
$("#onlineToggle").checked = state.site.isOnline;
$("#snippet").textContent = state.snippet;
const s = state.site.settings;
const form = $("#settingsForm");
form.titleCs.value = s.title.cs || "";
form.introCs.value = s.intro.cs || "";
form.titleEn.value = s.title.en || "";
form.introEn.value = s.intro.en || "";
form.brand.value = s.colors.brand || "#0f8f6f";
form.desktopSide.value = s.desktop.side || "right";
form.desktopBottom.value = s.desktop.bottomPx || 22;
form.desktopOffset.value = s.desktop.sideOffsetPx || 22;
form.mobileSide.value = s.mobile.side || "right";
form.mobileBottom.value = s.mobile.bottomPx || 14;
form.mobileOffset.value = s.mobile.sideOffsetPx || 12;
}
function renderConversations() {
$("#conversationList").innerHTML = state.conversations.map((item) => `
<button class="conversation-item ${item.id === state.activeId ? "active" : ""} ${item.status === "new" ? "fresh" : ""}" data-id="${item.id}">
<span class="row"><strong>${escapeHtml(item.visitor.name || item.visitor.email || "Navstevnik")}</strong><span class="status">${item.status}</span></span>
<span class="preview">${escapeHtml(item.lastBody || "")}</span>
<span class="meta">${flag(item.countryCode)} ${escapeHtml(item.language || "")} ${escapeHtml(item.currentUrl || "")}</span>
</button>
`).join("");
for (const button of document.querySelectorAll(".conversation-item")) {
button.addEventListener("click", () => openConversation(button.dataset.id));
}
}
function renderActive() {
const { conversation, messages } = state.active;
const statusSelect = $("#statusSelect");
$("#conversationHeader").innerHTML = `
<div>
<h2>${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}</h2>
<p>${flag(conversation.countryCode)} ${escapeHtml(conversation.currentUrl || "")}</p>
<p>${escapeHtml(conversation.device.userAgent || "")}</p>
</div>
`;
$("#conversationHeader").append(statusSelect);
statusSelect.classList.remove("hidden");
statusSelect.value = conversation.status;
$("#replyForm").classList.remove("hidden");
$("#messages").innerHTML = messages.map((message) => `
<article class="message ${message.senderType}">
<div class="by">${escapeHtml(message.senderName || message.senderType)} · ${escapeHtml(message.createdAt)}</div>
<p>${escapeHtml(message.body)}</p>
${message.attachments.map((a) => `<a href="${a.url}" target="_blank" rel="noreferrer">${escapeHtml(a.name)}</a>`).join("")}
</article>
`).join("");
$("#messages").scrollTop = $("#messages").scrollHeight;
}
async function saveSite(patch) {
const data = await api("/api/admin/site", { method: "PATCH", body: patch });
state.site = data.site;
state.snippet = data.snippet;
renderSite();
}
function notify() {
document.title = "* Nova zprava - MaalFlows";
const audio = new AudioContext();
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.frequency.value = 880;
gain.gain.value = 0.04;
osc.connect(gain);
gain.connect(audio.destination);
osc.start();
setTimeout(() => {
osc.stop();
audio.close();
}, 160);
}
function flashTyping() {
$("#typingNotice").classList.remove("hidden");
clearTimeout(flashTyping.timer);
flashTyping.timer = setTimeout(() => $("#typingNotice").classList.add("hidden"), 1800);
}
async function api(url, options = {}) {
const response = await fetch(url, {
method: options.method || "GET",
headers: { "Content-Type": "application/json" },
body: options.body ? JSON.stringify(options.body) : undefined
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
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 flag(countryCode) {
if (!countryCode || countryCode.length !== 2) return "";
return countryCode.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt()));
}
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MaalFlows Preview</title>
<style>
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #17211d;
background: #f5f7f4;
}
main {
max-width: 980px;
margin: 0 auto;
padding: 56px 20px;
display: grid;
gap: 22px;
}
h1 { margin: 0; font-size: clamp(32px, 5vw, 56px); }
p { max-width: 680px; line-height: 1.6; color: #627067; }
.demo {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.demo div {
background: white;
border: 1px solid #dfe6e2;
border-radius: 8px;
padding: 18px;
min-height: 120px;
}
</style>
</head>
<body>
<main>
<h1>9b-plus test stranka</h1>
<p>Tahle stranka simuluje eshop, kam se widget vlozi pres GTM. Otevri bublinu vpravo dole, posli zpravu a sleduj ji v administraci.</p>
<section class="demo">
<div><strong>Produkt</strong><p>Ukazkova stranka produktu pro sbirani current URL.</p></div>
<div><strong>Kosik</strong><p>Referrer, jazyk, timezone a device info se poslou do konverzace.</p></div>
<div><strong>Podpora</strong><p>Zakaznik muze napsat hned, kontaktni udaje jsou volitelne.</p></div>
</section>
</main>
<script>
window.MaalFlows = {
siteKey: "9b-plus",
lang: "cs",
apiBase: "http://localhost:3400"
};
</script>
<script async src="/widget.js"></script>
</body>
</html>
+277
View File
@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
})[char]);
}
function randomId() {
return `vis_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
}
})();