1713 lines
68 KiB
JavaScript
1713 lines
68 KiB
JavaScript
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]) => `<option value="${code}">${code.toUpperCase()} - ${escapeHtml(label)}</option>`)
|
||
.join("");
|
||
form.defaultLanguage.innerHTML = options;
|
||
$("#defaultLanguageMenu").innerHTML = WIDGET_LANGUAGES
|
||
.map(([code, label]) => languageOptionHtml(code, label))
|
||
.join("");
|
||
form.widgetLanguageMode.value = language.mode === "fixed" ? "default" : language.mode || "auto";
|
||
form.defaultLanguage.value = language.defaultLanguage || "cs";
|
||
updateDefaultLanguagePicker();
|
||
}
|
||
|
||
function languageCountryCode(language) {
|
||
const countries = {
|
||
cs: "CZ", sk: "SK", en: "GB", de: "DE", pl: "PL", hu: "HU", ro: "RO", bg: "BG", hr: "HR", sl: "SI",
|
||
it: "IT", fr: "FR", es: "ES", pt: "PT", nl: "NL", da: "DK", sv: "SE", fi: "FI", no: "NO", et: "EE",
|
||
lv: "LV", lt: "LT", el: "GR", uk: "UA", ru: "RU", tr: "TR", sr: "RS", bs: "BA", sq: "AL", mk: "MK",
|
||
mt: "MT", ga: "IE", is: "IS", be: "BY", ca: "ES"
|
||
};
|
||
return countries[language] || "";
|
||
}
|
||
|
||
function flagImageHtml(countryCode) {
|
||
const code = String(countryCode || "").toUpperCase();
|
||
if (!/^[A-Z]{2}$/.test(code)) return `<span class="flag-image">?</span>`;
|
||
return `<span class="flag-image" style="background-image:url('https://flagcdn.com/24x18/${code.toLowerCase()}.png')" aria-hidden="true"></span>`;
|
||
}
|
||
|
||
function languageOptionHtml(language, label) {
|
||
const countryCode = languageCountryCode(language);
|
||
return `
|
||
<button class="language-option" type="button" role="option" data-language-option="${language}">
|
||
${flagImageHtml(countryCode)}
|
||
<span>${language.toUpperCase()} - ${escapeHtml(label)}</span>
|
||
</button>
|
||
`;
|
||
}
|
||
|
||
function updateDefaultLanguagePicker() {
|
||
const picker = $("#defaultLanguagePicker");
|
||
const menu = $("#defaultLanguageMenu");
|
||
const select = $("#settingsForm")?.defaultLanguage;
|
||
if (!picker || !select) return;
|
||
const language = select.value || "cs";
|
||
const item = WIDGET_LANGUAGES.find(([code]) => code === language) || WIDGET_LANGUAGES[0];
|
||
picker.innerHTML = `${flagImageHtml(languageCountryCode(language))}<span>${item[0].toUpperCase()} - ${escapeHtml(item[1])}</span>`;
|
||
if (menu) {
|
||
menu.querySelectorAll("[data-language-option]").forEach((option) => {
|
||
option.classList.toggle("active", option.dataset.languageOption === language);
|
||
});
|
||
}
|
||
}
|
||
|
||
function toggleDefaultLanguageMenu() {
|
||
const menu = $("#defaultLanguageMenu");
|
||
const picker = $("#defaultLanguagePicker");
|
||
const isOpen = menu && !menu.classList.contains("hidden");
|
||
if (isOpen) closeDefaultLanguageMenu();
|
||
else {
|
||
menu?.classList.remove("hidden");
|
||
picker?.setAttribute("aria-expanded", "true");
|
||
}
|
||
}
|
||
|
||
function closeDefaultLanguageMenu() {
|
||
$("#defaultLanguageMenu")?.classList.add("hidden");
|
||
$("#defaultLanguagePicker")?.setAttribute("aria-expanded", "false");
|
||
}
|
||
|
||
function renderWidgetCopyFields(language) {
|
||
const form = $("#settingsForm");
|
||
const settings = state.site?.settings || {};
|
||
const copy = widgetCopyForAdmin(settings, language);
|
||
form.copyTitle.value = copy.title || "";
|
||
form.copyIntro.value = copy.intro || "";
|
||
form.copyOfflineIntro.value = copy.offlineIntro || "";
|
||
form.copyPlaceholder.value = copy.placeholder || "";
|
||
form.copySendLabel.value = copy.sendLabel || "";
|
||
form.copyDropzoneLabel.value = copy.dropzoneLabel || "";
|
||
form.copyOnlineLabel.value = copy.onlineLabel || "";
|
||
form.copyNewReplyLabel.value = copy.newReplyLabel || "";
|
||
form.copyOperatorReplyLabel.value = copy.operatorReplyLabel || "";
|
||
form.copyInfoText.value = copy.infoText || "";
|
||
}
|
||
|
||
function widgetCopyForAdmin(settings, language) {
|
||
const lang = language || "cs";
|
||
const copy = settings.widgetCopy?.[lang] || {};
|
||
const legacy = {
|
||
title: settings.title?.[lang],
|
||
intro: settings.intro?.[lang],
|
||
offlineIntro: settings.offlineIntro?.[lang],
|
||
placeholder: settings.placeholder?.[lang],
|
||
sendLabel: settings.sendLabel?.[lang]
|
||
};
|
||
return normalizeWidgetCopyForAdmin({ ...defaultWidgetCopy(lang), ...legacy, ...copy }, lang);
|
||
}
|
||
|
||
function normalizeWidgetCopyForAdmin(copy, language) {
|
||
if (language !== "cs") return copy;
|
||
const defaults = defaultWidgetCopy("cs");
|
||
const legacy = legacyCzechWidgetCopy();
|
||
const normalized = { ...copy };
|
||
for (const field of Object.keys(legacy)) {
|
||
if (normalized[field] === legacy[field]) normalized[field] = defaults[field];
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function defaultWidgetCopy(language) {
|
||
if (language === "en") {
|
||
return {
|
||
title: "Need help?",
|
||
intro: "Message us and we will reply as soon as possible.",
|
||
offlineIntro: "We are offline, but you can leave us a message.",
|
||
placeholder: "Write a message...",
|
||
sendLabel: "Send",
|
||
dropzoneLabel: "Add a photo or drag it here",
|
||
onlineLabel: "We are online",
|
||
newReplyLabel: "New reply",
|
||
infoText: "We process your message and technical session data only to answer your request.",
|
||
operatorReplyLabel: "is replying"
|
||
};
|
||
}
|
||
return {
|
||
title: "Potřebujete poradit?",
|
||
intro: "Napište nám. Odpovíme co nejdříve.",
|
||
offlineIntro: "Teď nejsme online, ale zprávu nám můžete nechat.",
|
||
placeholder: "Napište zprávu...",
|
||
sendLabel: "Odeslat",
|
||
dropzoneLabel: "Přidat fotku nebo přetáhnout",
|
||
onlineLabel: "Jsme online",
|
||
newReplyLabel: "Nová odpověď",
|
||
infoText: "Zprávu a technické údaje relace zpracujeme jen pro odpověď na váš dotaz.",
|
||
operatorReplyLabel: "odpovídá"
|
||
};
|
||
}
|
||
|
||
function legacyCzechWidgetCopy() {
|
||
return {
|
||
title: "Potrebujete poradit?",
|
||
intro: "Napiste nam. Odpovime co nejdrive.",
|
||
offlineIntro: "Ted nejsme online, ale zpravu nam muzete nechat.",
|
||
placeholder: "Napiste zpravu...",
|
||
sendLabel: "Odeslat",
|
||
dropzoneLabel: "Pridat fotku nebo pretahnout",
|
||
onlineLabel: "Jsme online",
|
||
newReplyLabel: "Nova odpoved",
|
||
infoText: "Zpravu a technicke udaje relace zpracujeme jen pro odpoved na vas dotaz.",
|
||
operatorReplyLabel: "odpovida"
|
||
};
|
||
}
|
||
|
||
function syncLegacyWidgetCopy(settings) {
|
||
settings.title = settings.title || {};
|
||
settings.intro = settings.intro || {};
|
||
settings.offlineIntro = settings.offlineIntro || {};
|
||
settings.placeholder = settings.placeholder || {};
|
||
settings.sendLabel = settings.sendLabel || {};
|
||
for (const [language, copy] of Object.entries(settings.widgetCopy || {})) {
|
||
settings.title[language] = copy.title;
|
||
settings.intro[language] = copy.intro;
|
||
settings.offlineIntro[language] = copy.offlineIntro;
|
||
settings.placeholder[language] = copy.placeholder;
|
||
settings.sendLabel[language] = copy.sendLabel;
|
||
}
|
||
}
|
||
|
||
async function saveTranslationSettings() {
|
||
if (!state.site) return;
|
||
setOpenAiStatus("Ukladam AI nastaveni...");
|
||
const settings = structuredClone(state.site.settings);
|
||
settings.translations = {
|
||
...(settings.translations || {}),
|
||
enabled: $("#translationEnabled").checked,
|
||
operatorLanguage: $("#operatorLanguage").value,
|
||
model: $("#translationModel").value.trim() || "gpt-5-mini"
|
||
};
|
||
const apiKey = $("#openaiApiKey").value.trim();
|
||
if (apiKey) settings.translations.openaiApiKey = apiKey;
|
||
await saveSite({ settings, isOnline: state.site.isOnline });
|
||
$("#openaiApiKey").value = "";
|
||
setOpenAiStatus(state.site.settings.translations?.hasOpenaiApiKey
|
||
? `Klic ulozeny (${state.site.settings.translations.openaiApiKeyMasked || "sk-..."})`
|
||
: "Klic neni ulozeny. Bez nej AI preklady nepobezi.");
|
||
await loadAiModels().catch(() => {});
|
||
}
|
||
|
||
function markAiSettingsDirty() {
|
||
setOpenAiStatus("AI nastaveni ma neulozene zmeny.");
|
||
}
|
||
|
||
async function testOpenAiKey() {
|
||
await saveTranslationSettings();
|
||
setOpenAiStatus("Testuji OpenAI klic...");
|
||
const result = await api("/api/admin/ai/test", { method: "POST" }).catch((error) => error);
|
||
if (result?.ok) {
|
||
setOpenAiStatus(`Klic funguje (${result.key || "sk-..."}), model ${result.model || "AI"} umi prekladat.`);
|
||
await loadAiModels().catch(() => {});
|
||
return;
|
||
}
|
||
const message = result?.error === "missing_openai_api_key"
|
||
? "Klic neni ulozeny. Nejdřív ho vloz a klikni na Ulozit AI nastaveni."
|
||
: `Test selhal (${result?.error || "neznamá chyba"}).`;
|
||
setOpenAiStatus(openAiTestMessage(result) || message);
|
||
}
|
||
|
||
function openAiTestMessage(result) {
|
||
if (result?.error === "missing_openai_api_key") return "Klic neni ulozeny. Nejdriiv ho vloz a klikni na Ulozit AI nastaveni.";
|
||
if (result?.error === "quota_exceeded") return "OpenAI klic je ulozeny, ale chybi kredit nebo billing. Preklady zatim nepobezi.";
|
||
if (result?.error === "invalid_key") return "OpenAI klic je spatny nebo nema pristup. Vloz novy klic.";
|
||
if (result?.error === "bad_model") return "Zvoleny OpenAI model neni dostupny pro tento klic. Vyber jiny model.";
|
||
if (result?.error === "rate_limited") return "OpenAI docasne omezuje pozadavky. Zkus test za chvili.";
|
||
if (result?.message) return `Test selhal (${result?.error || "chyba"}): ${shortErrorMessage(result.message)}`;
|
||
return "";
|
||
}
|
||
|
||
function shortErrorMessage(value) {
|
||
const text = String(value || "")
|
||
.replace(/\s+/g, " ")
|
||
.replace(/^OpenAI HTTP\s+/i, "OpenAI ")
|
||
.trim();
|
||
if (!text) return "bez detailu";
|
||
return text.length > 220 ? `${text.slice(0, 217)}...` : text;
|
||
}
|
||
|
||
function setOpenAiStatus(message) {
|
||
$("#openaiApiKeyStatus").textContent = message;
|
||
}
|
||
|
||
function renderTelegramSettings(settings) {
|
||
$("#telegramEnabled").checked = Boolean(settings.enabled);
|
||
$("#telegramOperatorName").value = settings.operatorName || "Alex";
|
||
$("#telegramBotToken").value = "";
|
||
$("#telegramChatId").value = settings.chatId || "";
|
||
$("#telegramWebhookSecret").value = settings.webhookSecret || "";
|
||
$("#telegramWebhookUrl").value = settings.webhookUrl || "";
|
||
setTelegramStatus(settings.hasBotToken
|
||
? `Token ulozeny (${settings.botTokenMasked || "token..."})`
|
||
: "Token neni ulozeny. Bez nej Telegram nepobezi.");
|
||
}
|
||
|
||
function markTelegramSettingsDirty() {
|
||
setTelegramStatus("Telegram nastaveni ma neulozene zmeny.");
|
||
}
|
||
|
||
async function saveTelegramSettings() {
|
||
if (!state.site) return;
|
||
setTelegramStatus("Ukladam Telegram...");
|
||
const settings = structuredClone(state.site.settings);
|
||
settings.telegram = {
|
||
...(settings.telegram || {}),
|
||
enabled: $("#telegramEnabled").checked,
|
||
chatId: $("#telegramChatId").value.trim(),
|
||
webhookSecret: $("#telegramWebhookSecret").value.trim(),
|
||
operatorName: $("#telegramOperatorName").value.trim() || "Alex"
|
||
};
|
||
const botToken = $("#telegramBotToken").value.trim();
|
||
if (botToken) settings.telegram.botToken = botToken;
|
||
await saveSite({ settings, isOnline: state.site.isOnline });
|
||
$("#telegramBotToken").value = "";
|
||
renderTelegramSettings(state.site.settings.telegram || {});
|
||
}
|
||
|
||
async function testTelegram() {
|
||
await saveTelegramSettings();
|
||
setTelegramStatus("Posilam test Telegramu...");
|
||
const result = await api("/api/admin/telegram/test", { method: "POST" }).catch((error) => error);
|
||
if (result?.ok) {
|
||
if (result.webhookUrl) $("#telegramWebhookUrl").value = result.webhookUrl;
|
||
setTelegramStatus("Telegram funguje. Testovaci zprava odeslana a webhook nastaven.");
|
||
return;
|
||
}
|
||
setTelegramStatus(`Telegram test selhal: ${shortErrorMessage(result?.message || result?.error || "neznamá chyba")}`);
|
||
}
|
||
|
||
function setTelegramStatus(message) {
|
||
$("#telegramStatus").textContent = message;
|
||
}
|
||
|
||
async function loadAiModels() {
|
||
const current = $("#translationModel").value || state.site?.settings?.translations?.model || "gpt-5-mini";
|
||
const data = await api("/api/admin/ai/models").catch((error) => error?.models ? error : null);
|
||
state.aiModels = data?.models || fallbackAiModels();
|
||
renderModelOptions(current);
|
||
}
|
||
|
||
function renderModelOptions(current) {
|
||
const select = $("#translationModel");
|
||
const models = sortAiModels([...new Set([current, ...state.aiModels, ...fallbackAiModels()].filter(Boolean))]);
|
||
select.innerHTML = models.map((model) => `<option value="${escapeHtml(model)}">${escapeHtml(model)}</option>`).join("");
|
||
select.value = models.includes(current) ? current : models[0];
|
||
}
|
||
|
||
function sortAiModels(models) {
|
||
return models.sort((a, b) => a.localeCompare(b, "en", { numeric: true, sensitivity: "base" }));
|
||
}
|
||
|
||
function fallbackAiModels() {
|
||
return ["gpt-5-mini", "gpt-5-nano", "gpt-5.1", "gpt-4.1-mini"];
|
||
}
|
||
|
||
async function loadAdminUsers() {
|
||
const data = await api("/api/admin/users");
|
||
state.adminUsers = data.users || [];
|
||
renderAdminUsers();
|
||
}
|
||
|
||
async function createAdminUser(event) {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const data = new FormData(form);
|
||
setAdminUsersStatus("Ukladam uzivatele...");
|
||
const result = await api("/api/admin/users", {
|
||
method: "POST",
|
||
body: {
|
||
username: data.get("username"),
|
||
displayName: data.get("displayName"),
|
||
chatNick: data.get("chatNick"),
|
||
password: data.get("password")
|
||
}
|
||
}).catch((error) => error);
|
||
if (result?.users) {
|
||
state.adminUsers = result.users;
|
||
form.reset();
|
||
renderAdminUsers();
|
||
setAdminUsersStatus("Uzivatel pridan.");
|
||
return;
|
||
}
|
||
setAdminUsersStatus(adminUserError(result));
|
||
}
|
||
|
||
async function handleAdminUserClick(event) {
|
||
const resetButton = event.target.closest("[data-reset-admin-user]");
|
||
const renameButton = event.target.closest("[data-rename-admin-user]");
|
||
const nickButton = event.target.closest("[data-nick-admin-user]");
|
||
const deleteButton = event.target.closest("[data-delete-admin-user]");
|
||
if (renameButton) {
|
||
const id = renameButton.dataset.renameAdminUser;
|
||
const user = state.adminUsers.find((item) => String(item.id) === String(id));
|
||
if (!user || user.isSystem) return;
|
||
const displayName = window.prompt(`Jmeno operatora pro ${user.username}`, user.displayName || user.username);
|
||
if (displayName === null) return;
|
||
setAdminUsersStatus("Ukladam jmeno...");
|
||
const result = await api(`/api/admin/users/${id}`, {
|
||
method: "PATCH",
|
||
body: { displayName }
|
||
}).catch((error) => error);
|
||
if (result?.users) {
|
||
state.adminUsers = result.users;
|
||
renderAdminUsers();
|
||
setAdminUsersStatus("Jmeno ulozeno.");
|
||
return;
|
||
}
|
||
setAdminUsersStatus(adminUserError(result));
|
||
}
|
||
if (nickButton) {
|
||
const id = nickButton.dataset.nickAdminUser;
|
||
const user = state.adminUsers.find((item) => String(item.id) === String(id));
|
||
if (!user || user.isSystem) return;
|
||
const chatNick = window.prompt(`Nick v chatu pro ${user.username}`, user.chatNick || user.displayName || user.username);
|
||
if (chatNick === null) return;
|
||
setAdminUsersStatus("Ukladam nick...");
|
||
const result = await api(`/api/admin/users/${id}`, {
|
||
method: "PATCH",
|
||
body: { chatNick }
|
||
}).catch((error) => error);
|
||
if (result?.users) {
|
||
state.adminUsers = result.users;
|
||
renderAdminUsers();
|
||
setAdminUsersStatus("Nick ulozen.");
|
||
return;
|
||
}
|
||
setAdminUsersStatus(adminUserError(result));
|
||
}
|
||
if (resetButton) {
|
||
const id = resetButton.dataset.resetAdminUser;
|
||
const user = state.adminUsers.find((item) => String(item.id) === String(id));
|
||
if (!user || user.isSystem) return;
|
||
const password = window.prompt(`Nove heslo pro ${user.username} (min. 8 znaku)`);
|
||
if (!password) return;
|
||
setAdminUsersStatus("Menim heslo...");
|
||
const result = await api(`/api/admin/users/${id}`, {
|
||
method: "PATCH",
|
||
body: { password }
|
||
}).catch((error) => error);
|
||
if (result?.users) {
|
||
state.adminUsers = result.users;
|
||
renderAdminUsers();
|
||
setAdminUsersStatus("Heslo zmeneno.");
|
||
return;
|
||
}
|
||
setAdminUsersStatus(adminUserError(result));
|
||
}
|
||
if (deleteButton) {
|
||
const id = deleteButton.dataset.deleteAdminUser;
|
||
const user = state.adminUsers.find((item) => String(item.id) === String(id));
|
||
if (!user || user.isSystem) return;
|
||
if (!window.confirm(`Smazat BO uzivatele ${user.username}?`)) return;
|
||
setAdminUsersStatus("Mazu uzivatele...");
|
||
const result = await api(`/api/admin/users/${id}`, { method: "DELETE" }).catch((error) => error);
|
||
if (result?.users) {
|
||
state.adminUsers = result.users;
|
||
renderAdminUsers();
|
||
setAdminUsersStatus("Uzivatel smazan.");
|
||
return;
|
||
}
|
||
setAdminUsersStatus(adminUserError(result));
|
||
}
|
||
}
|
||
|
||
function renderAdminUsers() {
|
||
$("#adminUsersList").innerHTML = state.adminUsers.length ? state.adminUsers.map((user) => `
|
||
<article class="admin-user-item">
|
||
<div>
|
||
<strong>${escapeHtml(user.displayName || user.username)}</strong>
|
||
<span>${escapeHtml(user.username)} · chat: ${escapeHtml(user.chatNick || user.displayName || user.username)} · ${user.isSystem ? "systemovy .env ucet" : "BO ucet"}</span>
|
||
</div>
|
||
<div class="admin-user-actions">
|
||
<span class="password-mask" title="Heslo se nezobrazuje">${user.hasPassword ? "••••••••" : "bez hesla"}</span>
|
||
<button type="button" data-rename-admin-user="${user.id}" ${user.isSystem ? "disabled" : ""}>Jmeno</button>
|
||
<button type="button" data-nick-admin-user="${user.id}" ${user.isSystem ? "disabled" : ""}>Nick</button>
|
||
<button type="button" data-reset-admin-user="${user.id}" ${user.isSystem ? "disabled" : ""}>Reset hesla</button>
|
||
<button type="button" data-delete-admin-user="${user.id}" ${user.isSystem ? "disabled" : ""}>Smazat</button>
|
||
</div>
|
||
</article>
|
||
`).join("") : `<p class="empty">Zatim tu nejsou zadni BO uzivatele.</p>`;
|
||
}
|
||
|
||
function adminUserError(error) {
|
||
if (error?.error === "bad_username") return "Login musi mit 3-60 znaku: pismena, cisla, tecka, pomlcka, podtrzitko nebo @.";
|
||
if (error?.error === "bad_password") return "Heslo musi mit aspon 8 znaku.";
|
||
if (error?.error === "username_exists") return "Tento login uz existuje.";
|
||
if (error?.error === "system_user_password_env") return "Systemovy .env ucet se meni na serveru v .env.";
|
||
if (error?.error === "system_user_profile_env") return "Systemovy .env ucet se prejmenovava na serveru v .env.";
|
||
if (error?.error === "system_user_protected") return "Systemovy .env ucet nejde smazat.";
|
||
if (error?.error === "cannot_delete_current_user") return "Nemuzes smazat uzivatele, pod kterym jsi prihlaseny.";
|
||
return `Akce se nepodarila (${error?.error || "neznama chyba"}).`;
|
||
}
|
||
|
||
function setAdminUsersStatus(message) {
|
||
$("#adminUsersStatus").textContent = message;
|
||
}
|
||
|
||
function renderConversations() {
|
||
const conversations = filteredConversations();
|
||
$("#conversationList").innerHTML = conversations.length ? conversations.map((item) => `
|
||
<article class="conversation-item ${item.id === state.activeId ? "active" : ""} ${item.hasUnread ? "unread fresh" : ""}">
|
||
<button class="conversation-open" type="button" data-open="${item.id}">
|
||
<span class="row visitor-row">
|
||
<span class="visitor-name-wrap">
|
||
<span class="unread-dot" aria-label="${item.hasUnread ? "Neprecteno" : "Precteno"}"></span>
|
||
<strong>${escapeHtml(conversationVisitorName(item))}</strong>
|
||
${presenceDot(item.visitorPresence)}
|
||
</span>
|
||
${item.imageCount ? `<span class="photo-count">foto ${item.imageCount}</span>` : ""}
|
||
</span>
|
||
<span class="visitor-facts">
|
||
<span title="Zeme">${visitorCountry(item)}</span>
|
||
<span title="Doba na webu">${formatDuration(item.sessionSeconds)}</span>
|
||
<span title="Navstivene stranky">${Number(item.pageCount || 0)} str.</span>
|
||
<span title="Pocet chatu">${Number(item.visitorConversationCount || 1)}x chat</span>
|
||
</span>
|
||
</button>
|
||
<div class="conversation-actions">
|
||
${statusButtons(item)}
|
||
<button class="delete-chat" type="button" data-delete="${item.id}" aria-label="Smazat chat" title="Smazat chat">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-2 6h10l-.7 11H7.7L7 9Zm3 2 .2 7h1.6l-.2-7H10Zm4 0-.2 7h1.6l.2-7H14Z"></path></svg>
|
||
</button>
|
||
</div>
|
||
</article>
|
||
`).join("") : `<p class="empty-list">Zadny chat pro vybrany filtr.</p>`;
|
||
}
|
||
|
||
function syncConversationVisitors() {
|
||
if (!state.visitorsLoaded) return;
|
||
const visitorsById = new Map(state.visitors.map((visitor) => [visitor.id, visitor]));
|
||
state.conversations = state.conversations.map((item) => ({
|
||
...item,
|
||
visitorOnline: visitorsById.has(item.visitorToken),
|
||
visitorPresence: visitorsById.get(item.visitorToken)?.presence || "offline"
|
||
}));
|
||
}
|
||
|
||
function conversationVisitorName(item) {
|
||
return item.visitorAlias || item.visitor?.name || item.visitor?.email || item.visitorLabel || "Navstevnik";
|
||
}
|
||
|
||
function renderVisitors() {
|
||
const visitors = state.hideBots ? state.visitors.filter((visitor) => !visitor.isBot) : state.visitors;
|
||
const botCount = state.visitors.filter((visitor) => visitor.isBot).length;
|
||
$("#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) => `
|
||
<article class="visitor-card ${visitor.lastConversationId ? "clickable" : ""} ${visitor.lastConversationId === state.activeId ? "active" : ""} ${visitor.isBot ? "bot" : ""}" data-visitor-conversation="${escapeHtml(visitor.lastConversationId || "")}">
|
||
<div class="visitor-card-head">
|
||
<span class="visitor-avatar">${escapeHtml(visitor.initials || "??")}</span>
|
||
<div>
|
||
<strong>${escapeHtml(visitor.label || "Navstevnik")}</strong>
|
||
<small>${escapeHtml(visitor.isBot ? `bot: ${visitor.botReason || "crawler"}` : browserLabel(visitor.device))}</small>
|
||
</div>
|
||
${presenceDot(visitor.presence)}
|
||
</div>
|
||
<div class="visitor-compact-meta">
|
||
<span>${visitorCountry(visitor)}</span>
|
||
<span>${formatDuration(visitor.sessionSeconds)}</span>
|
||
<span>${Number(visitor.visitCount || visitor.pageCount || 1)} navst.</span>
|
||
<span>${Number(visitor.conversationCount || 0)} chaty</span>
|
||
</div>
|
||
<a class="visitor-page" href="${escapeHtml(visitor.currentUrl || "#")}" target="_blank" rel="noreferrer">${escapeHtml(pageTitle(visitor))}</a>
|
||
<div class="visitor-referrer">${visitor.referrer ? escapeHtml(referrerLabel(visitor.referrer)) : "primy vstup"}${visitor.lastConversationId ? "" : " · bez chatu"}</div>
|
||
</article>
|
||
`).join("") : `<p class="empty-list">Zatim nikdo online.</p>`;
|
||
}
|
||
|
||
function presenceDot(presence) {
|
||
const value = ["active", "idle", "offline"].includes(presence) ? presence : "offline";
|
||
const label = value === "active" ? "Aktivni" : value === "idle" ? "Neaktivni" : "Offline";
|
||
return `<span class="presence-dot ${value}" aria-label="${label}" title="${label}"></span>`;
|
||
}
|
||
|
||
function pageTitle(visitor) {
|
||
const history = Array.isArray(visitor.browsingHistory) ? visitor.browsingHistory : [];
|
||
const current = history.find((item) => item.url === visitor.currentUrl) || history[history.length - 1];
|
||
return current?.title || shortUrl(visitor.currentUrl) || "neznamá stranka";
|
||
}
|
||
|
||
function referrerLabel(value) {
|
||
try {
|
||
return new URL(value).hostname.replace(/^www\./, "");
|
||
} catch {
|
||
return shortUrl(value) || value;
|
||
}
|
||
}
|
||
|
||
function browserLabel(device = {}) {
|
||
const ua = String(device.userAgent || "");
|
||
const browser = ua.includes("Edg/") ? "Edge"
|
||
: ua.includes("Chrome/") ? "Chrome"
|
||
: ua.includes("Firefox/") ? "Firefox"
|
||
: ua.includes("Safari/") ? "Safari"
|
||
: "Browser";
|
||
return [device.platform || "zarizeni", browser].filter(Boolean).join(" - ");
|
||
}
|
||
|
||
function renderActive() {
|
||
const { conversation, messages } = state.active;
|
||
$("#conversationHeader").innerHTML = `
|
||
<div class="visitor-head">
|
||
<div class="visitor-summary-row">
|
||
<button class="visitor-name-edit" type="button" data-rename-visitor="${escapeHtml(conversation.visitorToken)}" title="Prejmenovat navstevnika">
|
||
<span>${escapeHtml(conversationVisitorName(conversation))}</span>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 17.5V20h2.5L17.8 8.7l-2.5-2.5L4 17.5Zm15.7-11.1a1 1 0 0 0 0-1.4L18.3 3.6a1 1 0 0 0-1.4 0l-1 1 2.5 2.5 1.3-.7Z"></path></svg>
|
||
</button>
|
||
${presenceDot(conversation.visitorPresence)}
|
||
<span class="country-pill" title="Zeme">${visitorCountry(conversation)}</span>
|
||
<span title="Doba na webu">${formatDuration(conversation.sessionSeconds)}</span>
|
||
<span title="Navstivene stranky">${Number(conversation.pageCount || 0)} str.</span>
|
||
<span title="Pocet chatu">${Number(conversation.visitorConversationCount || 1)}x chat</span>
|
||
${visitorDetails(conversation)}
|
||
<span id="operatorPresence" class="operator-presence hidden"></span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
$("#replyForm").classList.remove("hidden");
|
||
$("#messages").innerHTML = messages.map((message) => `
|
||
<article class="message ${message.senderType}">
|
||
<div class="by">${escapeHtml(message.senderName || message.senderType)} · ${escapeHtml(message.createdAt)}</div>
|
||
${renderMessageBody(message)}
|
||
${renderAttachments(message.attachments)}
|
||
</article>
|
||
`).join("") + adminReadReceipt(conversation, messages);
|
||
decorateTranslationMeta(messages);
|
||
renderOperatorPresence();
|
||
$("#messages").scrollTop = $("#messages").scrollHeight;
|
||
}
|
||
|
||
function renderOperatorPresence() {
|
||
const boxes = [$("#operatorPresence"), $("#replyOperatorPresence")].filter(Boolean);
|
||
if (!boxes.length || !state.activeId) return;
|
||
const operators = (state.operatorPresence[state.activeId] || [])
|
||
.filter((operator) => operator.username !== state.me?.username);
|
||
const typing = state.operatorTyping[state.activeId];
|
||
const parts = [];
|
||
if (operators.length) {
|
||
parts.push(`V chatu: ${operators.map((operator) => operator.name).join(", ")}`);
|
||
}
|
||
if (typing && typing.username !== state.me?.username) {
|
||
parts.push(`${typing.name} pise...`);
|
||
}
|
||
const text = parts.join(" · ");
|
||
const isTyping = Boolean(typing && typing.username !== state.me?.username);
|
||
for (const box of boxes) {
|
||
box.textContent = text;
|
||
box.classList.toggle("hidden", !parts.length);
|
||
box.classList.toggle("typing", isTyping);
|
||
}
|
||
}
|
||
|
||
function flashOperatorTyping(data) {
|
||
if (data.conversationId !== state.activeId || data.username === state.me?.username) return;
|
||
state.operatorTyping[data.conversationId] = {
|
||
username: data.username,
|
||
name: data.name || "Operator"
|
||
};
|
||
renderOperatorPresence();
|
||
clearTimeout(flashOperatorTyping.timer);
|
||
flashOperatorTyping.timer = setTimeout(() => {
|
||
delete state.operatorTyping[data.conversationId];
|
||
renderOperatorPresence();
|
||
}, 2200);
|
||
}
|
||
|
||
function decorateTranslationMeta(messages) {
|
||
document.querySelectorAll("#messages .message .by").forEach((element, index) => {
|
||
const meta = translationMeta(messages[index]);
|
||
if (!meta) return;
|
||
const badge = document.createElement("span");
|
||
badge.className = `translation-meta ${meta.status}`;
|
||
badge.textContent = meta.label;
|
||
if (meta.error) badge.title = meta.error;
|
||
element.append(" ");
|
||
element.append(badge);
|
||
});
|
||
}
|
||
|
||
function translationMeta(message) {
|
||
if (!message?.translationStatus) return null;
|
||
const status = String(message.translationStatus || "");
|
||
const source = String(message.originalLanguage || "?").toUpperCase();
|
||
const target = String(message.translatedLanguage || "?").toUpperCase();
|
||
return {
|
||
status,
|
||
error: message.translationError || "",
|
||
label: translationStatusLabel(status, source, target)
|
||
};
|
||
}
|
||
|
||
function translationStatusLabel(status, source, target) {
|
||
if (status === "translated") return `preklad: ${source} -> ${target}`;
|
||
if (status === "same_language") return `preklad: ${source} = ${target}`;
|
||
if (status === "failed") return `preklad: ${source} -> ${target} - chyba`;
|
||
if (status === "quota_exceeded") return "preklad: neni kredit";
|
||
if (status === "invalid_key") return "preklad: spatny klic";
|
||
if (status === "bad_model") return "preklad: spatny model";
|
||
if (status === "rate_limited") return "preklad: limit API";
|
||
if (status === "missing_key") return "preklad: chybi klic";
|
||
if (status === "disabled") return "preklad: vypnuto";
|
||
if (status === "empty") return `preklad: ${source} -> ${target} - prazdny`;
|
||
if (status === "skipped") return "preklad: preskoceno";
|
||
if (status === "pending") return "preklad: ceka";
|
||
return `preklad: ${status}`;
|
||
}
|
||
|
||
function renderMessageBody(message) {
|
||
const original = message.body ? `<p>${escapeHtml(message.body)}</p>` : "";
|
||
if (!message.translatedBody) return original;
|
||
const label = message.senderType === "visitor"
|
||
? `Preklad do ${escapeHtml(message.translatedLanguage || "")}`
|
||
: `Odeslano zakaznikovi ${escapeHtml(message.translatedLanguage || "")}`;
|
||
return `
|
||
${original}
|
||
<div class="translation">
|
||
<span>${label}</span>
|
||
<p>${escapeHtml(message.translatedBody)}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function renameVisitor(visitorToken) {
|
||
if (!state.active?.conversation || !visitorToken) return;
|
||
const currentName = conversationVisitorName(state.active.conversation);
|
||
const nextName = window.prompt("Prejmenovat navstevnika", state.active.conversation.visitorAlias || currentName);
|
||
if (nextName === null) return;
|
||
const result = await api(`/api/admin/visitors/${encodeURIComponent(visitorToken)}`, {
|
||
method: "PATCH",
|
||
body: { displayName: nextName }
|
||
});
|
||
applyVisitorRename(visitorToken, result.label, result.displayName);
|
||
}
|
||
|
||
function applyVisitorRename(visitorToken, label, alias) {
|
||
const update = (item) => item.visitorToken === visitorToken
|
||
? { ...item, visitorLabel: label, visitorAlias: alias || "" }
|
||
: item;
|
||
state.conversations = state.conversations.map(update);
|
||
state.visitors = state.visitors.map((visitor) => visitor.id === visitorToken
|
||
? { ...visitor, label, visitorAlias: alias || "" }
|
||
: visitor);
|
||
if (state.active?.conversation?.visitorToken === visitorToken) {
|
||
state.active.conversation = update(state.active.conversation);
|
||
}
|
||
renderConversations();
|
||
renderVisitors();
|
||
if (state.active) renderActive();
|
||
}
|
||
|
||
function adminReadReceipt(conversation, messages) {
|
||
const lastAdminMessage = [...messages].reverse().find((message) => message.senderType === "admin");
|
||
if (!lastAdminMessage) return "";
|
||
const seenAt = conversation.visitorSeenAt;
|
||
const isSeen = Boolean(seenAt && String(seenAt) >= String(lastAdminMessage.createdAt));
|
||
const text = isSeen ? `Precteno zakaznikem ${formatTime(seenAt)}` : "Zatim neprecteno";
|
||
return `<div class="read-receipt ${isSeen ? "seen" : "unseen"}">${escapeHtml(text)}</div>`;
|
||
}
|
||
|
||
function filteredConversations() {
|
||
if (state.statusFilter === "all") return state.conversations;
|
||
if (state.statusFilter === "active") return state.conversations.filter((item) => item.status === "new" || item.status === "open");
|
||
return state.conversations.filter((item) => item.status === state.statusFilter);
|
||
}
|
||
|
||
function statusButtons(item) {
|
||
return [
|
||
["new", "nove"],
|
||
["open", "otevrene"],
|
||
["resolved", "vyresene"]
|
||
].map(([value, label]) => `
|
||
<button class="status-button ${value} ${item.status === value ? "active" : ""}" type="button" data-status-button="${item.id}" data-status="${value}">
|
||
${label}
|
||
</button>
|
||
`).join("");
|
||
}
|
||
|
||
function visitorCountry(item) {
|
||
const countryCode = item.countryCode || countryFromLanguage(item.language) || countryFromTimezone(item.timezone);
|
||
const code = String(countryCode || "").toUpperCase();
|
||
if (!/^[A-Z]{2}$/.test(code)) return `<span class="country-badge unknown"><span class="flag-image">?</span><strong>?</strong></span>`;
|
||
return `
|
||
<span class="country-badge">
|
||
<span class="flag-image" style="background-image:url('https://flagcdn.com/24x18/${code.toLowerCase()}.png')" aria-hidden="true"></span>
|
||
<strong>${escapeHtml(code)}</strong>
|
||
</span>
|
||
`;
|
||
}
|
||
|
||
function visitorDetails(conversation) {
|
||
const history = Array.isArray(conversation.browsingHistory) ? conversation.browsingHistory : [];
|
||
const historyItems = history.length ? history.slice().reverse().map((item) => `
|
||
<li>
|
||
<a href="${escapeHtml(item.url || "#")}" target="_blank" rel="noreferrer">${escapeHtml(pageLabel(item))}</a>
|
||
<small>${escapeHtml(formatVisitedAt(item.at))}</small>
|
||
</li>
|
||
`).join("") : `<li><span>Zatim bez historie.</span></li>`;
|
||
return `
|
||
<details class="visitor-details">
|
||
<summary title="Detail navstevnika">
|
||
<span aria-hidden="true">i</span>
|
||
</summary>
|
||
<div class="visitor-detail-popover">
|
||
<div class="visitor-detail-grid">
|
||
<span>Aktualne</span>
|
||
<a href="${escapeHtml(conversation.currentUrl || "#")}" target="_blank" rel="noreferrer">${escapeHtml(shortUrl(conversation.currentUrl) || "-")}</a>
|
||
<span>Prisiel z</span>
|
||
${conversation.referrer ? `<a href="${escapeHtml(conversation.referrer)}" target="_blank" rel="noreferrer">${escapeHtml(shortUrl(conversation.referrer))}</a>` : `<em>primy vstup / nezname</em>`}
|
||
<span>Zarizeni</span>
|
||
<em>${escapeHtml(deviceLabel(conversation.device, conversation.language, conversation.timezone))}</em>
|
||
</div>
|
||
<ol class="history-list">${historyItems}</ol>
|
||
</div>
|
||
</details>
|
||
`;
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
const value = Number(seconds || 0);
|
||
if (value < 60) return `${Math.max(0, value)}s`;
|
||
const minutes = Math.floor(value / 60);
|
||
if (minutes < 60) return `${minutes}m`;
|
||
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||
}
|
||
|
||
async function updateConversationStatus(id, status) {
|
||
await api(`/api/admin/conversations/${id}/status`, {
|
||
method: "PATCH",
|
||
body: { status }
|
||
});
|
||
const item = state.conversations.find((conversation) => conversation.id === id);
|
||
if (item) item.status = status;
|
||
if (state.active?.conversation?.id === id) state.active.conversation.status = status;
|
||
renderConversations();
|
||
}
|
||
|
||
async function deleteConversation(id) {
|
||
const item = state.conversations.find((conversation) => conversation.id === id);
|
||
const label = item?.visitor?.name || item?.lastBody || "tento chat";
|
||
if (!confirm(`Smazat ${label}? Tahle akce nejde vratit.`)) return;
|
||
await api(`/api/admin/conversations/${id}`, { method: "DELETE" });
|
||
state.conversations = state.conversations.filter((conversation) => conversation.id !== id);
|
||
if (state.activeId === id) clearActiveConversation();
|
||
renderConversations();
|
||
}
|
||
|
||
function clearActiveConversation() {
|
||
stopOperatorPresence();
|
||
state.activeId = null;
|
||
state.active = null;
|
||
$("#conversationHeader").innerHTML = `
|
||
<div>
|
||
<h2>Vyber konverzaci</h2>
|
||
</div>
|
||
`;
|
||
$("#messages").innerHTML = "";
|
||
$("#replyForm").classList.add("hidden");
|
||
syncAttentionWithUnread();
|
||
}
|
||
|
||
function renderAttachments(attachments) {
|
||
if (!attachments.length) return "";
|
||
return `<div class="attachments">${attachments.map((attachment) => {
|
||
if (!attachment.type?.startsWith("image/")) {
|
||
return `<a href="${escapeHtml(attachment.url)}" target="_blank" rel="noreferrer">${escapeHtml(attachment.name)}</a>`;
|
||
}
|
||
return `
|
||
<figure class="photo-preview">
|
||
<button class="photo-open" type="button" data-photo-url="${escapeHtml(attachment.url)}" data-photo-name="${escapeHtml(attachment.name)}">
|
||
<img src="${escapeHtml(attachment.url)}" alt="${escapeHtml(attachment.name)}">
|
||
</button>
|
||
<figcaption>
|
||
<span>${escapeHtml(attachment.name)}</span>
|
||
<button class="photo-delete" type="button" data-delete-attachment="${attachment.id}" aria-label="Smazat fotku" title="Smazat fotku">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-2 6h10l-.7 11H7.7L7 9Zm3 2 .2 7h1.6l-.2-7H10Zm4 0-.2 7h1.6l.2-7H14Z"></path></svg>
|
||
</button>
|
||
</figcaption>
|
||
</figure>
|
||
`;
|
||
}).join("")}</div>`;
|
||
}
|
||
|
||
async function deleteAttachment(id) {
|
||
if (!confirm("Smazat tuhle fotku?")) return;
|
||
await api(`/api/admin/attachments/${id}`, { method: "DELETE" });
|
||
if (state.activeId) await openConversation(state.activeId);
|
||
await loadConversations();
|
||
}
|
||
|
||
function renderReplyAttachments() {
|
||
$("#replyAttachments").innerHTML = state.replyFiles.map((file, index) => `
|
||
<span class="reply-chip">
|
||
<span>${escapeHtml(file.name)}</span>
|
||
<button type="button" data-remove-reply="${index}" aria-label="Odebrat fotku">×</button>
|
||
</span>
|
||
`).join("");
|
||
}
|
||
|
||
function addReplyFiles(files) {
|
||
state.replyFiles = mergeImageFiles(state.replyFiles, files);
|
||
renderReplyAttachments();
|
||
}
|
||
|
||
function clearReplyComposer(form = $("#replyForm")) {
|
||
const textarea = form?.querySelector("textarea[name='message']");
|
||
if (!textarea) return;
|
||
textarea.value = "";
|
||
textarea.defaultValue = "";
|
||
textarea.textContent = "";
|
||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
}
|
||
|
||
function mergeImageFiles(currentFiles, incomingFiles) {
|
||
const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
||
const nextFiles = [...currentFiles];
|
||
for (const file of incomingFiles) {
|
||
if (!allowedTypes.has(file.type) || file.size <= 0 || file.size > 3_000_000) continue;
|
||
const exists = nextFiles.some((item) => item.name === file.name && item.size === file.size && item.lastModified === file.lastModified);
|
||
if (!exists) nextFiles.push(file);
|
||
if (nextFiles.length === 3) break;
|
||
}
|
||
return nextFiles;
|
||
}
|
||
|
||
function fileToPayload(file) {
|
||
return new Promise((resolve) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve({ name: file.name, type: file.type, size: file.size, dataBase64: reader.result });
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function countryFromLanguage(language) {
|
||
const match = String(language || "").match(/-([A-Za-z]{2})\b/);
|
||
return match ? match[1].toUpperCase() : null;
|
||
}
|
||
|
||
function countryFromTimezone(timezone) {
|
||
const zones = {
|
||
"Europe/Prague": "CZ",
|
||
"Europe/Bratislava": "SK",
|
||
"Europe/Warsaw": "PL",
|
||
"Europe/Berlin": "DE",
|
||
"Europe/Vienna": "AT",
|
||
"Europe/Budapest": "HU"
|
||
};
|
||
return zones[String(timezone || "")] || null;
|
||
}
|
||
|
||
function shortUrl(value) {
|
||
if (!value) return "";
|
||
try {
|
||
const url = new URL(value);
|
||
const path = `${url.pathname}${url.search}`.replace(/\/$/, "");
|
||
return `${url.hostname}${path || "/"}`;
|
||
} catch {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
function pageLabel(item) {
|
||
return item.title || shortUrl(item.url) || item.url || "stranka";
|
||
}
|
||
|
||
function formatVisitedAt(value) {
|
||
if (!value) return "";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
return date.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
|
||
function formatTime(value) {
|
||
const text = String(value || "");
|
||
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text) ? `${text.replace(" ", "T")}Z` : text;
|
||
const date = new Date(normalized);
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
return date.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
|
||
function deviceLabel(device = {}, language, timezone) {
|
||
return [device.platform, device.viewport, language, timezone].filter(Boolean).join(" | ");
|
||
}
|
||
|
||
function openPhotoPreview(url, name) {
|
||
let modal = $("#photoModal");
|
||
if (!modal) {
|
||
modal = document.createElement("div");
|
||
modal.id = "photoModal";
|
||
modal.className = "photo-modal hidden";
|
||
modal.innerHTML = `
|
||
<button class="photo-backdrop" type="button" aria-label="Zavrit nahled"></button>
|
||
<figure>
|
||
<button class="photo-close" type="button" aria-label="Zavrit nahled">×</button>
|
||
<img alt="">
|
||
<figcaption></figcaption>
|
||
</figure>
|
||
`;
|
||
document.body.append(modal);
|
||
modal.addEventListener("click", (event) => {
|
||
if (event.target.closest(".photo-close") || event.target.classList.contains("photo-backdrop")) {
|
||
modal.classList.add("hidden");
|
||
}
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") modal.classList.add("hidden");
|
||
});
|
||
}
|
||
modal.querySelector("img").src = url;
|
||
modal.querySelector("img").alt = name || "Fotka";
|
||
modal.querySelector("figcaption").textContent = name || "";
|
||
modal.classList.remove("hidden");
|
||
}
|
||
|
||
async function saveSite(patch) {
|
||
const data = await api("/api/admin/site", { method: "PATCH", body: patch });
|
||
state.site = data.site;
|
||
state.snippet = data.snippet;
|
||
renderSite();
|
||
return data;
|
||
}
|
||
|
||
function setSettingsSaveStatus(type, text) {
|
||
const status = $("#settingsSaveStatus");
|
||
if (!status) return;
|
||
status.className = `settings-save-status ${type || ""}`.trim();
|
||
status.textContent = text || "";
|
||
}
|
||
|
||
function notify() {
|
||
startAttention();
|
||
playNotifySound();
|
||
startRepeatingSound();
|
||
}
|
||
|
||
function renderAdminSoundControls() {
|
||
const toggle = $("#adminSoundToggle");
|
||
const volume = $("#adminSoundVolume");
|
||
const settingsToggle = $("#adminSoundToggleSettings");
|
||
const settingsVolume = $("#adminSoundVolumeSettings");
|
||
const settingsStyle = $("#adminSoundStyle");
|
||
const settingsRepeat = $("#adminSoundRepeat");
|
||
const safeVolume = clamp(state.soundVolume, 0, 100);
|
||
if (toggle) {
|
||
toggle.classList.toggle("muted", state.soundMuted || safeVolume <= 0);
|
||
toggle.setAttribute("aria-label", state.soundMuted ? "Zapnout zvuk notifikaci" : "Vypnout zvuk notifikaci");
|
||
toggle.title = state.soundMuted ? "Zapnout zvuk notifikaci" : "Vypnout zvuk notifikaci";
|
||
}
|
||
if (volume) {
|
||
volume.value = String(safeVolume);
|
||
volume.title = `Hlasitost ${safeVolume}%`;
|
||
}
|
||
if (settingsToggle) settingsToggle.checked = !state.soundMuted;
|
||
if (settingsVolume) settingsVolume.value = String(safeVolume);
|
||
if (settingsStyle) settingsStyle.value = state.soundStyle;
|
||
if (settingsRepeat) settingsRepeat.checked = state.soundRepeat;
|
||
}
|
||
|
||
function setAdminSoundMuted(isMuted) {
|
||
state.soundMuted = Boolean(isMuted);
|
||
if (!state.soundMuted && state.soundVolume <= 0) state.soundVolume = 70;
|
||
localStorage.setItem("mf_admin_sound_muted", state.soundMuted ? "1" : "0");
|
||
localStorage.setItem("mf_admin_sound_volume", String(state.soundVolume));
|
||
if (state.soundMuted) stopRepeatingSound();
|
||
renderAdminSoundControls();
|
||
}
|
||
|
||
function setAdminSoundVolume(value) {
|
||
state.soundVolume = clamp(value, 0, 100);
|
||
state.soundMuted = state.soundVolume <= 0;
|
||
localStorage.setItem("mf_admin_sound_volume", String(state.soundVolume));
|
||
localStorage.setItem("mf_admin_sound_muted", state.soundMuted ? "1" : "0");
|
||
if (state.soundMuted) stopRepeatingSound();
|
||
renderAdminSoundControls();
|
||
}
|
||
|
||
function playNotifySound(options = {}) {
|
||
const volume = clamp(state.soundVolume, 0, 100);
|
||
if (!options.ignoreMuted && (state.soundMuted || volume <= 0)) return;
|
||
const AudioCtor = window.AudioContext || window.webkitAudioContext;
|
||
if (!AudioCtor) return;
|
||
const audio = new AudioCtor();
|
||
const gain = audio.createGain();
|
||
gain.gain.value = 0.12 * ((volume || 70) / 100);
|
||
gain.connect(audio.destination);
|
||
for (const tone of soundPattern(state.soundStyle)) {
|
||
const osc = audio.createOscillator();
|
||
osc.type = tone.type;
|
||
osc.frequency.value = tone.frequency;
|
||
osc.connect(gain);
|
||
osc.start(audio.currentTime + tone.offset);
|
||
osc.stop(audio.currentTime + tone.offset + tone.duration);
|
||
}
|
||
setTimeout(() => audio.close().catch(() => {}), 720);
|
||
}
|
||
|
||
function startRepeatingSound() {
|
||
if (!state.soundRepeat || state.soundRepeatTimer || state.soundMuted || clamp(state.soundVolume, 0, 100) <= 0) return;
|
||
state.soundRepeatTimer = setInterval(() => {
|
||
playNotifySound();
|
||
}, 5500);
|
||
}
|
||
|
||
function stopRepeatingSound() {
|
||
if (state.soundRepeatTimer) clearInterval(state.soundRepeatTimer);
|
||
state.soundRepeatTimer = null;
|
||
}
|
||
|
||
function hasUnreadConversations() {
|
||
return state.conversations.some((conversation) => conversation.hasUnread && conversation.id !== state.activeId);
|
||
}
|
||
|
||
function syncAttentionWithUnread() {
|
||
if (hasUnreadConversations()) {
|
||
startAttention();
|
||
startRepeatingSound();
|
||
return;
|
||
}
|
||
stopAttention();
|
||
}
|
||
|
||
function soundPattern(style) {
|
||
const patterns = {
|
||
tap: [
|
||
{ offset: 0, duration: 0.08, frequency: 720, type: "sine" }
|
||
],
|
||
double: [
|
||
{ offset: 0, duration: 0.09, frequency: 760, type: "sine" },
|
||
{ offset: 0.12, duration: 0.09, frequency: 760, type: "sine" },
|
||
{ offset: 0.28, duration: 0.1, frequency: 980, type: "triangle" }
|
||
],
|
||
soft: [
|
||
{ offset: 0, duration: 0.13, frequency: 520, type: "sine" },
|
||
{ offset: 0.16, duration: 0.12, frequency: 680, type: "sine" }
|
||
],
|
||
chime: [
|
||
{ offset: 0, duration: 0.16, frequency: 880, type: "triangle" },
|
||
{ offset: 0.18, duration: 0.18, frequency: 1320, type: "triangle" }
|
||
],
|
||
urgent: [
|
||
{ offset: 0, duration: 0.1, frequency: 760, type: "square" },
|
||
{ offset: 0.13, duration: 0.1, frequency: 760, type: "square" },
|
||
{ offset: 0.26, duration: 0.12, frequency: 980, type: "square" }
|
||
],
|
||
bright: [
|
||
{ offset: 0, duration: 0.11, frequency: 820, type: "sine" },
|
||
{ offset: 0.14, duration: 0.11, frequency: 1040, type: "sine" }
|
||
]
|
||
};
|
||
return patterns[style] || patterns.bright;
|
||
}
|
||
|
||
function clamp(value, min, max) {
|
||
if (!Number.isFinite(value)) return min;
|
||
return Math.min(max, Math.max(min, value));
|
||
}
|
||
|
||
function startAttention() {
|
||
if (state.attentionTimer) return;
|
||
const favicon = document.querySelector("link[rel='icon']");
|
||
const alertIcon = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0f8f6f"/><circle cx="46" cy="18" r="11" fill="#ff3b30"/><path fill="#fff" d="M18 24h22v6H18zm0 11h16v6H18z"/></svg>')}`;
|
||
state.attentionTimer = setInterval(() => {
|
||
state.attentionOn = !state.attentionOn;
|
||
document.title = state.attentionOn ? "* Nova zprava - MaalFlows" : state.originalTitle;
|
||
if (favicon) favicon.href = state.attentionOn ? alertIcon : state.originalFavicon;
|
||
}, 760);
|
||
}
|
||
|
||
function stopAttention() {
|
||
if (state.attentionTimer) clearInterval(state.attentionTimer);
|
||
state.attentionTimer = null;
|
||
stopRepeatingSound();
|
||
state.attentionOn = false;
|
||
document.title = state.originalTitle;
|
||
const favicon = document.querySelector("link[rel='icon']");
|
||
if (favicon) favicon.href = state.originalFavicon;
|
||
}
|
||
|
||
function isVisitorMessageEvent(data) {
|
||
if (data.type !== "message:new") return false;
|
||
const messages = data.payload?.messages || [];
|
||
return messages[messages.length - 1]?.senderType === "visitor";
|
||
}
|
||
|
||
function eventConversationIdFrom(data) {
|
||
return data.conversationId || data.conversation?.id || data.payload?.conversation?.id || null;
|
||
}
|
||
|
||
function flashTyping() {
|
||
$("#typingNotice").classList.remove("hidden");
|
||
clearTimeout(flashTyping.timer);
|
||
flashTyping.timer = setTimeout(() => $("#typingNotice").classList.add("hidden"), 1800);
|
||
}
|
||
|
||
async function api(url, options = {}) {
|
||
const response = await fetch(url, {
|
||
method: options.method || "GET",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: options.body ? JSON.stringify(options.body) : undefined
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
const error = new Error(`HTTP ${response.status}`);
|
||
Object.assign(error, data);
|
||
throw error;
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function debounce(fn, delay) {
|
||
let timer;
|
||
return (...args) => {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(() => fn(...args), delay);
|
||
};
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'"
|
||
})[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()));
|
||
}
|