Files
MaalFlows/public/admin.js
T

1154 lines
45 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.
const state = {
site: null,
snippet: "",
conversations: [],
visitors: [],
visitorsLoaded: false,
activeId: null,
active: null,
events: null,
statusFilter: "active",
hideBots: localStorage.getItem("mf_admin_hide_bots") !== "0",
soundMuted: localStorage.getItem("mf_admin_sound_muted") === "1",
soundVolume: Number(localStorage.getItem("mf_admin_sound_volume") || 70),
soundStyle: localStorage.getItem("mf_admin_sound_style") || "bright",
soundRepeat: localStorage.getItem("mf_admin_sound_repeat") === "1",
replyFiles: [],
attentionTimer: null,
soundRepeatTimer: null,
attentionOn: false,
originalTitle: document.title,
originalFavicon: document.querySelector("link[rel='icon']")?.href || "/favicon.svg",
aiModels: []
};
const $ = (selector) => document.querySelector(selector);
boot();
renderAdminSoundControls();
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.toggle("hidden"));
$("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden"));
$("#adminSoundToggle").addEventListener("click", () => {
setAdminSoundMuted(!state.soundMuted);
});
$("#adminSoundVolume").addEventListener("input", (event) => {
setAdminSoundVolume(Number(event.target.value));
});
$("#adminSoundToggleSettings").addEventListener("change", (event) => {
setAdminSoundMuted(!event.target.checked);
});
$("#adminSoundVolumeSettings").addEventListener("input", (event) => {
setAdminSoundVolume(Number(event.target.value));
});
$("#adminSoundStyle").addEventListener("change", (event) => {
state.soundStyle = event.target.value;
localStorage.setItem("mf_admin_sound_style", state.soundStyle);
renderAdminSoundControls();
});
$("#adminSoundRepeat").addEventListener("change", (event) => {
state.soundRepeat = event.target.checked;
localStorage.setItem("mf_admin_sound_repeat", state.soundRepeat ? "1" : "0");
if (!state.soundRepeat) stopRepeatingSound();
renderAdminSoundControls();
});
$("#adminSoundTest").addEventListener("click", () => {
playNotifySound({ ignoreMuted: true });
});
$("#translationEnabled").addEventListener("change", markAiSettingsDirty);
$("#operatorLanguage").addEventListener("change", markAiSettingsDirty);
$("#translationModel").addEventListener("change", markAiSettingsDirty);
$("#openaiApiKey").addEventListener("input", markAiSettingsDirty);
$("#refreshAiModels").addEventListener("click", loadAiModels);
$("#saveAiSettings").addEventListener("click", saveTranslationSettings);
$("#testOpenAiKey").addEventListener("click", testOpenAiKey);
document.querySelectorAll("[data-settings-tab]").forEach((button) => {
button.addEventListener("click", () => {
const tab = button.dataset.settingsTab;
document.querySelectorAll("[data-settings-tab]").forEach((item) => item.classList.toggle("active", item === button));
document.querySelectorAll("[data-settings-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.settingsPanel === tab));
});
});
$("#settingsForm").brand.addEventListener("input", (event) => {
$("#settingsForm").brandHex.value = normalizeHexColor(event.target.value) || event.target.value;
});
$("#settingsForm").brandHex.addEventListener("input", (event) => {
const color = normalizeHexColor(event.target.value);
if (color) $("#settingsForm").brand.value = color;
});
window.addEventListener("focus", syncAttentionWithUnread);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) syncAttentionWithUnread();
});
$("#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 = normalizeHexColor(form.get("brandHex")) || 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"));
settings.launcherEffects = {
breathe: form.get("launcherBreathe") === "on",
ring: form.get("launcherRing") === "on",
hoverLabel: form.get("launcherHoverLabel") === "on",
avatar: form.get("launcherAvatar") === "on"
};
settings.notifications = settings.notifications || {};
settings.notifications.pulseOnAdminReply = form.get("messagePulse") === "on";
settings.notifications.badgeOnAdminReply = form.get("messageBadge") === "on";
settings.notifications.labelOnAdminReply = form.get("messageLabel") === "on";
settings.notifications.wiggleOnAdminReply = form.get("messageWiggle") === "on";
await saveSite({ settings, isOnline: state.site.isOnline });
$("#settingsPanel").classList.add("hidden");
});
$("#replyForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.activeId) return;
const replyForm = event.currentTarget;
const textarea = replyForm.querySelector("textarea[name='message']");
const originalMessage = textarea.value;
const form = new FormData(replyForm);
const payload = {
message: form.get("message") || "",
attachments: await Promise.all(state.replyFiles.slice(0, 3).map(fileToPayload))
};
if (!payload.message.trim() && !payload.attachments.length) return;
clearReplyComposer(replyForm);
try {
await api(`/api/admin/conversations/${state.activeId}/messages`, {
method: "POST",
body: payload
});
clearReplyComposer(replyForm);
setTimeout(() => clearReplyComposer(replyForm), 0);
setTimeout(() => clearReplyComposer(replyForm), 120);
state.replyFiles = [];
renderReplyAttachments();
await openConversation(state.activeId);
clearReplyComposer(replyForm);
} catch (error) {
textarea.value = originalMessage;
textarea.defaultValue = originalMessage;
throw error;
}
});
$("#replyForm textarea").addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
$("#replyForm").requestSubmit();
});
$("#replyForm textarea").addEventListener("input", debounce(() => {
if (!state.activeId) return;
api(`/api/admin/conversations/${state.activeId}/typing`, { method: "POST" }).catch(() => {});
}, 600));
$("#replyForm input[name='attachments']").addEventListener("change", (event) => {
addReplyFiles([...event.target.files]);
event.target.value = "";
});
for (const eventName of ["dragenter", "dragover"]) {
$(".conversation").addEventListener(eventName, (event) => {
event.preventDefault();
if (!state.activeId) return;
$("#replyForm").classList.add("dragging");
});
}
for (const eventName of ["dragleave", "drop"]) {
$(".conversation").addEventListener(eventName, (event) => {
event.preventDefault();
$("#replyForm").classList.remove("dragging");
});
}
$(".conversation").addEventListener("drop", (event) => {
if (!state.activeId) return;
addReplyFiles([...event.dataTransfer.files]);
});
$(".conversation").addEventListener("paste", (event) => {
if (!state.activeId) return;
const files = [...event.clipboardData?.items || []]
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter(Boolean);
if (!files.length) return;
event.preventDefault();
addReplyFiles(files);
});
$("#replyAttachments").addEventListener("click", (event) => {
const removeButton = event.target.closest("[data-remove-reply]");
if (!removeButton) return;
state.replyFiles.splice(Number(removeButton.dataset.removeReply), 1);
renderReplyAttachments();
});
$("#conversationFilter").addEventListener("change", () => {
state.statusFilter = $("#conversationFilter").value;
renderConversations();
});
$("#hideBotsToggle").addEventListener("click", () => {
state.hideBots = !state.hideBots;
localStorage.setItem("mf_admin_hide_bots", state.hideBots ? "1" : "0");
renderVisitors();
});
$("#conversationList").addEventListener("click", async (event) => {
const photoButton = event.target.closest("[data-photo-url]");
const openButton = event.target.closest("[data-open]");
const deleteButton = event.target.closest("[data-delete]");
if (photoButton) {
openPhotoPreview(photoButton.dataset.photoUrl, photoButton.dataset.photoName);
return;
}
if (deleteButton) {
await deleteConversation(deleteButton.dataset.delete);
return;
}
if (openButton) await openConversation(openButton.dataset.open);
});
$("#conversationList").addEventListener("click", async (event) => {
const statusButton = event.target.closest("[data-status-button]");
if (!statusButton) return;
await updateConversationStatus(statusButton.dataset.statusButton, statusButton.dataset.status);
});
$("#messages").addEventListener("click", (event) => {
const deleteButton = event.target.closest("[data-delete-attachment]");
if (deleteButton) {
deleteAttachment(deleteButton.dataset.deleteAttachment);
return;
}
const preview = event.target.closest("[data-photo-url]");
if (!preview) return;
openPhotoPreview(preview.dataset.photoUrl, preview.dataset.photoName);
});
$("#visitorList").addEventListener("click", async (event) => {
if (event.target.closest("a")) return;
const card = event.target.closest("[data-visitor-conversation]");
const conversationId = card?.dataset.visitorConversation;
if (!conversationId) return;
await openConversation(conversationId);
});
$("#conversationHeader").addEventListener("click", async (event) => {
const button = event.target.closest("[data-rename-visitor]");
if (!button) return;
event.preventDefault();
await renameVisitor(button.dataset.renameVisitor);
});
async function showApp() {
$("#loginView").classList.add("hidden");
$("#appView").classList.remove("hidden");
await loadBootstrap();
await loadAiModels().catch(() => {});
await Promise.all([loadConversations(), loadVisitors()]);
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;
syncConversationVisitors();
renderConversations();
syncAttentionWithUnread();
}
async function loadVisitors() {
const data = await api("/api/admin/visitors");
state.visitors = data.visitors;
state.visitorsLoaded = true;
syncConversationVisitors();
renderConversations();
renderVisitors();
}
async function openConversation(id) {
state.activeId = id;
const data = await api(`/api/admin/conversations/${id}`);
state.active = data;
const index = state.conversations.findIndex((item) => item.id === id);
if (index >= 0) state.conversations[index] = { ...state.conversations[index], ...data.conversation };
syncConversationVisitors();
renderConversations();
renderActive();
syncAttentionWithUnread();
}
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;
}
if (data.type === "visitors:update") {
state.visitors = data.visitors;
state.visitorsLoaded = true;
syncConversationVisitors();
renderConversations();
renderVisitors();
return;
}
const visitorMessage = isVisitorMessageEvent(data);
const eventConversationId = eventConversationIdFrom(data);
const shouldNotify = data.type === "conversation:new" || (visitorMessage && eventConversationId !== state.activeId);
await loadConversations();
if (data.type === "conversation:delete" && data.conversationId === state.activeId) {
clearActiveConversation();
return;
}
if (state.activeId) await openConversation(state.activeId).catch(() => clearActiveConversation());
if (shouldNotify) notify();
else syncAttentionWithUnread();
});
}
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 = normalizeHexColor(s.colors.brand) || "#0f8f6f";
form.brandHex.value = form.brand.value;
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;
form.launcherBreathe.checked = s.launcherEffects?.breathe !== false;
form.launcherRing.checked = s.launcherEffects?.ring !== false;
form.launcherHoverLabel.checked = s.launcherEffects?.hoverLabel !== false;
form.launcherAvatar.checked = s.launcherEffects?.avatar !== false;
form.messagePulse.checked = s.notifications?.pulseOnAdminReply !== false;
form.messageBadge.checked = s.notifications?.badgeOnAdminReply !== false;
form.messageLabel.checked = s.notifications?.labelOnAdminReply !== false;
form.messageWiggle.checked = s.notifications?.wiggleOnAdminReply !== false;
$("#translationEnabled").checked = Boolean(s.translations?.enabled);
$("#operatorLanguage").value = s.translations?.operatorLanguage || "cs";
$("#openaiApiKey").value = "";
$("#openaiApiKeyStatus").textContent = s.translations?.hasOpenaiApiKey
? `Klic ulozeny (${s.translations.openaiApiKeyMasked || "sk-..."})`
: "Klic neni ulozeny. Bez nej AI preklady nepobezi.";
renderModelOptions(s.translations?.model || "gpt-5-mini");
}
async function saveTranslationSettings() {
if (!state.site) return;
setOpenAiStatus("Ukladam AI nastaveni...");
const settings = structuredClone(state.site.settings);
settings.translations = {
...(settings.translations || {}),
enabled: $("#translationEnabled").checked,
operatorLanguage: $("#operatorLanguage").value,
model: $("#translationModel").value.trim() || "gpt-5-mini"
};
const apiKey = $("#openaiApiKey").value.trim();
if (apiKey) settings.translations.openaiApiKey = apiKey;
await saveSite({ settings, isOnline: state.site.isOnline });
$("#openaiApiKey").value = "";
setOpenAiStatus(state.site.settings.translations?.hasOpenaiApiKey
? `Klic ulozeny (${state.site.settings.translations.openaiApiKeyMasked || "sk-..."})`
: "Klic neni ulozeny. Bez nej AI preklady nepobezi.");
await loadAiModels().catch(() => {});
}
function markAiSettingsDirty() {
setOpenAiStatus("AI nastaveni ma neulozene zmeny.");
}
async function testOpenAiKey() {
setOpenAiStatus("Testuji OpenAI klic...");
const result = await api("/api/admin/ai/test", { method: "POST" }).catch((error) => error);
if (result?.ok) {
setOpenAiStatus(`Klic funguje (${result.key || "sk-..."}), dostupnych modelu: ${result.modelCount}.`);
await loadAiModels().catch(() => {});
return;
}
const message = result?.error === "missing_openai_api_key"
? "Klic neni ulozeny. Nejdřív ho vloz a klikni na Ulozit AI nastaveni."
: `Test selhal (${result?.error || "neznamá chyba"}).`;
setOpenAiStatus(message);
}
function setOpenAiStatus(message) {
$("#openaiApiKeyStatus").textContent = message;
}
async function loadAiModels() {
const current = $("#translationModel").value || state.site?.settings?.translations?.model || "gpt-5-mini";
const data = await api("/api/admin/ai/models").catch((error) => error?.models ? error : null);
state.aiModels = data?.models || fallbackAiModels();
renderModelOptions(current);
}
function renderModelOptions(current) {
const select = $("#translationModel");
const models = [...new Set([current, ...state.aiModels, ...fallbackAiModels()].filter(Boolean))];
select.innerHTML = models.map((model) => `<option value="${escapeHtml(model)}">${escapeHtml(model)}</option>`).join("");
select.value = models.includes(current) ? current : models[0];
}
function fallbackAiModels() {
return ["gpt-5-mini", "gpt-5-nano", "gpt-5.1", "gpt-4.1-mini"];
}
function renderConversations() {
const conversations = filteredConversations();
$("#conversationList").innerHTML = conversations.length ? conversations.map((item) => `
<article class="conversation-item ${item.id === state.activeId ? "active" : ""} ${item.hasUnread ? "unread fresh" : ""}">
<button class="conversation-open" type="button" data-open="${item.id}">
<span class="row visitor-row">
<span class="visitor-name-wrap">
<span class="unread-dot" aria-label="${item.hasUnread ? "Neprecteno" : "Precteno"}"></span>
<strong>${escapeHtml(conversationVisitorName(item))}</strong>
${presenceDot(item.visitorPresence)}
</span>
${item.imageCount ? `<span class="photo-count">foto ${item.imageCount}</span>` : ""}
</span>
<span class="visitor-facts">
<span title="Zeme">${visitorCountry(item)}</span>
<span title="Doba na webu">${formatDuration(item.sessionSeconds)}</span>
<span title="Navstivene stranky">${Number(item.pageCount || 0)} str.</span>
<span title="Pocet chatu">${Number(item.visitorConversationCount || 1)}x chat</span>
</span>
</button>
<div class="conversation-actions">
${statusButtons(item)}
<button class="delete-chat" type="button" data-delete="${item.id}" aria-label="Smazat chat" title="Smazat chat">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-2 6h10l-.7 11H7.7L7 9Zm3 2 .2 7h1.6l-.2-7H10Zm4 0-.2 7h1.6l.2-7H14Z"></path></svg>
</button>
</div>
</article>
`).join("") : `<p class="empty-list">Zadny chat pro vybrany filtr.</p>`;
}
function syncConversationVisitors() {
if (!state.visitorsLoaded) return;
const visitorsById = new Map(state.visitors.map((visitor) => [visitor.id, visitor]));
state.conversations = state.conversations.map((item) => ({
...item,
visitorOnline: visitorsById.has(item.visitorToken),
visitorPresence: visitorsById.get(item.visitorToken)?.presence || "offline"
}));
}
function conversationVisitorName(item) {
return item.visitorAlias || item.visitor?.name || item.visitor?.email || item.visitorLabel || "Navstevnik";
}
function renderVisitors() {
const visitors = state.hideBots ? state.visitors.filter((visitor) => !visitor.isBot) : state.visitors;
const botCount = state.visitors.filter((visitor) => visitor.isBot).length;
$("#hideBotsToggle").classList.toggle("active", state.hideBots);
$("#hideBotsToggle").textContent = state.hideBots ? "Skryt boty" : "Boty videt";
$("#visitorCount").textContent = `${visitors.length} na webu${state.hideBots && botCount ? ` · ${botCount} bot` : ""}`;
$("#visitorList").innerHTML = visitors.length ? visitors.map((visitor) => `
<article class="visitor-card ${visitor.lastConversationId ? "clickable" : ""} ${visitor.lastConversationId === state.activeId ? "active" : ""} ${visitor.isBot ? "bot" : ""}" data-visitor-conversation="${escapeHtml(visitor.lastConversationId || "")}">
<div class="visitor-card-head">
<span class="visitor-avatar">${escapeHtml(visitor.initials || "??")}</span>
<div>
<strong>${escapeHtml(visitor.label || "Navstevnik")}</strong>
<small>${escapeHtml(visitor.isBot ? `bot: ${visitor.botReason || "crawler"}` : browserLabel(visitor.device))}</small>
</div>
${presenceDot(visitor.presence)}
</div>
<div class="visitor-compact-meta">
<span>${visitorCountry(visitor)}</span>
<span>${formatDuration(visitor.sessionSeconds)}</span>
<span>${Number(visitor.visitCount || visitor.pageCount || 1)} navst.</span>
<span>${Number(visitor.conversationCount || 0)} chaty</span>
</div>
<a class="visitor-page" href="${escapeHtml(visitor.currentUrl || "#")}" target="_blank" rel="noreferrer">${escapeHtml(pageTitle(visitor))}</a>
<div class="visitor-referrer">${visitor.referrer ? escapeHtml(referrerLabel(visitor.referrer)) : "primy vstup"}${visitor.lastConversationId ? "" : " · bez chatu"}</div>
</article>
`).join("") : `<p class="empty-list">Zatim nikdo online.</p>`;
}
function presenceDot(presence) {
const value = ["active", "idle", "offline"].includes(presence) ? presence : "offline";
const label = value === "active" ? "Aktivni" : value === "idle" ? "Neaktivni" : "Offline";
return `<span class="presence-dot ${value}" aria-label="${label}" title="${label}"></span>`;
}
function pageTitle(visitor) {
const history = Array.isArray(visitor.browsingHistory) ? visitor.browsingHistory : [];
const current = history.find((item) => item.url === visitor.currentUrl) || history[history.length - 1];
return current?.title || shortUrl(visitor.currentUrl) || "neznamá stranka";
}
function referrerLabel(value) {
try {
return new URL(value).hostname.replace(/^www\./, "");
} catch {
return shortUrl(value) || value;
}
}
function browserLabel(device = {}) {
const ua = String(device.userAgent || "");
const browser = ua.includes("Edg/") ? "Edge"
: ua.includes("Chrome/") ? "Chrome"
: ua.includes("Firefox/") ? "Firefox"
: ua.includes("Safari/") ? "Safari"
: "Browser";
return [device.platform || "zarizeni", browser].filter(Boolean).join(" - ");
}
function renderActive() {
const { conversation, messages } = state.active;
$("#conversationHeader").innerHTML = `
<div class="visitor-head">
<div class="visitor-summary-row">
<button class="visitor-name-edit" type="button" data-rename-visitor="${escapeHtml(conversation.visitorToken)}" title="Prejmenovat navstevnika">
<span>${escapeHtml(conversationVisitorName(conversation))}</span>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 17.5V20h2.5L17.8 8.7l-2.5-2.5L4 17.5Zm15.7-11.1a1 1 0 0 0 0-1.4L18.3 3.6a1 1 0 0 0-1.4 0l-1 1 2.5 2.5 1.3-.7Z"></path></svg>
</button>
${presenceDot(conversation.visitorPresence)}
<span class="country-pill" title="Zeme">${visitorCountry(conversation)}</span>
<span title="Doba na webu">${formatDuration(conversation.sessionSeconds)}</span>
<span title="Navstivene stranky">${Number(conversation.pageCount || 0)} str.</span>
<span title="Pocet chatu">${Number(conversation.visitorConversationCount || 1)}x chat</span>
${visitorDetails(conversation)}
</div>
</div>
`;
$("#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>
${renderMessageBody(message)}
${renderAttachments(message.attachments)}
</article>
`).join("") + adminReadReceipt(conversation, messages);
decorateTranslationMeta(messages);
$("#messages").scrollTop = $("#messages").scrollHeight;
}
function decorateTranslationMeta(messages) {
document.querySelectorAll("#messages .message .by").forEach((element, index) => {
const meta = translationMeta(messages[index]);
if (!meta) return;
const badge = document.createElement("span");
badge.className = `translation-meta ${meta.status}`;
badge.textContent = meta.label;
if (meta.error) badge.title = meta.error;
element.append(" ");
element.append(badge);
});
}
function translationMeta(message) {
if (!message?.translationStatus) return null;
const status = String(message.translationStatus || "");
const source = String(message.originalLanguage || "?").toUpperCase();
const target = String(message.translatedLanguage || "?").toUpperCase();
return {
status,
error: message.translationError || "",
label: translationStatusLabel(status, source, target)
};
}
function translationStatusLabel(status, source, target) {
if (status === "translated") return `preklad: ${source} -> ${target}`;
if (status === "same_language") return `preklad: ${source} = ${target}`;
if (status === "failed") return `preklad: ${source} -> ${target} - chyba`;
if (status === "missing_key") return "preklad: chybi klic";
if (status === "disabled") return "preklad: vypnuto";
if (status === "empty") return `preklad: ${source} -> ${target} - prazdny`;
if (status === "skipped") return "preklad: preskoceno";
if (status === "pending") return "preklad: ceka";
return `preklad: ${status}`;
}
function renderMessageBody(message) {
const original = message.body ? `<p>${escapeHtml(message.body)}</p>` : "";
if (!message.translatedBody) return original;
const label = message.senderType === "visitor"
? `Preklad do ${escapeHtml(message.translatedLanguage || "")}`
: `Odeslano zakaznikovi ${escapeHtml(message.translatedLanguage || "")}`;
return `
${original}
<div class="translation">
<span>${label}</span>
<p>${escapeHtml(message.translatedBody)}</p>
</div>
`;
}
async function renameVisitor(visitorToken) {
if (!state.active?.conversation || !visitorToken) return;
const currentName = conversationVisitorName(state.active.conversation);
const nextName = window.prompt("Prejmenovat navstevnika", state.active.conversation.visitorAlias || currentName);
if (nextName === null) return;
const result = await api(`/api/admin/visitors/${encodeURIComponent(visitorToken)}`, {
method: "PATCH",
body: { displayName: nextName }
});
applyVisitorRename(visitorToken, result.label, result.displayName);
}
function applyVisitorRename(visitorToken, label, alias) {
const update = (item) => item.visitorToken === visitorToken
? { ...item, visitorLabel: label, visitorAlias: alias || "" }
: item;
state.conversations = state.conversations.map(update);
state.visitors = state.visitors.map((visitor) => visitor.id === visitorToken
? { ...visitor, label, visitorAlias: alias || "" }
: visitor);
if (state.active?.conversation?.visitorToken === visitorToken) {
state.active.conversation = update(state.active.conversation);
}
renderConversations();
renderVisitors();
if (state.active) renderActive();
}
function adminReadReceipt(conversation, messages) {
const lastAdminMessage = [...messages].reverse().find((message) => message.senderType === "admin");
if (!lastAdminMessage) return "";
const seenAt = conversation.visitorSeenAt;
const isSeen = Boolean(seenAt && String(seenAt) >= String(lastAdminMessage.createdAt));
const text = isSeen ? `Precteno zakaznikem ${formatTime(seenAt)}` : "Zatim neprecteno";
return `<div class="read-receipt ${isSeen ? "seen" : "unseen"}">${escapeHtml(text)}</div>`;
}
function filteredConversations() {
if (state.statusFilter === "all") return state.conversations;
if (state.statusFilter === "active") return state.conversations.filter((item) => item.status === "new" || item.status === "open");
return state.conversations.filter((item) => item.status === state.statusFilter);
}
function statusButtons(item) {
return [
["new", "nove"],
["open", "otevrene"],
["resolved", "vyresene"]
].map(([value, label]) => `
<button class="status-button ${value} ${item.status === value ? "active" : ""}" type="button" data-status-button="${item.id}" data-status="${value}">
${label}
</button>
`).join("");
}
function visitorCountry(item) {
const countryCode = item.countryCode || countryFromLanguage(item.language) || countryFromTimezone(item.timezone);
const code = String(countryCode || "").toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return `<span class="country-badge unknown"><span class="flag-image">?</span><strong>?</strong></span>`;
return `
<span class="country-badge">
<span class="flag-image" style="background-image:url('https://flagcdn.com/24x18/${code.toLowerCase()}.png')" aria-hidden="true"></span>
<strong>${escapeHtml(code)}</strong>
</span>
`;
}
function visitorDetails(conversation) {
const history = Array.isArray(conversation.browsingHistory) ? conversation.browsingHistory : [];
const historyItems = history.length ? history.slice().reverse().map((item) => `
<li>
<a href="${escapeHtml(item.url || "#")}" target="_blank" rel="noreferrer">${escapeHtml(pageLabel(item))}</a>
<small>${escapeHtml(formatVisitedAt(item.at))}</small>
</li>
`).join("") : `<li><span>Zatim bez historie.</span></li>`;
return `
<details class="visitor-details">
<summary title="Detail navstevnika">
<span aria-hidden="true">i</span>
</summary>
<div class="visitor-detail-popover">
<div class="visitor-detail-grid">
<span>Aktualne</span>
<a href="${escapeHtml(conversation.currentUrl || "#")}" target="_blank" rel="noreferrer">${escapeHtml(shortUrl(conversation.currentUrl) || "-")}</a>
<span>Prisiel z</span>
${conversation.referrer ? `<a href="${escapeHtml(conversation.referrer)}" target="_blank" rel="noreferrer">${escapeHtml(shortUrl(conversation.referrer))}</a>` : `<em>primy vstup / nezname</em>`}
<span>Zarizeni</span>
<em>${escapeHtml(deviceLabel(conversation.device, conversation.language, conversation.timezone))}</em>
</div>
<ol class="history-list">${historyItems}</ol>
</div>
</details>
`;
}
function formatDuration(seconds) {
const value = Number(seconds || 0);
if (value < 60) return `${Math.max(0, value)}s`;
const minutes = Math.floor(value / 60);
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
async function updateConversationStatus(id, status) {
await api(`/api/admin/conversations/${id}/status`, {
method: "PATCH",
body: { status }
});
const item = state.conversations.find((conversation) => conversation.id === id);
if (item) item.status = status;
if (state.active?.conversation?.id === id) state.active.conversation.status = status;
renderConversations();
}
async function deleteConversation(id) {
const item = state.conversations.find((conversation) => conversation.id === id);
const label = item?.visitor?.name || item?.lastBody || "tento chat";
if (!confirm(`Smazat ${label}? Tahle akce nejde vratit.`)) return;
await api(`/api/admin/conversations/${id}`, { method: "DELETE" });
state.conversations = state.conversations.filter((conversation) => conversation.id !== id);
if (state.activeId === id) clearActiveConversation();
renderConversations();
}
function clearActiveConversation() {
state.activeId = null;
state.active = null;
$("#conversationHeader").innerHTML = `
<div>
<h2>Vyber konverzaci</h2>
</div>
`;
$("#messages").innerHTML = "";
$("#replyForm").classList.add("hidden");
syncAttentionWithUnread();
}
function renderAttachments(attachments) {
if (!attachments.length) return "";
return `<div class="attachments">${attachments.map((attachment) => {
if (!attachment.type?.startsWith("image/")) {
return `<a href="${escapeHtml(attachment.url)}" target="_blank" rel="noreferrer">${escapeHtml(attachment.name)}</a>`;
}
return `
<figure class="photo-preview">
<button class="photo-open" type="button" data-photo-url="${escapeHtml(attachment.url)}" data-photo-name="${escapeHtml(attachment.name)}">
<img src="${escapeHtml(attachment.url)}" alt="${escapeHtml(attachment.name)}">
</button>
<figcaption>
<span>${escapeHtml(attachment.name)}</span>
<button class="photo-delete" type="button" data-delete-attachment="${attachment.id}" aria-label="Smazat fotku" title="Smazat fotku">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-2 6h10l-.7 11H7.7L7 9Zm3 2 .2 7h1.6l-.2-7H10Zm4 0-.2 7h1.6l.2-7H14Z"></path></svg>
</button>
</figcaption>
</figure>
`;
}).join("")}</div>`;
}
async function deleteAttachment(id) {
if (!confirm("Smazat tuhle fotku?")) return;
await api(`/api/admin/attachments/${id}`, { method: "DELETE" });
if (state.activeId) await openConversation(state.activeId);
await loadConversations();
}
function renderReplyAttachments() {
$("#replyAttachments").innerHTML = state.replyFiles.map((file, index) => `
<span class="reply-chip">
<span>${escapeHtml(file.name)}</span>
<button type="button" data-remove-reply="${index}" aria-label="Odebrat fotku">×</button>
</span>
`).join("");
}
function addReplyFiles(files) {
state.replyFiles = mergeImageFiles(state.replyFiles, files);
renderReplyAttachments();
}
function clearReplyComposer(form = $("#replyForm")) {
const textarea = form?.querySelector("textarea[name='message']");
if (!textarea) return;
textarea.value = "";
textarea.defaultValue = "";
textarea.textContent = "";
textarea.dispatchEvent(new Event("input", { bubbles: true }));
}
function mergeImageFiles(currentFiles, incomingFiles) {
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
const nextFiles = [...currentFiles];
for (const file of incomingFiles) {
if (!allowedTypes.has(file.type) || file.size <= 0 || file.size > 3_000_000) 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 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 countryFromLanguage(language) {
const match = String(language || "").match(/-([A-Za-z]{2})\b/);
return match ? match[1].toUpperCase() : null;
}
function countryFromTimezone(timezone) {
const zones = {
"Europe/Prague": "CZ",
"Europe/Bratislava": "SK",
"Europe/Warsaw": "PL",
"Europe/Berlin": "DE",
"Europe/Vienna": "AT",
"Europe/Budapest": "HU"
};
return zones[String(timezone || "")] || null;
}
function shortUrl(value) {
if (!value) return "";
try {
const url = new URL(value);
const path = `${url.pathname}${url.search}`.replace(/\/$/, "");
return `${url.hostname}${path || "/"}`;
} catch {
return String(value);
}
}
function pageLabel(item) {
return item.title || shortUrl(item.url) || item.url || "stranka";
}
function formatVisitedAt(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" });
}
function formatTime(value) {
const text = String(value || "");
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text) ? `${text.replace(" ", "T")}Z` : text;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" });
}
function deviceLabel(device = {}, language, timezone) {
return [device.platform, device.viewport, language, timezone].filter(Boolean).join(" | ");
}
function openPhotoPreview(url, name) {
let modal = $("#photoModal");
if (!modal) {
modal = document.createElement("div");
modal.id = "photoModal";
modal.className = "photo-modal hidden";
modal.innerHTML = `
<button class="photo-backdrop" type="button" aria-label="Zavrit nahled"></button>
<figure>
<button class="photo-close" type="button" aria-label="Zavrit nahled">×</button>
<img alt="">
<figcaption></figcaption>
</figure>
`;
document.body.append(modal);
modal.addEventListener("click", (event) => {
if (event.target.closest(".photo-close") || event.target.classList.contains("photo-backdrop")) {
modal.classList.add("hidden");
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") modal.classList.add("hidden");
});
}
modal.querySelector("img").src = url;
modal.querySelector("img").alt = name || "Fotka";
modal.querySelector("figcaption").textContent = name || "";
modal.classList.remove("hidden");
}
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() {
startAttention();
playNotifySound();
startRepeatingSound();
}
function renderAdminSoundControls() {
const toggle = $("#adminSoundToggle");
const volume = $("#adminSoundVolume");
const settingsToggle = $("#adminSoundToggleSettings");
const settingsVolume = $("#adminSoundVolumeSettings");
const settingsStyle = $("#adminSoundStyle");
const settingsRepeat = $("#adminSoundRepeat");
const safeVolume = clamp(state.soundVolume, 0, 100);
if (toggle) {
toggle.classList.toggle("muted", state.soundMuted || safeVolume <= 0);
toggle.setAttribute("aria-label", state.soundMuted ? "Zapnout zvuk notifikaci" : "Vypnout zvuk notifikaci");
toggle.title = state.soundMuted ? "Zapnout zvuk notifikaci" : "Vypnout zvuk notifikaci";
}
if (volume) {
volume.value = String(safeVolume);
volume.title = `Hlasitost ${safeVolume}%`;
}
if (settingsToggle) settingsToggle.checked = !state.soundMuted;
if (settingsVolume) settingsVolume.value = String(safeVolume);
if (settingsStyle) settingsStyle.value = state.soundStyle;
if (settingsRepeat) settingsRepeat.checked = state.soundRepeat;
}
function setAdminSoundMuted(isMuted) {
state.soundMuted = Boolean(isMuted);
if (!state.soundMuted && state.soundVolume <= 0) state.soundVolume = 70;
localStorage.setItem("mf_admin_sound_muted", state.soundMuted ? "1" : "0");
localStorage.setItem("mf_admin_sound_volume", String(state.soundVolume));
if (state.soundMuted) stopRepeatingSound();
renderAdminSoundControls();
}
function setAdminSoundVolume(value) {
state.soundVolume = clamp(value, 0, 100);
state.soundMuted = state.soundVolume <= 0;
localStorage.setItem("mf_admin_sound_volume", String(state.soundVolume));
localStorage.setItem("mf_admin_sound_muted", state.soundMuted ? "1" : "0");
if (state.soundMuted) stopRepeatingSound();
renderAdminSoundControls();
}
function playNotifySound(options = {}) {
const volume = clamp(state.soundVolume, 0, 100);
if (!options.ignoreMuted && (state.soundMuted || volume <= 0)) return;
const AudioCtor = window.AudioContext || window.webkitAudioContext;
if (!AudioCtor) return;
const audio = new AudioCtor();
const gain = audio.createGain();
gain.gain.value = 0.12 * ((volume || 70) / 100);
gain.connect(audio.destination);
for (const tone of soundPattern(state.soundStyle)) {
const osc = audio.createOscillator();
osc.type = tone.type;
osc.frequency.value = tone.frequency;
osc.connect(gain);
osc.start(audio.currentTime + tone.offset);
osc.stop(audio.currentTime + tone.offset + tone.duration);
}
setTimeout(() => audio.close().catch(() => {}), 720);
}
function startRepeatingSound() {
if (!state.soundRepeat || state.soundRepeatTimer || state.soundMuted || clamp(state.soundVolume, 0, 100) <= 0) return;
state.soundRepeatTimer = setInterval(() => {
playNotifySound();
}, 5500);
}
function stopRepeatingSound() {
if (state.soundRepeatTimer) clearInterval(state.soundRepeatTimer);
state.soundRepeatTimer = null;
}
function hasUnreadConversations() {
return state.conversations.some((conversation) => conversation.hasUnread && conversation.id !== state.activeId);
}
function syncAttentionWithUnread() {
if (hasUnreadConversations()) {
startAttention();
startRepeatingSound();
return;
}
stopAttention();
}
function soundPattern(style) {
const patterns = {
tap: [
{ offset: 0, duration: 0.08, frequency: 720, type: "sine" }
],
double: [
{ offset: 0, duration: 0.09, frequency: 760, type: "sine" },
{ offset: 0.12, duration: 0.09, frequency: 760, type: "sine" },
{ offset: 0.28, duration: 0.1, frequency: 980, type: "triangle" }
],
soft: [
{ offset: 0, duration: 0.13, frequency: 520, type: "sine" },
{ offset: 0.16, duration: 0.12, frequency: 680, type: "sine" }
],
chime: [
{ offset: 0, duration: 0.16, frequency: 880, type: "triangle" },
{ offset: 0.18, duration: 0.18, frequency: 1320, type: "triangle" }
],
urgent: [
{ offset: 0, duration: 0.1, frequency: 760, type: "square" },
{ offset: 0.13, duration: 0.1, frequency: 760, type: "square" },
{ offset: 0.26, duration: 0.12, frequency: 980, type: "square" }
],
bright: [
{ offset: 0, duration: 0.11, frequency: 820, type: "sine" },
{ offset: 0.14, duration: 0.11, frequency: 1040, type: "sine" }
]
};
return patterns[style] || patterns.bright;
}
function clamp(value, min, max) {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}
function startAttention() {
if (state.attentionTimer) return;
const favicon = document.querySelector("link[rel='icon']");
const alertIcon = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0f8f6f"/><circle cx="46" cy="18" r="11" fill="#ff3b30"/><path fill="#fff" d="M18 24h22v6H18zm0 11h16v6H18z"/></svg>')}`;
state.attentionTimer = setInterval(() => {
state.attentionOn = !state.attentionOn;
document.title = state.attentionOn ? "* Nova zprava - MaalFlows" : state.originalTitle;
if (favicon) favicon.href = state.attentionOn ? alertIcon : state.originalFavicon;
}, 760);
}
function stopAttention() {
if (state.attentionTimer) clearInterval(state.attentionTimer);
state.attentionTimer = null;
stopRepeatingSound();
state.attentionOn = false;
document.title = state.originalTitle;
const favicon = document.querySelector("link[rel='icon']");
if (favicon) favicon.href = state.originalFavicon;
}
function isVisitorMessageEvent(data) {
if (data.type !== "message:new") return false;
const messages = data.payload?.messages || [];
return messages[messages.length - 1]?.senderType === "visitor";
}
function eventConversationIdFrom(data) {
return data.conversationId || data.conversation?.id || data.payload?.conversation?.id || null;
}
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
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
Object.assign(error, data);
throw error;
}
return data;
}
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 normalizeHexColor(value) {
const match = String(value || "").trim().match(/^#?([0-9a-fA-F]{6})$/);
return match ? `#${match[1].toLowerCase()}` : "";
}
function flag(countryCode) {
if (!countryCode || countryCode.length !== 2) return "";
return countryCode.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt()));
}