diff --git a/public/admin.css b/public/admin.css index a80738b..51149cf 100644 --- a/public/admin.css +++ b/public/admin.css @@ -1328,6 +1328,16 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } padding: 0; accent-color: var(--brand); } +.seconds-field { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 7px; + align-items: center; +} +.seconds-field span { + color: var(--muted); + font-size: 12px; +} pre { white-space: pre-wrap; overflow: auto; diff --git a/public/admin.html b/public/admin.html index 74f9edd..4851305 100644 --- a/public/admin.html +++ b/public/admin.html @@ -181,6 +181,20 @@
+
+ Signalizace navstevniku +

Zelena znamena aktivni. Po necinosti se prepne na oranzovou a po dalsim limitu na sedou.

+
+ + +
+ Oranzova je cas mezi zelenou a sedou. + +
BO zvuk diff --git a/public/admin.js b/public/admin.js index ee7c079..80a8904 100644 --- a/public/admin.js +++ b/public/admin.js @@ -71,6 +71,9 @@ $("#logoutButton").addEventListener("click", async () => { $("#settingsButton").addEventListener("click", () => $("#settingsPanel").classList.toggle("hidden")); $("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden")); +$("#presenceActiveSeconds").addEventListener("input", markPresenceSettingsDirty); +$("#presenceOfflineSeconds").addEventListener("input", markPresenceSettingsDirty); +$("#savePresenceSettings").addEventListener("click", savePresenceSettings); $("#adminSoundToggle").addEventListener("click", () => { setAdminSoundMuted(!state.soundMuted); }); @@ -522,6 +525,7 @@ function renderSite() { ? `Klic ulozeny (${s.translations.openaiApiKeyMasked || "sk-..."})` : "Klic neni ulozeny. Bez nej AI preklady nepobezi."; renderModelOptions(s.translations?.model || "gpt-5-mini"); + renderPresenceSettings(s.adminPresence || {}); renderTelegramSettings(s.telegram || {}); } @@ -757,6 +761,52 @@ function setOpenAiStatus(message) { $("#openaiApiKeyStatus").textContent = message; } +function renderPresenceSettings(settings) { + const normalized = normalizePresenceSettings(settings); + $("#presenceActiveSeconds").value = normalized.activeSeconds; + $("#presenceOfflineSeconds").value = normalized.offlineSeconds; + setPresenceStatus(`Oranzova: ${normalized.activeSeconds}-${normalized.offlineSeconds} s necinosti.`); +} + +function markPresenceSettingsDirty() { + const normalized = normalizePresenceSettings({ + activeSeconds: $("#presenceActiveSeconds").value, + offlineSeconds: $("#presenceOfflineSeconds").value + }); + setPresenceStatus(`Neulozeno. Oranzova bude ${normalized.activeSeconds}-${normalized.offlineSeconds} s.`); +} + +async function savePresenceSettings() { + if (!state.site) return; + const normalized = normalizePresenceSettings({ + activeSeconds: $("#presenceActiveSeconds").value, + offlineSeconds: $("#presenceOfflineSeconds").value + }); + setPresenceStatus("Ukladam signalizaci..."); + const settings = structuredClone(state.site.settings); + settings.adminPresence = normalized; + await saveSite({ settings, isOnline: state.site.isOnline }); + renderPresenceSettings(state.site.settings.adminPresence || normalized); + await Promise.all([loadConversations(), loadVisitors()]); + setPresenceStatus(`Ulozeno. Oranzova: ${normalized.activeSeconds}-${normalized.offlineSeconds} s.`); +} + +function normalizePresenceSettings(value = {}) { + const activeSeconds = clampNumber(value.activeSeconds, 10, 3600, 30); + const offlineSeconds = Math.max(clampNumber(value.offlineSeconds, 20, 7200, 120), activeSeconds + 10); + return { activeSeconds, offlineSeconds }; +} + +function clampNumber(value, min, max, fallback) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.min(max, Math.max(min, Math.round(number))); +} + +function setPresenceStatus(message) { + $("#presenceStatus").textContent = message; +} + function renderTelegramSettings(settings) { $("#telegramEnabled").checked = Boolean(settings.enabled); $("#telegramOperatorName").value = settings.operatorName || "Alex"; diff --git a/server.js b/server.js index 45844fd..091fea2 100644 --- a/server.js +++ b/server.js @@ -24,8 +24,6 @@ const TELEGRAM_WEBHOOK_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET || ""; const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]); const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]); const MAX_ATTACHMENT_BYTES = 3_000_000; -const ACTIVE_VISITOR_SECONDS = 90; -const PRESENT_VISITOR_SECONDS = 300; const WIDGET_COPY_FIELDS = ["title", "intro", "offlineIntro", "placeholder", "sendLabel", "dropzoneLabel", "onlineLabel", "offlineLabel", "newReplyLabel", "typingLabel", "infoTitle", "infoText", "closeLabel", "operatorReplyLabel"]; const EUROPEAN_WIDGET_LANGUAGES = new Set([ "sq", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el", @@ -362,6 +360,10 @@ function defaultSettings() { chatId: TELEGRAM_CHAT_ID, webhookSecret: TELEGRAM_WEBHOOK_SECRET || id("tgsec"), operatorName: "Alex" + }, + adminPresence: { + activeSeconds: 30, + offlineSeconds: 120 } }; } @@ -480,6 +482,7 @@ function adminBootstrap(res) { } function adminConversations(res) { + const presence = adminPresenceSettings(); const rows = db.prepare(` SELECT c.*, s.site_key, vp.display_name AS visitor_display_name, (SELECT body FROM messages WHERE conversation_id = c.id ORDER BY id DESC LIMIT 1) AS last_body, @@ -495,12 +498,12 @@ function adminConversations(res) { SELECT 1 FROM visitor_sessions vs WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token - AND datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') + AND datetime(COALESCE(vs.last_activity_at, vs.last_seen_at)) >= datetime('now', '-${presence.offlineSeconds} seconds') ) AS visitor_online, COALESCE(( SELECT CASE - WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline' - WHEN datetime(vs.last_activity_at) >= datetime('now', '-${ACTIVE_VISITOR_SECONDS} seconds') THEN 'active' + WHEN datetime(COALESCE(vs.last_activity_at, vs.last_seen_at)) < datetime('now', '-${presence.offlineSeconds} seconds') THEN 'offline' + WHEN datetime(vs.last_activity_at) >= datetime('now', '-${presence.activeSeconds} seconds') THEN 'active' ELSE 'idle' END FROM visitor_sessions vs @@ -1472,9 +1475,28 @@ function mergeSiteSettings(currentSettings, incomingSettings) { delete settings.telegram.hasBotToken; delete settings.telegram.botTokenMasked; delete settings.telegram.webhookUrl; + settings.adminPresence = normalizeAdminPresenceSettings(settings.adminPresence || currentSettings.adminPresence || {}); return settings; } +function normalizeAdminPresenceSettings(value = {}) { + const activeSeconds = clampNumber(value.activeSeconds, 10, 3600, 30); + const offlineSeconds = Math.max(clampNumber(value.offlineSeconds, 20, 7200, 120), activeSeconds + 10); + return { activeSeconds, offlineSeconds }; +} + +function adminPresenceSettings() { + const site = getDefaultSite(); + const settings = parseJson(site.settings_json, defaultSettings()); + return normalizeAdminPresenceSettings(settings.adminPresence || {}); +} + +function clampNumber(value, min, max, fallback) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.min(max, Math.max(min, Math.round(number))); +} + function normalizeWidgetLanguageSettings(value) { const defaults = defaultSettings().widgetLanguage; const settings = { ...defaults, ...(value || {}) }; @@ -1795,6 +1817,7 @@ function updateVisitorContext(conversationId, body) { } function currentVisitors() { + const presence = adminPresenceSettings(); const rows = db.prepare(` SELECT vs.*, s.site_key, vp.display_name AS visitor_display_name, (SELECT c.public_id FROM conversations c WHERE c.site_id = vs.site_id AND c.visitor_token = vs.visitor_token ORDER BY datetime(c.last_message_at) DESC, c.id DESC LIMIT 1) AS last_conversation_id, @@ -1802,19 +1825,19 @@ function currentVisitors() { FROM visitor_sessions vs JOIN sites s ON s.id = vs.site_id LEFT JOIN visitor_profiles vp ON vp.site_id = vs.site_id AND vp.visitor_token = vs.visitor_token - WHERE datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') + WHERE datetime(vs.last_seen_at) >= datetime('now', '-${presence.offlineSeconds} seconds') ORDER BY datetime(vs.last_seen_at) DESC LIMIT 100 `).all(); - return rows.map(formatVisitorSession); + return rows.map((row) => formatVisitorSession(row, presence)); } -function formatVisitorSession(row) { +function formatVisitorSession(row, thresholds = adminPresenceSettings()) { const history = parseJson(row.browsing_history_json, []); const device = parseJson(row.device_json, {}); const bot = botInfo(device.userAgent); const countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language); - const presence = visitorPresence(row); + const presence = visitorPresence(row, thresholds); return { id: row.visitor_token, initials: visitorInitials(row.visitor_token), @@ -1843,11 +1866,12 @@ function formatVisitorSession(row) { }; } -function visitorPresence(row) { +function visitorPresence(row, thresholds = adminPresenceSettings()) { const lastSeen = parseTimestampMs(row.last_seen_at); - if (!Number.isFinite(lastSeen) || Date.now() - lastSeen > PRESENT_VISITOR_SECONDS * 1000) return "offline"; const lastActivity = parseTimestampMs(row.last_activity_at || row.last_seen_at); - return Number.isFinite(lastActivity) && Date.now() - lastActivity <= ACTIVE_VISITOR_SECONDS * 1000 ? "active" : "idle"; + if (!Number.isFinite(lastSeen) || Date.now() - lastSeen > thresholds.offlineSeconds * 1000) return "offline"; + if (!Number.isFinite(lastActivity) || Date.now() - lastActivity > thresholds.offlineSeconds * 1000) return "offline"; + return Number.isFinite(lastActivity) && Date.now() - lastActivity <= thresholds.activeSeconds * 1000 ? "active" : "idle"; } function conversationDetails(conversation) { @@ -1981,6 +2005,7 @@ function getSiteById(id) { } function findConversation(publicId) { + const presence = adminPresenceSettings(); return db.prepare(` SELECT c.*, s.site_key, vp.display_name AS visitor_display_name, (SELECT body FROM messages WHERE conversation_id = c.id ORDER BY id DESC LIMIT 1) AS last_body, @@ -1996,12 +2021,12 @@ function findConversation(publicId) { SELECT 1 FROM visitor_sessions vs WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token - AND datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') + AND datetime(COALESCE(vs.last_activity_at, vs.last_seen_at)) >= datetime('now', '-${presence.offlineSeconds} seconds') ) AS visitor_online, COALESCE(( SELECT CASE - WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline' - WHEN datetime(vs.last_activity_at) >= datetime('now', '-${ACTIVE_VISITOR_SECONDS} seconds') THEN 'active' + WHEN datetime(COALESCE(vs.last_activity_at, vs.last_seen_at)) < datetime('now', '-${presence.offlineSeconds} seconds') THEN 'offline' + WHEN datetime(vs.last_activity_at) >= datetime('now', '-${presence.activeSeconds} seconds') THEN 'active' ELSE 'idle' END FROM visitor_sessions vs