Nastaveni widgetu
diff --git a/public/admin.js b/public/admin.js
index 4acb775..ed6b56a 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -2,6 +2,7 @@ const state = {
site: null,
snippet: "",
conversations: [],
+ visitors: [],
activeId: null,
active: null,
events: null,
@@ -202,7 +203,7 @@ async function showApp() {
$("#loginView").classList.add("hidden");
$("#appView").classList.remove("hidden");
await loadBootstrap();
- await loadConversations();
+ await Promise.all([loadConversations(), loadVisitors()]);
connectEvents();
}
@@ -224,6 +225,12 @@ async function loadConversations() {
renderConversations();
}
+async function loadVisitors() {
+ const data = await api("/api/admin/visitors");
+ state.visitors = data.visitors;
+ renderVisitors();
+}
+
async function openConversation(id) {
stopAttention();
state.activeId = id;
@@ -247,6 +254,11 @@ function connectEvents() {
renderSite();
return;
}
+ if (data.type === "visitors:update") {
+ state.visitors = data.visitors;
+ renderVisitors();
+ return;
+ }
await loadConversations();
if (data.type === "conversation:delete" && data.conversationId === state.activeId) {
clearActiveConversation();
@@ -305,6 +317,60 @@ function renderConversations() {
`).join("") : `
Zadny chat pro vybrany filtr.
`;
}
+function renderVisitors() {
+ $("#visitorCount").textContent = `${state.visitors.length} online`;
+ $("#visitorList").innerHTML = state.visitors.length ? state.visitors.map((visitor) => `
+
+
+
${escapeHtml(visitor.initials || "??")}
+
+ ${escapeHtml(visitor.label || "Navstevnik")}
+ ${escapeHtml(browserLabel(visitor.device))}
+
+
+
+
+ - Zeme
+ - ${visitor.countryFlag || ""} ${escapeHtml(visitor.countryCode || "?")}
+ - Stranka
+ - ${escapeHtml(pageTitle(visitor))}
+ - Prisiel z
+ - ${visitor.referrer ? `${escapeHtml(referrerLabel(visitor.referrer))}` : "primy vstup"}
+ - Cas
+ - ${formatDuration(visitor.sessionSeconds)}
+ - Navstevy
+ - ${Number(visitor.visitCount || visitor.pageCount || 1)}
+ - Chaty
+ - ${Number(visitor.conversationCount || 0)}
+
+
+ `).join("") : `
Zatim nikdo online.
`;
+}
+
+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 = `
diff --git a/public/widget.js b/public/widget.js
index 8dcc27d..21dac0d 100644
--- a/public/widget.js
+++ b/public/widget.js
@@ -164,6 +164,11 @@
});
rememberPage();
+ pingVisitor();
+ setInterval(pingVisitor, 25_000);
+ document.addEventListener("visibilitychange", () => {
+ if (!document.hidden) pingVisitor();
+ });
if (session.conversationId) {
loadExistingConversation(messages).finally(() => connectEvents(messages, typing, panel, launcher, settings));
}
@@ -345,6 +350,10 @@
};
}
+ function pingVisitor() {
+ postJson(`${apiBase}/api/widget/visitors/ping`, withVisitorContext({})).catch(() => {});
+ }
+
function showFormError(root, message) {
const note = root.querySelector(".mf-note");
note.textContent = message;
diff --git a/server.js b/server.js
index 1a16c44..7a0d3f9 100644
--- a/server.js
+++ b/server.js
@@ -62,6 +62,7 @@ async function route(req, res) {
if (url.pathname.startsWith("/api/admin/")) return requireAdmin(req, res, () => adminApi(req, res, url));
if (url.pathname === "/api/widget/config" && req.method === "GET") return widgetConfig(res, url);
+ if (url.pathname === "/api/widget/visitors/ping" && req.method === "POST") return visitorPing(req, res);
if (url.pathname === "/api/widget/conversations" && req.method === "POST") return createConversation(req, res);
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+$/) && req.method === "GET") return getVisitorConversation(res, url);
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createVisitorMessage(req, res, url);
@@ -77,6 +78,7 @@ async function adminApi(req, res, url) {
if (url.pathname === "/api/admin/events" && req.method === "GET") return eventStream(req, res, url, "admin");
if (url.pathname === "/api/admin/site" && req.method === "PATCH") return updateSite(req, res);
if (url.pathname === "/api/admin/conversations" && req.method === "GET") return adminConversations(res);
+ if (url.pathname === "/api/admin/visitors" && req.method === "GET") return adminVisitors(res);
if (url.pathname.match(/^\/api\/admin\/attachments\/\d+$/) && req.method === "DELETE") return deleteAttachment(res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "GET") return adminConversation(res, url);
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "DELETE") return deleteConversation(res, url);
@@ -159,8 +161,27 @@ function initDb() {
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+ CREATE TABLE IF NOT EXISTS visitor_sessions (
+ id INTEGER PRIMARY KEY,
+ company_id INTEGER NOT NULL REFERENCES companies(id),
+ site_id INTEGER NOT NULL REFERENCES sites(id),
+ visitor_token TEXT NOT NULL,
+ current_url TEXT,
+ referrer TEXT,
+ browsing_history_json TEXT NOT NULL DEFAULT '[]',
+ device_json TEXT NOT NULL DEFAULT '{}',
+ language TEXT,
+ timezone TEXT,
+ ip TEXT,
+ country_code TEXT,
+ first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(site_id, visitor_token)
+ );
+
CREATE INDEX IF NOT EXISTS idx_conversations_site_status ON conversations(site_id, status, last_message_at);
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);
+ CREATE INDEX IF NOT EXISTS idx_visitor_sessions_seen ON visitor_sessions(site_id, last_seen_at);
`);
if (ensureColumn("conversations", "admin_seen_at", "TEXT")) {
db.prepare("UPDATE conversations SET admin_seen_at = last_message_at WHERE admin_seen_at IS NULL").run();
@@ -292,6 +313,10 @@ function adminConversations(res) {
return json(res, 200, { conversations: rows.map(formatConversationSummary) });
}
+function adminVisitors(res) {
+ return json(res, 200, { visitors: currentVisitors() });
+}
+
function adminConversation(res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
@@ -350,6 +375,44 @@ function widgetConfig(res, url) {
return json(res, 200, { site: formatSite(site), publicBaseUrl: PUBLIC_BASE_URL });
}
+async function visitorPing(req, res) {
+ const body = await readJson(req);
+ const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(body.siteKey || "9b-plus");
+ if (!site) return json(res, 404, { error: "site_not_found" });
+ const visitorToken = body.visitorToken || id("vis");
+ db.prepare(`
+ INSERT INTO visitor_sessions (
+ company_id, site_id, visitor_token, current_url, referrer, browsing_history_json,
+ device_json, language, timezone, ip, country_code, first_seen_at, last_seen_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ ON CONFLICT(site_id, visitor_token) DO UPDATE SET
+ current_url = excluded.current_url,
+ referrer = COALESCE(excluded.referrer, visitor_sessions.referrer),
+ browsing_history_json = excluded.browsing_history_json,
+ device_json = excluded.device_json,
+ language = excluded.language,
+ timezone = excluded.timezone,
+ ip = excluded.ip,
+ country_code = COALESCE(excluded.country_code, visitor_sessions.country_code),
+ last_seen_at = CURRENT_TIMESTAMP
+ `).run(
+ site.company_id,
+ site.id,
+ visitorToken,
+ nullish(body.currentUrl),
+ nullish(body.referrer),
+ JSON.stringify(body.browsingHistory || []),
+ JSON.stringify(body.device || {}),
+ nullish(body.language),
+ nullish(body.timezone),
+ clientIp(req),
+ nullish(countryFromHeaders(req))
+ );
+ publish("admin", { type: "visitors:update", visitors: currentVisitors() });
+ return json(res, 200, { ok: true });
+}
+
function getVisitorConversation(res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
@@ -559,6 +622,44 @@ function updateVisitorContext(conversationId, body) {
);
}
+function currentVisitors() {
+ const rows = db.prepare(`
+ SELECT vs.*, s.site_key,
+ (SELECT COUNT(*) FROM conversations c WHERE c.site_id = vs.site_id AND c.visitor_token = vs.visitor_token) AS visitor_conversation_count
+ FROM visitor_sessions vs
+ JOIN sites s ON s.id = vs.site_id
+ WHERE datetime(vs.last_seen_at) >= datetime('now', '-90 seconds')
+ ORDER BY datetime(vs.last_seen_at) DESC
+ LIMIT 100
+ `).all();
+ return rows.map(formatVisitorSession);
+}
+
+function formatVisitorSession(row) {
+ const history = parseJson(row.browsing_history_json, []);
+ const device = parseJson(row.device_json, {});
+ const countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language);
+ return {
+ id: row.visitor_token,
+ initials: visitorInitials(row.visitor_token),
+ label: visitorLabel(row.visitor_token),
+ online: true,
+ currentUrl: row.current_url,
+ referrer: row.referrer,
+ browsingHistory: history,
+ pageCount: new Set(history.map((item) => item.url).filter(Boolean)).size,
+ visitCount: Math.max(1, history.length),
+ conversationCount: row.visitor_conversation_count || 0,
+ device,
+ language: row.language,
+ timezone: row.timezone,
+ countryCode,
+ countryFlag: countryCode ? flagEmoji(countryCode) : "",
+ sessionSeconds: sessionSeconds(row.browsing_history_json, row.first_seen_at),
+ lastSeenAt: row.last_seen_at
+ };
+}
+
function conversationDetails(conversation) {
const messages = db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
@@ -812,6 +913,20 @@ function countryFromLanguage(language) {
return match ? match[1].toUpperCase() : null;
}
+function visitorInitials(visitorToken) {
+ const hash = crypto.createHash("sha1").update(String(visitorToken || "")).digest("hex").toUpperCase();
+ return hash.slice(0, 2);
+}
+
+function visitorLabel(visitorToken) {
+ return `Navstevnik ${visitorInitials(visitorToken)}`;
+}
+
+function flagEmoji(countryCode) {
+ if (!countryCode || countryCode.length !== 2) return "";
+ return countryCode.toUpperCase().replace(/./g, (char) => String.fromCodePoint(127397 + char.charCodeAt()));
+}
+
function isAllowedImageAttachment(attachment, buffer) {
const extension = path.extname(attachment.name || "").toLowerCase();
const type = String(attachment.type || "").toLowerCase();