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);
$("#clusterBotFilterEnabled").addEventListener("change", markPresenceSettingsDirty);
$("#clusterBotCount").addEventListener("input", markPresenceSettingsDirty);
$("#clusterBotWindowSeconds").addEventListener("input", markPresenceSettingsDirty);
$("#savePresenceSettings").addEventListener("click", savePresenceSettings);
$("#saveBotFilterSettings").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]) => ``)
.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 `?`;
return ``;
}
function languageOptionHtml(language, label) {
const countryCode = languageCountryCode(language);
return `
`;
}
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))}${item[0].toUpperCase()} - ${escapeHtml(item[1])}`;
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;
$("#clusterBotFilterEnabled").checked = normalized.clusterBotFilterEnabled;
$("#clusterBotCount").value = normalized.clusterBotCount;
$("#clusterBotWindowSeconds").value = normalized.clusterBotWindowSeconds;
setPresenceStatus(`Oranzova: ${normalized.activeSeconds}-${normalized.offlineSeconds} s necinosti.`);
setBotFilterStatus(botFilterStatusText(normalized));
}
function markPresenceSettingsDirty() {
const normalized = currentPresenceSettingsFromForm();
setPresenceStatus(`Neulozeno. Oranzova bude ${normalized.activeSeconds}-${normalized.offlineSeconds} s.`);
setBotFilterStatus(`Neulozeno. ${botFilterStatusText(normalized)}`);
}
async function savePresenceSettings() {
if (!state.site) return;
const normalized = currentPresenceSettingsFromForm();
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);
state.visitorRenderKey = "";
await Promise.all([loadConversations(), loadVisitors()]);
setPresenceStatus(`Ulozeno. Oranzova: ${normalized.activeSeconds}-${normalized.offlineSeconds} s necinosti.`);
setBotFilterStatus(`Ulozeno. ${botFilterStatusText(normalized)}`);
}
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,
clusterBotFilterEnabled: value.clusterBotFilterEnabled !== false,
clusterBotCount: clampNumber(value.clusterBotCount, 3, 500, 20),
clusterBotWindowSeconds: clampNumber(value.clusterBotWindowSeconds, 10, 3600, 60)
};
}
function currentPresenceSettingsFromForm() {
return normalizePresenceSettings({
activeSeconds: $("#presenceActiveSeconds").value,
offlineSeconds: $("#presenceOfflineSeconds").value,
clusterBotFilterEnabled: $("#clusterBotFilterEnabled").checked,
clusterBotCount: $("#clusterBotCount").value,
clusterBotWindowSeconds: $("#clusterBotWindowSeconds").value
});
}
function botFilterStatusText(settings) {
return settings.clusterBotFilterEnabled
? `Hromadny filtr: ${settings.clusterBotCount} navstev / ${settings.clusterBotWindowSeconds} s.`
: "Hromadny filtr vypnuty.";
}
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 setBotFilterStatus(message) {
$("#botFilterStatus").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) => ``).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) => `
Zatim tu nejsou zadni BO uzivatele.
`; } 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) => `Zadny chat pro vybrany filtr.
`; } 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) => `Zatim nikdo online.
`; } 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 ``; } 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 = `${escapeHtml(message.body)}
` : ""; if (!message.translatedBody) return original; const label = message.senderType === "visitor" ? `Preklad do ${escapeHtml(message.translatedLanguage || "")}` : `Odeslano zakaznikovi ${escapeHtml(message.translatedLanguage || "")}`; return ` ${original}${escapeHtml(message.translatedBody)}