258 lines
8.6 KiB
JavaScript
258 lines
8.6 KiB
JavaScript
const state = {
|
|
site: null,
|
|
snippet: "",
|
|
conversations: [],
|
|
activeId: null,
|
|
active: null,
|
|
events: null
|
|
};
|
|
|
|
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);
|
|
await api(`/api/admin/conversations/${state.activeId}/messages`, {
|
|
method: "POST",
|
|
body: { message: form.get("message") }
|
|
});
|
|
event.currentTarget.reset();
|
|
await openConversation(state.activeId);
|
|
});
|
|
|
|
$("#replyForm textarea").addEventListener("input", debounce(() => {
|
|
if (!state.activeId) return;
|
|
api(`/api/admin/conversations/${state.activeId}/typing`, { method: "POST" }).catch(() => {});
|
|
}, 600));
|
|
|
|
$("#statusSelect").addEventListener("change", async () => {
|
|
if (!state.activeId) return;
|
|
await api(`/api/admin/conversations/${state.activeId}/status`, {
|
|
method: "PATCH",
|
|
body: { status: $("#statusSelect").value }
|
|
});
|
|
await loadConversations();
|
|
});
|
|
|
|
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;
|
|
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 (state.activeId) await openConversation(state.activeId);
|
|
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() {
|
|
$("#conversationList").innerHTML = state.conversations.map((item) => `
|
|
<button class="conversation-item ${item.id === state.activeId ? "active" : ""} ${item.status === "new" ? "fresh" : ""}" data-id="${item.id}">
|
|
<span class="row"><strong>${escapeHtml(item.visitor.name || item.visitor.email || "Navstevnik")}</strong><span class="status">${item.status}</span></span>
|
|
<span class="preview">${escapeHtml(item.lastBody || "")}</span>
|
|
<span class="meta">${flag(item.countryCode)} ${escapeHtml(item.language || "")} ${escapeHtml(item.currentUrl || "")}</span>
|
|
</button>
|
|
`).join("");
|
|
for (const button of document.querySelectorAll(".conversation-item")) {
|
|
button.addEventListener("click", () => openConversation(button.dataset.id));
|
|
}
|
|
}
|
|
|
|
function renderActive() {
|
|
const { conversation, messages } = state.active;
|
|
const statusSelect = $("#statusSelect");
|
|
$("#conversationHeader").innerHTML = `
|
|
<div>
|
|
<h2>${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}</h2>
|
|
<p>${flag(conversation.countryCode)} ${escapeHtml(conversation.currentUrl || "")}</p>
|
|
<p>${escapeHtml(conversation.device.userAgent || "")}</p>
|
|
</div>
|
|
`;
|
|
$("#conversationHeader").append(statusSelect);
|
|
statusSelect.classList.remove("hidden");
|
|
statusSelect.value = conversation.status;
|
|
$("#replyForm").classList.remove("hidden");
|
|
$("#messages").innerHTML = messages.map((message) => `
|
|
<article class="message ${message.senderType}">
|
|
<div class="by">${escapeHtml(message.senderName || message.senderType)} · ${escapeHtml(message.createdAt)}</div>
|
|
<p>${escapeHtml(message.body)}</p>
|
|
${message.attachments.map((a) => `<a href="${a.url}" target="_blank" rel="noreferrer">${escapeHtml(a.name)}</a>`).join("")}
|
|
</article>
|
|
`).join("");
|
|
$("#messages").scrollTop = $("#messages").scrollHeight;
|
|
}
|
|
|
|
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()));
|
|
}
|