const state = { site: null, snippet: "", conversations: [], activeId: null, active: null, events: null, statusFilter: "active", replyFiles: [] }; const $ = (selector) => document.querySelector(selector); boot(); async function boot() { const me = await api("/api/admin/me").catch(() => null); if (me?.user) showApp(); else showLogin(); } $("#loginForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); const result = await api("/api/admin/login", { method: "POST", body: { username: form.get("username"), password: form.get("password") } }).catch(() => null); if (!result?.ok) { $("#loginError").textContent = "Prihlaseni se nepodarilo."; return; } showApp(); }); $("#logoutButton").addEventListener("click", async () => { await api("/api/admin/logout", { method: "POST" }); location.reload(); }); $("#settingsButton").addEventListener("click", () => $("#settingsPanel").classList.remove("hidden")); $("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden")); $("#onlineToggle").addEventListener("change", async () => { await saveSite({ isOnline: $("#onlineToggle").checked }); }); $("#settingsForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); const settings = structuredClone(state.site.settings); settings.title.cs = form.get("titleCs"); settings.intro.cs = form.get("introCs"); settings.title.en = form.get("titleEn"); settings.intro.en = form.get("introEn"); settings.colors.brand = form.get("brand"); settings.desktop.side = form.get("desktopSide"); settings.desktop.bottomPx = Number(form.get("desktopBottom")); settings.desktop.sideOffsetPx = Number(form.get("desktopOffset")); settings.mobile.side = form.get("mobileSide"); settings.mobile.bottomPx = Number(form.get("mobileBottom")); settings.mobile.sideOffsetPx = Number(form.get("mobileOffset")); await saveSite({ settings, isOnline: state.site.isOnline }); }); $("#replyForm").addEventListener("submit", async (event) => { event.preventDefault(); if (!state.activeId) return; const form = new FormData(event.currentTarget); 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; await api(`/api/admin/conversations/${state.activeId}/messages`, { method: "POST", body: payload }); event.currentTarget.reset(); state.replyFiles = []; renderReplyAttachments(); await openConversation(state.activeId); }); $("#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) => { state.replyFiles = mergeImageFiles(state.replyFiles, [...event.target.files]); event.target.value = ""; renderReplyAttachments(); }); $(".admin-dropzone").addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); $("#replyForm input[name='attachments']").click(); } }); for (const eventName of ["dragenter", "dragover"]) { $(".admin-dropzone").addEventListener(eventName, (event) => { event.preventDefault(); $(".admin-dropzone").classList.add("dragging"); }); } for (const eventName of ["dragleave", "drop"]) { $(".admin-dropzone").addEventListener(eventName, (event) => { event.preventDefault(); $(".admin-dropzone").classList.remove("dragging"); }); } $(".admin-dropzone").addEventListener("drop", (event) => { state.replyFiles = mergeImageFiles(state.replyFiles, [...event.dataTransfer.files]); renderReplyAttachments(); }); $("#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(); }); $("#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); }); async function showApp() { $("#loginView").classList.add("hidden"); $("#appView").classList.remove("hidden"); await loadBootstrap(); await loadConversations(); connectEvents(); } function showLogin() { $("#loginView").classList.remove("hidden"); $("#appView").classList.add("hidden"); } async function loadBootstrap() { const data = await api("/api/admin/bootstrap"); state.site = data.site; state.snippet = data.snippet; renderSite(); } async function loadConversations() { const data = await api("/api/admin/conversations"); state.conversations = data.conversations; renderConversations(); } async function openConversation(id) { state.activeId = id; const data = await api(`/api/admin/conversations/${id}`); state.active = data; const index = state.conversations.findIndex((item) => item.id === id); if (index >= 0) state.conversations[index] = { ...state.conversations[index], ...data.conversation }; renderConversations(); renderActive(); } function connectEvents() { if (state.events) state.events.close(); state.events = new EventSource("/api/admin/events"); state.events.addEventListener("message", async (event) => { const data = JSON.parse(event.data); if (data.type === "typing" && data.actor === "visitor" && data.conversationId !== state.activeId) return; if (data.type === "typing" && data.actor === "visitor") return flashTyping(); if (data.type === "site:update") { state.site = data.site; renderSite(); return; } await loadConversations(); if (data.type === "conversation:delete" && data.conversationId === state.activeId) { clearActiveConversation(); return; } if (state.activeId) await openConversation(state.activeId).catch(() => clearActiveConversation()); if (data.type === "conversation:new" || data.type === "message:new") notify(); }); } function renderSite() { $("#onlineToggle").checked = state.site.isOnline; $("#snippet").textContent = state.snippet; const s = state.site.settings; const form = $("#settingsForm"); form.titleCs.value = s.title.cs || ""; form.introCs.value = s.intro.cs || ""; form.titleEn.value = s.title.en || ""; form.introEn.value = s.intro.en || ""; form.brand.value = s.colors.brand || "#0f8f6f"; form.desktopSide.value = s.desktop.side || "right"; form.desktopBottom.value = s.desktop.bottomPx || 22; form.desktopOffset.value = s.desktop.sideOffsetPx || 22; form.mobileSide.value = s.mobile.side || "right"; form.mobileBottom.value = s.mobile.bottomPx || 14; form.mobileOffset.value = s.mobile.sideOffsetPx || 12; } function renderConversations() { const conversations = filteredConversations(); $("#conversationList").innerHTML = conversations.length ? conversations.map((item) => `
${statusButtons(item)}
`).join("") : `

Zadny chat pro vybrany filtr.

`; } function renderActive() { const { conversation, messages } = state.active; $("#conversationHeader").innerHTML = `

${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}

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

${escapeHtml(message.body)}

${renderAttachments(message.attachments)}
`).join(""); $("#messages").scrollTop = $("#messages").scrollHeight; } 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); return countryCode ? `${flag(countryCode)} ${escapeHtml(countryCode)}` : "zeme ?"; } 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 `
    Detail navstevnika
    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() { state.activeId = null; state.active = null; $("#conversationHeader").innerHTML = `

    Vyber konverzaci

    Nova konverzace se tady zvyrazni a pusti zvuk.

    `; $("#messages").innerHTML = ""; $("#replyForm").classList.add("hidden"); } 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 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 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(); } function notify() { document.title = "* Nova zprava - MaalFlows"; const audio = new AudioContext(); const osc = audio.createOscillator(); const gain = audio.createGain(); osc.frequency.value = 880; gain.gain.value = 0.04; osc.connect(gain); gain.connect(audio.destination); osc.start(); setTimeout(() => { osc.stop(); audio.close(); }, 160); } function flashTyping() { $("#typingNotice").classList.remove("hidden"); clearTimeout(flashTyping.timer); flashTyping.timer = setTimeout(() => $("#typingNotice").classList.add("hidden"), 1800); } async function api(url, options = {}) { const response = await fetch(url, { method: options.method || "GET", headers: { "Content-Type": "application/json" }, body: options.body ? JSON.stringify(options.body) : undefined }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); } function debounce(fn, delay) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; } function escapeHtml(value) { return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); } function flag(countryCode) { if (!countryCode || countryCode.length !== 2) return ""; return countryCode.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt())); }