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 }; 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")); $("#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("click", () => { state.hideBots = !state.hideBots; localStorage.setItem("mf_admin_hide_bots", state.hideBots ? "1" : "0"); renderVisitors(); }); $("#conversationList").addEventListener("click", async (event) => { const photoButton = event.target.closest("[data-photo-url]"); const openButton = event.target.closest("[data-open]"); const deleteButton = event.target.closest("[data-delete]"); if (photoButton) { openPhotoPreview(photoButton.dataset.photoUrl, photoButton.dataset.photoName); return; } if (deleteButton) { await deleteConversation(deleteButton.dataset.delete); return; } if (openButton) await openConversation(openButton.dataset.open); }); $("#conversationList").addEventListener("click", async (event) => { const statusButton = event.target.closest("[data-status-button]"); if (!statusButton) return; await updateConversationStatus(statusButton.dataset.statusButton, statusButton.dataset.status); }); $("#messages").addEventListener("click", (event) => { const deleteButton = event.target.closest("[data-delete-attachment]"); if (deleteButton) { deleteAttachment(deleteButton.dataset.deleteAttachment); return; } const preview = event.target.closest("[data-photo-url]"); if (!preview) return; openPhotoPreview(preview.dataset.photoUrl, preview.dataset.photoName); }); $("#visitorList").addEventListener("click", async (event) => { if (event.target.closest("a")) return; const card = event.target.closest("[data-visitor-conversation]"); const conversationId = card?.dataset.visitorConversation; if (!conversationId) return; await openConversation(conversationId); }); $("#conversationHeader").addEventListener("click", async (event) => { const button = event.target.closest("[data-rename-visitor]"); if (!button) return; event.preventDefault(); await renameVisitor(button.dataset.renameVisitor); }); async function showApp() { $("#loginView").classList.add("hidden"); $("#appView").classList.remove("hidden"); 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 = 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 = 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"); 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 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) => `
${escapeHtml(user.displayName || user.username)} ${escapeHtml(user.username)} · chat: ${escapeHtml(user.chatNick || user.displayName || user.username)} · ${user.isSystem ? "systemovy .env ucet" : "BO ucet"}
${user.hasPassword ? "••••••••" : "bez hesla"}
`).join("") : `

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) => `
${statusButtons(item)}
`).join("") : `

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; $("#hideBotsToggle").classList.toggle("active", state.hideBots); $("#hideBotsToggle").textContent = state.hideBots ? "Skryt boty" : "Boty videt"; $("#visitorCount").textContent = `${visitors.length} na webu${state.hideBots && botCount ? ` · ${botCount} bot` : ""}`; $("#visitorList").innerHTML = visitors.length ? visitors.map((visitor) => `
${escapeHtml(visitor.initials || "??")}
${escapeHtml(visitor.label || "Navstevnik")} ${escapeHtml(visitor.isBot ? `bot: ${visitor.botReason || "crawler"}` : browserLabel(visitor.device))}
${presenceDot(visitor.presence)}
${visitorCountry(visitor)} ${formatDuration(visitor.sessionSeconds)} ${Number(visitor.visitCount || visitor.pageCount || 1)} navst. ${Number(visitor.conversationCount || 0)} chaty
${escapeHtml(pageTitle(visitor))}
${visitor.referrer ? escapeHtml(referrerLabel(visitor.referrer)) : "primy vstup"}${visitor.lastConversationId ? "" : " · bez chatu"}
`).join("") : `

Zatim nikdo online.

`; } 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 = `
${presenceDot(conversation.visitorPresence)} ${visitorCountry(conversation)} ${formatDuration(conversation.sessionSeconds)} ${Number(conversation.pageCount || 0)} str. ${Number(conversation.visitorConversationCount || 1)}x chat ${visitorDetails(conversation)}
`; $("#replyForm").classList.remove("hidden"); $("#messages").innerHTML = messages.map((message) => `
${escapeHtml(message.senderName || message.senderType)} · ${escapeHtml(message.createdAt)}
${renderMessageBody(message)} ${renderAttachments(message.attachments)}
`).join("") + adminReadReceipt(conversation, messages); decorateTranslationMeta(messages); renderOperatorPresence(); $("#messages").scrollTop = $("#messages").scrollHeight; } function renderOperatorPresence() { const box = $("#operatorPresence"); if (!box || !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...`); } box.textContent = parts.join(" · "); box.classList.toggle("hidden", !parts.length); box.classList.toggle("typing", Boolean(typing && typing.username !== state.me?.username)); } 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 ? `

${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}
${label}

${escapeHtml(message.translatedBody)}

`; } 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 `
${escapeHtml(text)}
`; } 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]) => ` `).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 `??`; return ` ${escapeHtml(code)} `; } function visitorDetails(conversation) { const history = Array.isArray(conversation.browsingHistory) ? conversation.browsingHistory : []; const historyItems = history.length ? history.slice().reverse().map((item) => `
  • ${escapeHtml(pageLabel(item))} ${escapeHtml(formatVisitedAt(item.at))}
  • `).join("") : `
  • Zatim bez historie.
  • `; return `
    Aktualne ${escapeHtml(shortUrl(conversation.currentUrl) || "-")} Prisiel z ${conversation.referrer ? `${escapeHtml(shortUrl(conversation.referrer))}` : `primy vstup / nezname`} Zarizeni ${escapeHtml(deviceLabel(conversation.device, conversation.language, conversation.timezone))}
      ${historyItems}
    `; } 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 = `

    Vyber konverzaci

    `; $("#messages").innerHTML = ""; $("#replyForm").classList.add("hidden"); syncAttentionWithUnread(); } function renderAttachments(attachments) { if (!attachments.length) return ""; return `
    ${attachments.map((attachment) => { if (!attachment.type?.startsWith("image/")) { return `${escapeHtml(attachment.name)}`; } return `
    ${escapeHtml(attachment.name)}
    `; }).join("")}
    `; } 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) => ` ${escapeHtml(file.name)} `).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 = `
    `; 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('')}`; 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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())); }