Files
MaalFlows/public/admin.js
T

1797 lines
71 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: [],
adminUsers: [],
me: null,
operatorPresence: {},
operatorTyping: {},
operatorPresenceTimer: null,
visitorRenderKey: ""
};
const $ = (selector) => document.querySelector(selector);
const WIDGET_LANGUAGES = [
["cs", "Cestina"], ["sk", "Slovenstina"], ["en", "Anglictina"], ["de", "Nemcina"], ["pl", "Polstina"],
["hu", "Madarstina"], ["ro", "Rumunstina"], ["bg", "Bulharstina"], ["hr", "Chorvatstina"], ["sl", "Slovinstina"],
["it", "Italstina"], ["fr", "Francouzstina"], ["es", "Spanelstina"], ["pt", "Portugalstina"], ["nl", "Nizozemstina"],
["da", "Danstina"], ["sv", "Svedstina"], ["fi", "Finstina"], ["no", "Norstina"], ["et", "Estonstina"],
["lv", "Lotystina"], ["lt", "Litevstina"], ["el", "Rectina"], ["uk", "Ukrajinstina"], ["ru", "Rustina"],
["tr", "Turectina"], ["sr", "Srb-stina"], ["bs", "Bosenstina"], ["sq", "Albanstina"], ["mk", "Makedonstina"],
["mt", "Maltstina"], ["ga", "Irstina"], ["is", "Islandstina"], ["be", "Belorustina"], ["ca", "Katalanstina"]
];
boot();
renderAdminSoundControls();
async function boot() {
const me = await api("/api/admin/me").catch(() => null);
if (me?.user) {
state.me = 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"));
$("#presenceActiveSeconds").addEventListener("input", markPresenceSettingsDirty);
$("#presenceOfflineSeconds").addEventListener("input", markPresenceSettingsDirty);
$("#savePresenceSettings").addEventListener("click", savePresenceSettings);
$("#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);
$("#telegramEnabled").addEventListener("change", markTelegramSettingsDirty);
$("#telegramOperatorName").addEventListener("input", markTelegramSettingsDirty);
$("#telegramBotToken").addEventListener("input", markTelegramSettingsDirty);
$("#telegramChatId").addEventListener("input", markTelegramSettingsDirty);
$("#telegramWebhookSecret").addEventListener("input", markTelegramSettingsDirty);
$("#saveTelegramSettings").addEventListener("click", saveTelegramSettings);
$("#testTelegram").addEventListener("click", testTelegram);
$("#adminUserForm").addEventListener("submit", createAdminUser);
$("#adminUsersList").addEventListener("click", handleAdminUserClick);
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;
});
$("#settingsForm").defaultLanguage.addEventListener("change", () => {
updateDefaultLanguagePicker();
renderWidgetCopyFields($("#settingsForm").defaultLanguage.value);
});
$("#defaultLanguagePicker").addEventListener("click", (event) => {
event.stopPropagation();
toggleDefaultLanguageMenu();
});
$("#defaultLanguageMenu").addEventListener("click", (event) => {
const option = event.target.closest("[data-language-option]");
if (!option) return;
$("#settingsForm").defaultLanguage.value = option.dataset.languageOption;
$("#settingsForm").defaultLanguage.dispatchEvent(new Event("change", { bubbles: true }));
closeDefaultLanguageMenu();
});
document.addEventListener("click", (event) => {
if (!event.target.closest(".language-select")) closeDefaultLanguageMenu();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeDefaultLanguageMenu();
});
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 submitButton = event.currentTarget.querySelector("button[type='submit']");
setSettingsSaveStatus("saving", "Ukládám...");
const form = new FormData(event.currentTarget);
const settings = structuredClone(state.site.settings);
const defaultLanguage = form.get("defaultLanguage") || "cs";
settings.widgetLanguage = {
...(settings.widgetLanguage || {}),
mode: form.get("widgetLanguageMode") || "auto",
defaultLanguage
};
settings.widgetCopy = settings.widgetCopy || {};
settings.widgetCopy[defaultLanguage] = {
...(settings.widgetCopy[defaultLanguage] || {}),
title: form.get("copyTitle") || "",
intro: form.get("copyIntro") || "",
offlineIntro: form.get("copyOfflineIntro") || "",
placeholder: form.get("copyPlaceholder") || "",
sendLabel: form.get("copySendLabel") || "",
dropzoneLabel: form.get("copyDropzoneLabel") || "",
onlineLabel: form.get("copyOnlineLabel") || "",
offlineLabel: settings.widgetCopy[defaultLanguage]?.offlineLabel || "Offline",
newReplyLabel: form.get("copyNewReplyLabel") || "",
typingLabel: settings.widgetCopy[defaultLanguage]?.typingLabel || "Operátor píše...",
infoTitle: settings.widgetCopy[defaultLanguage]?.infoTitle || "GDPR informace",
infoText: form.get("copyInfoText") || "",
closeLabel: settings.widgetCopy[defaultLanguage]?.closeLabel || "Zmenšit chat",
operatorReplyLabel: form.get("copyOperatorReplyLabel") || ""
};
syncLegacyWidgetCopy(settings);
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";
submitButton.disabled = true;
try {
setSettingsSaveStatus("translating", "Překládám...");
const data = await saveSite({ settings, isOnline: state.site.isOnline });
renderWidgetCopyFields(state.site.settings.widgetLanguage?.defaultLanguage || defaultLanguage);
const sync = data.widgetCopySync;
if (sync?.errors?.length) {
const failedLanguages = [...new Set(sync.errors.map((item) => item.language?.toUpperCase()).filter(Boolean))];
setSettingsSaveStatus("warning", `Uloženo, překlad selhal: ${failedLanguages.join(", ") || "AI"}`);
return;
}
if (sync?.translated) setSettingsSaveStatus("saved", `✓ Uloženo a přeloženo`);
else setSettingsSaveStatus("saved", "✓ Uloženo");
setTimeout(() => $("#settingsPanel").classList.add("hidden"), 700);
} catch (error) {
setSettingsSaveStatus("error", "Akce se nepodařila.");
} finally {
submitButton.disabled = false;
}
});
$("#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("change", (event) => {
state.hideBots = event.target.checked;
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");
if (!state.me) {
const me = await api("/api/admin/me").catch(() => null);
state.me = me?.user || null;
}
await loadBootstrap();
await loadAiModels().catch(() => {});
await loadAdminUsers().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 = stableVisitors(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;
state.operatorPresence[id] = data.operators || [];
const index = state.conversations.findIndex((item) => item.id === id);
if (index >= 0) state.conversations[index] = { ...state.conversations[index], ...data.conversation };
syncConversationVisitors();
renderConversations();
renderActive();
startOperatorPresence(id);
syncAttentionWithUnread();
}
function startOperatorPresence(id) {
stopOperatorPresence();
sendOperatorPresence(id);
state.operatorPresenceTimer = setInterval(() => {
if (state.activeId) sendOperatorPresence(state.activeId);
}, 15_000);
}
function stopOperatorPresence() {
if (!state.operatorPresenceTimer) return;
clearInterval(state.operatorPresenceTimer);
state.operatorPresenceTimer = null;
}
function sendOperatorPresence(id) {
api(`/api/admin/conversations/${id}/presence`, { method: "POST" })
.then((data) => {
state.operatorPresence[id] = data.operators || [];
if (id === state.activeId) renderOperatorPresence();
})
.catch(() => {});
}
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 === "typing" && data.actor === "admin") return flashOperatorTyping(data);
if (data.type === "operator:presence") {
state.operatorPresence[data.conversationId] = data.operators || [];
if (data.conversationId === state.activeId) renderOperatorPresence();
return;
}
if (data.type === "site:update") {
state.site = data.site;
renderSite();
return;
}
if (data.type === "visitors:update") {
state.visitors = stableVisitors(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");
renderWidgetLanguageOptions(form, s);
renderWidgetCopyFields(s.widgetLanguage?.defaultLanguage || "cs");
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");
renderPresenceSettings(s.adminPresence || {});
renderTelegramSettings(s.telegram || {});
}
function renderWidgetLanguageOptions(form, settings) {
const language = settings.widgetLanguage || {};
const options = WIDGET_LANGUAGES
.map(([code, label]) => `<option value="${code}">${code.toUpperCase()} - ${escapeHtml(label)}</option>`)
.join("");
form.defaultLanguage.innerHTML = options;
$("#defaultLanguageMenu").innerHTML = WIDGET_LANGUAGES
.map(([code, label]) => languageOptionHtml(code, label))
.join("");
form.widgetLanguageMode.value = language.mode === "fixed" ? "default" : language.mode || "auto";
form.defaultLanguage.value = language.defaultLanguage || "cs";
updateDefaultLanguagePicker();
}
function languageCountryCode(language) {
const countries = {
cs: "CZ", sk: "SK", en: "GB", de: "DE", pl: "PL", hu: "HU", ro: "RO", bg: "BG", hr: "HR", sl: "SI",
it: "IT", fr: "FR", es: "ES", pt: "PT", nl: "NL", da: "DK", sv: "SE", fi: "FI", no: "NO", et: "EE",
lv: "LV", lt: "LT", el: "GR", uk: "UA", ru: "RU", tr: "TR", sr: "RS", bs: "BA", sq: "AL", mk: "MK",
mt: "MT", ga: "IE", is: "IS", be: "BY", ca: "ES"
};
return countries[language] || "";
}
function flagImageHtml(countryCode) {
const code = String(countryCode || "").toUpperCase();
if (!/^[A-Z]{2}$/.test(code)) return `<span class="flag-image">?</span>`;
return `<span class="flag-image" style="background-image:url('https://flagcdn.com/24x18/${code.toLowerCase()}.png')" aria-hidden="true"></span>`;
}
function languageOptionHtml(language, label) {
const countryCode = languageCountryCode(language);
return `
<button class="language-option" type="button" role="option" data-language-option="${language}">
${flagImageHtml(countryCode)}
<span>${language.toUpperCase()} - ${escapeHtml(label)}</span>
</button>
`;
}
function updateDefaultLanguagePicker() {
const picker = $("#defaultLanguagePicker");
const menu = $("#defaultLanguageMenu");
const select = $("#settingsForm")?.defaultLanguage;
if (!picker || !select) return;
const language = select.value || "cs";
const item = WIDGET_LANGUAGES.find(([code]) => code === language) || WIDGET_LANGUAGES[0];
picker.innerHTML = `${flagImageHtml(languageCountryCode(language))}<span>${item[0].toUpperCase()} - ${escapeHtml(item[1])}</span>`;
if (menu) {
menu.querySelectorAll("[data-language-option]").forEach((option) => {
option.classList.toggle("active", option.dataset.languageOption === language);
});
}
}
function toggleDefaultLanguageMenu() {
const menu = $("#defaultLanguageMenu");
const picker = $("#defaultLanguagePicker");
const isOpen = menu && !menu.classList.contains("hidden");
if (isOpen) closeDefaultLanguageMenu();
else {
menu?.classList.remove("hidden");
picker?.setAttribute("aria-expanded", "true");
}
}
function closeDefaultLanguageMenu() {
$("#defaultLanguageMenu")?.classList.add("hidden");
$("#defaultLanguagePicker")?.setAttribute("aria-expanded", "false");
}
function renderWidgetCopyFields(language) {
const form = $("#settingsForm");
const settings = state.site?.settings || {};
const copy = widgetCopyForAdmin(settings, language);
form.copyTitle.value = copy.title || "";
form.copyIntro.value = copy.intro || "";
form.copyOfflineIntro.value = copy.offlineIntro || "";
form.copyPlaceholder.value = copy.placeholder || "";
form.copySendLabel.value = copy.sendLabel || "";
form.copyDropzoneLabel.value = copy.dropzoneLabel || "";
form.copyOnlineLabel.value = copy.onlineLabel || "";
form.copyNewReplyLabel.value = copy.newReplyLabel || "";
form.copyOperatorReplyLabel.value = copy.operatorReplyLabel || "";
form.copyInfoText.value = copy.infoText || "";
}
function widgetCopyForAdmin(settings, language) {
const lang = language || "cs";
const copy = settings.widgetCopy?.[lang] || {};
const legacy = {
title: settings.title?.[lang],
intro: settings.intro?.[lang],
offlineIntro: settings.offlineIntro?.[lang],
placeholder: settings.placeholder?.[lang],
sendLabel: settings.sendLabel?.[lang]
};
return normalizeWidgetCopyForAdmin({ ...defaultWidgetCopy(lang), ...legacy, ...copy }, lang);
}
function normalizeWidgetCopyForAdmin(copy, language) {
if (language !== "cs") return copy;
const defaults = defaultWidgetCopy("cs");
const legacy = legacyCzechWidgetCopy();
const normalized = { ...copy };
for (const field of Object.keys(legacy)) {
if (normalized[field] === legacy[field]) normalized[field] = defaults[field];
}
return normalized;
}
function defaultWidgetCopy(language) {
if (language === "en") {
return {
title: "Need help?",
intro: "Message us and we will reply as soon as possible.",
offlineIntro: "We are offline, but you can leave us a message.",
placeholder: "Write a message...",
sendLabel: "Send",
dropzoneLabel: "Add a photo or drag it here",
onlineLabel: "We are online",
newReplyLabel: "New reply",
infoText: "We process your message and technical session data only to answer your request.",
operatorReplyLabel: "is replying"
};
}
return {
title: "Potřebujete poradit?",
intro: "Napište nám. Odpovíme co nejdříve.",
offlineIntro: "Teď nejsme online, ale zprávu nám můžete nechat.",
placeholder: "Napište zprávu...",
sendLabel: "Odeslat",
dropzoneLabel: "Přidat fotku nebo přetáhnout",
onlineLabel: "Jsme online",
newReplyLabel: "Nová odpověď",
infoText: "Zprávu a technické údaje relace zpracujeme jen pro odpověď na váš dotaz.",
operatorReplyLabel: "odpovídá"
};
}
function legacyCzechWidgetCopy() {
return {
title: "Potrebujete poradit?",
intro: "Napiste nam. Odpovime co nejdrive.",
offlineIntro: "Ted nejsme online, ale zpravu nam muzete nechat.",
placeholder: "Napiste zpravu...",
sendLabel: "Odeslat",
dropzoneLabel: "Pridat fotku nebo pretahnout",
onlineLabel: "Jsme online",
newReplyLabel: "Nova odpoved",
infoText: "Zpravu a technicke udaje relace zpracujeme jen pro odpoved na vas dotaz.",
operatorReplyLabel: "odpovida"
};
}
function syncLegacyWidgetCopy(settings) {
settings.title = settings.title || {};
settings.intro = settings.intro || {};
settings.offlineIntro = settings.offlineIntro || {};
settings.placeholder = settings.placeholder || {};
settings.sendLabel = settings.sendLabel || {};
for (const [language, copy] of Object.entries(settings.widgetCopy || {})) {
settings.title[language] = copy.title;
settings.intro[language] = copy.intro;
settings.offlineIntro[language] = copy.offlineIntro;
settings.placeholder[language] = copy.placeholder;
settings.sendLabel[language] = copy.sendLabel;
}
}
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() {
await saveTranslationSettings();
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-..."}), model ${result.model || "AI"} umi prekladat.`);
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(openAiTestMessage(result) || message);
}
function openAiTestMessage(result) {
if (result?.error === "missing_openai_api_key") return "Klic neni ulozeny. Nejdriiv ho vloz a klikni na Ulozit AI nastaveni.";
if (result?.error === "quota_exceeded") return "OpenAI klic je ulozeny, ale chybi kredit nebo billing. Preklady zatim nepobezi.";
if (result?.error === "invalid_key") return "OpenAI klic je spatny nebo nema pristup. Vloz novy klic.";
if (result?.error === "bad_model") return "Zvoleny OpenAI model neni dostupny pro tento klic. Vyber jiny model.";
if (result?.error === "rate_limited") return "OpenAI docasne omezuje pozadavky. Zkus test za chvili.";
if (result?.message) return `Test selhal (${result?.error || "chyba"}): ${shortErrorMessage(result.message)}`;
return "";
}
function shortErrorMessage(value) {
const text = String(value || "")
.replace(/\s+/g, " ")
.replace(/^OpenAI HTTP\s+/i, "OpenAI ")
.trim();
if (!text) return "bez detailu";
return text.length > 220 ? `${text.slice(0, 217)}...` : text;
}
function setOpenAiStatus(message) {
$("#openaiApiKeyStatus").textContent = message;
}
function renderPresenceSettings(settings) {
const normalized = normalizePresenceSettings(settings);
$("#presenceActiveSeconds").value = normalized.activeSeconds;
$("#presenceOfflineSeconds").value = normalized.offlineSeconds;
setPresenceStatus(`Oranzova: ${normalized.activeSeconds}-${normalized.offlineSeconds} s necinosti.`);
}
function markPresenceSettingsDirty() {
const normalized = normalizePresenceSettings({
activeSeconds: $("#presenceActiveSeconds").value,
offlineSeconds: $("#presenceOfflineSeconds").value
});
setPresenceStatus(`Neulozeno. Oranzova bude ${normalized.activeSeconds}-${normalized.offlineSeconds} s.`);
}
async function savePresenceSettings() {
if (!state.site) return;
const normalized = normalizePresenceSettings({
activeSeconds: $("#presenceActiveSeconds").value,
offlineSeconds: $("#presenceOfflineSeconds").value
});
setPresenceStatus("Ukladam signalizaci...");
const settings = structuredClone(state.site.settings);
settings.adminPresence = normalized;
await saveSite({ settings, isOnline: state.site.isOnline });
renderPresenceSettings(state.site.settings.adminPresence || normalized);
await Promise.all([loadConversations(), loadVisitors()]);
setPresenceStatus(`Ulozeno. Oranzova: ${normalized.activeSeconds}-${normalized.offlineSeconds} s.`);
}
function normalizePresenceSettings(value = {}) {
const activeSeconds = clampNumber(value.activeSeconds, 10, 3600, 30);
const offlineSeconds = Math.max(clampNumber(value.offlineSeconds, 20, 7200, 120), activeSeconds + 10);
return { activeSeconds, offlineSeconds };
}
function clampNumber(value, min, max, fallback) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(max, Math.max(min, Math.round(number)));
}
function setPresenceStatus(message) {
$("#presenceStatus").textContent = message;
}
function renderTelegramSettings(settings) {
$("#telegramEnabled").checked = Boolean(settings.enabled);
$("#telegramOperatorName").value = settings.operatorName || "Alex";
$("#telegramBotToken").value = "";
$("#telegramChatId").value = settings.chatId || "";
$("#telegramWebhookSecret").value = settings.webhookSecret || "";
$("#telegramWebhookUrl").value = settings.webhookUrl || "";
setTelegramStatus(settings.hasBotToken
? `Token ulozeny (${settings.botTokenMasked || "token..."})`
: "Token neni ulozeny. Bez nej Telegram nepobezi.");
}
function markTelegramSettingsDirty() {
setTelegramStatus("Telegram nastaveni ma neulozene zmeny.");
}
async function saveTelegramSettings() {
if (!state.site) return;
setTelegramStatus("Ukladam Telegram...");
const settings = structuredClone(state.site.settings);
settings.telegram = {
...(settings.telegram || {}),
enabled: $("#telegramEnabled").checked,
chatId: $("#telegramChatId").value.trim(),
webhookSecret: $("#telegramWebhookSecret").value.trim(),
operatorName: $("#telegramOperatorName").value.trim() || "Alex"
};
const botToken = $("#telegramBotToken").value.trim();
if (botToken) settings.telegram.botToken = botToken;
await saveSite({ settings, isOnline: state.site.isOnline });
$("#telegramBotToken").value = "";
renderTelegramSettings(state.site.settings.telegram || {});
}
async function testTelegram() {
await saveTelegramSettings();
setTelegramStatus("Posilam test Telegramu...");
const result = await api("/api/admin/telegram/test", { method: "POST" }).catch((error) => error);
if (result?.ok) {
if (result.webhookUrl) $("#telegramWebhookUrl").value = result.webhookUrl;
setTelegramStatus("Telegram funguje. Testovaci zprava odeslana a webhook nastaven.");
return;
}
setTelegramStatus(`Telegram test selhal: ${shortErrorMessage(result?.message || result?.error || "neznamá chyba")}`);
}
function setTelegramStatus(message) {
$("#telegramStatus").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 = sortAiModels([...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 sortAiModels(models) {
return models.sort((a, b) => a.localeCompare(b, "en", { numeric: true, sensitivity: "base" }));
}
function fallbackAiModels() {
return ["gpt-5-mini", "gpt-5-nano", "gpt-5.1", "gpt-4.1-mini"];
}
async function loadAdminUsers() {
const data = await api("/api/admin/users");
state.adminUsers = data.users || [];
renderAdminUsers();
}
async function createAdminUser(event) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
setAdminUsersStatus("Ukladam uzivatele...");
const result = await api("/api/admin/users", {
method: "POST",
body: {
username: data.get("username"),
displayName: data.get("displayName"),
chatNick: data.get("chatNick"),
password: data.get("password")
}
}).catch((error) => error);
if (result?.users) {
state.adminUsers = result.users;
form.reset();
renderAdminUsers();
setAdminUsersStatus("Uzivatel pridan.");
return;
}
setAdminUsersStatus(adminUserError(result));
}
async function handleAdminUserClick(event) {
const resetButton = event.target.closest("[data-reset-admin-user]");
const renameButton = event.target.closest("[data-rename-admin-user]");
const nickButton = event.target.closest("[data-nick-admin-user]");
const deleteButton = event.target.closest("[data-delete-admin-user]");
if (renameButton) {
const id = renameButton.dataset.renameAdminUser;
const user = state.adminUsers.find((item) => String(item.id) === String(id));
if (!user || user.isSystem) return;
const displayName = window.prompt(`Jmeno operatora pro ${user.username}`, user.displayName || user.username);
if (displayName === null) return;
setAdminUsersStatus("Ukladam jmeno...");
const result = await api(`/api/admin/users/${id}`, {
method: "PATCH",
body: { displayName }
}).catch((error) => error);
if (result?.users) {
state.adminUsers = result.users;
renderAdminUsers();
setAdminUsersStatus("Jmeno ulozeno.");
return;
}
setAdminUsersStatus(adminUserError(result));
}
if (nickButton) {
const id = nickButton.dataset.nickAdminUser;
const user = state.adminUsers.find((item) => String(item.id) === String(id));
if (!user || user.isSystem) return;
const chatNick = window.prompt(`Nick v chatu pro ${user.username}`, user.chatNick || user.displayName || user.username);
if (chatNick === null) return;
setAdminUsersStatus("Ukladam nick...");
const result = await api(`/api/admin/users/${id}`, {
method: "PATCH",
body: { chatNick }
}).catch((error) => error);
if (result?.users) {
state.adminUsers = result.users;
renderAdminUsers();
setAdminUsersStatus("Nick ulozen.");
return;
}
setAdminUsersStatus(adminUserError(result));
}
if (resetButton) {
const id = resetButton.dataset.resetAdminUser;
const user = state.adminUsers.find((item) => String(item.id) === String(id));
if (!user || user.isSystem) return;
const password = window.prompt(`Nove heslo pro ${user.username} (min. 8 znaku)`);
if (!password) return;
setAdminUsersStatus("Menim heslo...");
const result = await api(`/api/admin/users/${id}`, {
method: "PATCH",
body: { password }
}).catch((error) => error);
if (result?.users) {
state.adminUsers = result.users;
renderAdminUsers();
setAdminUsersStatus("Heslo zmeneno.");
return;
}
setAdminUsersStatus(adminUserError(result));
}
if (deleteButton) {
const id = deleteButton.dataset.deleteAdminUser;
const user = state.adminUsers.find((item) => String(item.id) === String(id));
if (!user || user.isSystem) return;
if (!window.confirm(`Smazat BO uzivatele ${user.username}?`)) return;
setAdminUsersStatus("Mazu uzivatele...");
const result = await api(`/api/admin/users/${id}`, { method: "DELETE" }).catch((error) => error);
if (result?.users) {
state.adminUsers = result.users;
renderAdminUsers();
setAdminUsersStatus("Uzivatel smazan.");
return;
}
setAdminUsersStatus(adminUserError(result));
}
}
function renderAdminUsers() {
$("#adminUsersList").innerHTML = state.adminUsers.length ? state.adminUsers.map((user) => `
<article class="admin-user-item">
<div>
<strong>${escapeHtml(user.displayName || user.username)}</strong>
<span>${escapeHtml(user.username)} &middot; chat: ${escapeHtml(user.chatNick || user.displayName || user.username)} &middot; ${user.isSystem ? "systemovy .env ucet" : "BO ucet"}</span>
</div>
<div class="admin-user-actions">
<span class="password-mask" title="Heslo se nezobrazuje">${user.hasPassword ? "&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;" : "bez hesla"}</span>
<button type="button" data-rename-admin-user="${user.id}" ${user.isSystem ? "disabled" : ""}>Jmeno</button>
<button type="button" data-nick-admin-user="${user.id}" ${user.isSystem ? "disabled" : ""}>Nick</button>
<button type="button" data-reset-admin-user="${user.id}" ${user.isSystem ? "disabled" : ""}>Reset hesla</button>
<button type="button" data-delete-admin-user="${user.id}" ${user.isSystem ? "disabled" : ""}>Smazat</button>
</div>
</article>
`).join("") : `<p class="empty">Zatim tu nejsou zadni BO uzivatele.</p>`;
}
function adminUserError(error) {
if (error?.error === "bad_username") return "Login musi mit 3-60 znaku: pismena, cisla, tecka, pomlcka, podtrzitko nebo @.";
if (error?.error === "bad_password") return "Heslo musi mit aspon 8 znaku.";
if (error?.error === "username_exists") return "Tento login uz existuje.";
if (error?.error === "system_user_password_env") return "Systemovy .env ucet se meni na serveru v .env.";
if (error?.error === "system_user_profile_env") return "Systemovy .env ucet se prejmenovava na serveru v .env.";
if (error?.error === "system_user_protected") return "Systemovy .env ucet nejde smazat.";
if (error?.error === "cannot_delete_current_user") return "Nemuzes smazat uzivatele, pod kterym jsi prihlaseny.";
return `Akce se nepodarila (${error?.error || "neznama chyba"}).`;
}
function setAdminUsersStatus(message) {
$("#adminUsersStatus").textContent = message;
}
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;
const nextRenderKey = visitorListRenderKey(visitors, botCount);
if (nextRenderKey === state.visitorRenderKey) return;
state.visitorRenderKey = nextRenderKey;
$("#hideBotsToggle").checked = state.hideBots;
$("#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 stableVisitors(visitors) {
return [...visitors].sort((a, b) => {
const aFirst = Date.parse(a.firstSeenAt || "") || 0;
const bFirst = Date.parse(b.firstSeenAt || "") || 0;
if (aFirst !== bFirst) return aFirst - bFirst;
return String(a.id || "").localeCompare(String(b.id || ""));
});
}
function visitorListRenderKey(visitors, botCount) {
return JSON.stringify({
hideBots: state.hideBots,
botCount,
visitors: visitors.map((visitor) => ({
id: visitor.id,
label: visitor.label,
presence: visitor.presence,
currentUrl: visitor.currentUrl,
referrer: visitor.referrer,
lastConversationId: visitor.lastConversationId,
active: visitor.lastConversationId === state.activeId,
isBot: visitor.isBot,
botReason: visitor.botReason,
pageCount: visitor.pageCount,
visitCount: visitor.visitCount,
conversationCount: visitor.conversationCount,
sessionMinute: Math.floor(Number(visitor.sessionSeconds || 0) / 60)
}))
});
}
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)}
<span id="operatorPresence" class="operator-presence hidden"></span>
</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);
renderOperatorPresence();
$("#messages").scrollTop = $("#messages").scrollHeight;
}
function renderOperatorPresence() {
const boxes = [$("#operatorPresence"), $("#replyOperatorPresence")].filter(Boolean);
if (!boxes.length || !state.activeId) return;
const operators = (state.operatorPresence[state.activeId] || [])
.filter((operator) => operator.username !== state.me?.username);
const typing = state.operatorTyping[state.activeId];
const parts = [];
if (operators.length) {
parts.push(`V chatu: ${operators.map((operator) => operator.name).join(", ")}`);
}
if (typing && typing.username !== state.me?.username) {
parts.push(`${typing.name} pise...`);
}
const text = parts.join(" · ");
const isTyping = Boolean(typing && typing.username !== state.me?.username);
for (const box of boxes) {
box.textContent = text;
box.classList.toggle("hidden", !parts.length);
box.classList.toggle("typing", isTyping);
}
}
function flashOperatorTyping(data) {
if (data.conversationId !== state.activeId || data.username === state.me?.username) return;
state.operatorTyping[data.conversationId] = {
username: data.username,
name: data.name || "Operator"
};
renderOperatorPresence();
clearTimeout(flashOperatorTyping.timer);
flashOperatorTyping.timer = setTimeout(() => {
delete state.operatorTyping[data.conversationId];
renderOperatorPresence();
}, 2200);
}
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 === "quota_exceeded") return "preklad: neni kredit";
if (status === "invalid_key") return "preklad: spatny klic";
if (status === "bad_model") return "preklad: spatny model";
if (status === "rate_limited") return "preklad: limit API";
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() {
stopOperatorPresence();
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();
return data;
}
function setSettingsSaveStatus(type, text) {
const status = $("#settingsSaveStatus");
if (!status) return;
status.className = `settings-save-status ${type || ""}`.trim();
status.textContent = text || "";
}
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()));
}