Add admin presence timing settings
This commit is contained in:
@@ -1328,6 +1328,16 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
accent-color: var(--brand);
|
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 {
|
pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@@ -181,6 +181,20 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="settings-tab-panel" data-settings-panel="admin">
|
<section class="settings-tab-panel" data-settings-panel="admin">
|
||||||
<div class="admin-preferences">
|
<div class="admin-preferences">
|
||||||
|
<fieldset class="effect-settings presence-settings">
|
||||||
|
<legend>Signalizace navstevniku</legend>
|
||||||
|
<p class="ai-help">Zelena znamena aktivni. Po necinosti se prepne na oranzovou a po dalsim limitu na sedou.</p>
|
||||||
|
<div class="settings-top-row">
|
||||||
|
<label>Zelena do
|
||||||
|
<span class="seconds-field"><input id="presenceActiveSeconds" type="number" min="10" max="3600" step="5"><span>s</span></span>
|
||||||
|
</label>
|
||||||
|
<label>Seda po
|
||||||
|
<span class="seconds-field"><input id="presenceOfflineSeconds" type="number" min="20" max="7200" step="5"><span>s</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<small id="presenceStatus" class="full-row">Oranzova je cas mezi zelenou a sedou.</small>
|
||||||
|
<button id="savePresenceSettings" class="full-row" type="button">Ulozit signalizaci</button>
|
||||||
|
</fieldset>
|
||||||
<fieldset class="effect-settings sound-settings">
|
<fieldset class="effect-settings sound-settings">
|
||||||
<legend>BO zvuk</legend>
|
<legend>BO zvuk</legend>
|
||||||
<label class="setting-switch full-row">Zvuk notifikaci <input id="adminSoundToggleSettings" type="checkbox"></label>
|
<label class="setting-switch full-row">Zvuk notifikaci <input id="adminSoundToggleSettings" type="checkbox"></label>
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ $("#logoutButton").addEventListener("click", async () => {
|
|||||||
|
|
||||||
$("#settingsButton").addEventListener("click", () => $("#settingsPanel").classList.toggle("hidden"));
|
$("#settingsButton").addEventListener("click", () => $("#settingsPanel").classList.toggle("hidden"));
|
||||||
$("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden"));
|
$("#closeSettingsButton").addEventListener("click", () => $("#settingsPanel").classList.add("hidden"));
|
||||||
|
$("#presenceActiveSeconds").addEventListener("input", markPresenceSettingsDirty);
|
||||||
|
$("#presenceOfflineSeconds").addEventListener("input", markPresenceSettingsDirty);
|
||||||
|
$("#savePresenceSettings").addEventListener("click", savePresenceSettings);
|
||||||
$("#adminSoundToggle").addEventListener("click", () => {
|
$("#adminSoundToggle").addEventListener("click", () => {
|
||||||
setAdminSoundMuted(!state.soundMuted);
|
setAdminSoundMuted(!state.soundMuted);
|
||||||
});
|
});
|
||||||
@@ -522,6 +525,7 @@ function renderSite() {
|
|||||||
? `Klic ulozeny (${s.translations.openaiApiKeyMasked || "sk-..."})`
|
? `Klic ulozeny (${s.translations.openaiApiKeyMasked || "sk-..."})`
|
||||||
: "Klic neni ulozeny. Bez nej AI preklady nepobezi.";
|
: "Klic neni ulozeny. Bez nej AI preklady nepobezi.";
|
||||||
renderModelOptions(s.translations?.model || "gpt-5-mini");
|
renderModelOptions(s.translations?.model || "gpt-5-mini");
|
||||||
|
renderPresenceSettings(s.adminPresence || {});
|
||||||
renderTelegramSettings(s.telegram || {});
|
renderTelegramSettings(s.telegram || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +761,52 @@ function setOpenAiStatus(message) {
|
|||||||
$("#openaiApiKeyStatus").textContent = 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) {
|
function renderTelegramSettings(settings) {
|
||||||
$("#telegramEnabled").checked = Boolean(settings.enabled);
|
$("#telegramEnabled").checked = Boolean(settings.enabled);
|
||||||
$("#telegramOperatorName").value = settings.operatorName || "Alex";
|
$("#telegramOperatorName").value = settings.operatorName || "Alex";
|
||||||
|
|||||||
@@ -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_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 ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]);
|
||||||
const MAX_ATTACHMENT_BYTES = 3_000_000;
|
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 WIDGET_COPY_FIELDS = ["title", "intro", "offlineIntro", "placeholder", "sendLabel", "dropzoneLabel", "onlineLabel", "offlineLabel", "newReplyLabel", "typingLabel", "infoTitle", "infoText", "closeLabel", "operatorReplyLabel"];
|
||||||
const EUROPEAN_WIDGET_LANGUAGES = new Set([
|
const EUROPEAN_WIDGET_LANGUAGES = new Set([
|
||||||
"sq", "be", "bs", "bg", "ca", "hr", "cs", "da", "nl", "en", "et", "fi", "fr", "de", "el",
|
"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,
|
chatId: TELEGRAM_CHAT_ID,
|
||||||
webhookSecret: TELEGRAM_WEBHOOK_SECRET || id("tgsec"),
|
webhookSecret: TELEGRAM_WEBHOOK_SECRET || id("tgsec"),
|
||||||
operatorName: "Alex"
|
operatorName: "Alex"
|
||||||
|
},
|
||||||
|
adminPresence: {
|
||||||
|
activeSeconds: 30,
|
||||||
|
offlineSeconds: 120
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -480,6 +482,7 @@ function adminBootstrap(res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adminConversations(res) {
|
function adminConversations(res) {
|
||||||
|
const presence = adminPresenceSettings();
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT c.*, s.site_key, vp.display_name AS visitor_display_name,
|
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,
|
(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
|
SELECT 1 FROM visitor_sessions vs
|
||||||
WHERE vs.site_id = c.site_id
|
WHERE vs.site_id = c.site_id
|
||||||
AND vs.visitor_token = c.visitor_token
|
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,
|
) AS visitor_online,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT CASE
|
SELECT CASE
|
||||||
WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline'
|
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', '-${ACTIVE_VISITOR_SECONDS} seconds') THEN 'active'
|
WHEN datetime(vs.last_activity_at) >= datetime('now', '-${presence.activeSeconds} seconds') THEN 'active'
|
||||||
ELSE 'idle'
|
ELSE 'idle'
|
||||||
END
|
END
|
||||||
FROM visitor_sessions vs
|
FROM visitor_sessions vs
|
||||||
@@ -1472,9 +1475,28 @@ function mergeSiteSettings(currentSettings, incomingSettings) {
|
|||||||
delete settings.telegram.hasBotToken;
|
delete settings.telegram.hasBotToken;
|
||||||
delete settings.telegram.botTokenMasked;
|
delete settings.telegram.botTokenMasked;
|
||||||
delete settings.telegram.webhookUrl;
|
delete settings.telegram.webhookUrl;
|
||||||
|
settings.adminPresence = normalizeAdminPresenceSettings(settings.adminPresence || currentSettings.adminPresence || {});
|
||||||
return settings;
|
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) {
|
function normalizeWidgetLanguageSettings(value) {
|
||||||
const defaults = defaultSettings().widgetLanguage;
|
const defaults = defaultSettings().widgetLanguage;
|
||||||
const settings = { ...defaults, ...(value || {}) };
|
const settings = { ...defaults, ...(value || {}) };
|
||||||
@@ -1795,6 +1817,7 @@ function updateVisitorContext(conversationId, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentVisitors() {
|
function currentVisitors() {
|
||||||
|
const presence = adminPresenceSettings();
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT vs.*, s.site_key, vp.display_name AS visitor_display_name,
|
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,
|
(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
|
FROM visitor_sessions vs
|
||||||
JOIN sites s ON s.id = vs.site_id
|
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
|
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
|
ORDER BY datetime(vs.last_seen_at) DESC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
`).all();
|
`).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 history = parseJson(row.browsing_history_json, []);
|
||||||
const device = parseJson(row.device_json, {});
|
const device = parseJson(row.device_json, {});
|
||||||
const bot = botInfo(device.userAgent);
|
const bot = botInfo(device.userAgent);
|
||||||
const countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language);
|
const countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language);
|
||||||
const presence = visitorPresence(row);
|
const presence = visitorPresence(row, thresholds);
|
||||||
return {
|
return {
|
||||||
id: row.visitor_token,
|
id: row.visitor_token,
|
||||||
initials: visitorInitials(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);
|
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);
|
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) {
|
function conversationDetails(conversation) {
|
||||||
@@ -1981,6 +2005,7 @@ function getSiteById(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findConversation(publicId) {
|
function findConversation(publicId) {
|
||||||
|
const presence = adminPresenceSettings();
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT c.*, s.site_key, vp.display_name AS visitor_display_name,
|
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,
|
(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
|
SELECT 1 FROM visitor_sessions vs
|
||||||
WHERE vs.site_id = c.site_id
|
WHERE vs.site_id = c.site_id
|
||||||
AND vs.visitor_token = c.visitor_token
|
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,
|
) AS visitor_online,
|
||||||
COALESCE((
|
COALESCE((
|
||||||
SELECT CASE
|
SELECT CASE
|
||||||
WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline'
|
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', '-${ACTIVE_VISITOR_SECONDS} seconds') THEN 'active'
|
WHEN datetime(vs.last_activity_at) >= datetime('now', '-${presence.activeSeconds} seconds') THEN 'active'
|
||||||
ELSE 'idle'
|
ELSE 'idle'
|
||||||
END
|
END
|
||||||
FROM visitor_sessions vs
|
FROM visitor_sessions vs
|
||||||
|
|||||||
Reference in New Issue
Block a user