Add live visitor monitoring
This commit is contained in:
+79
-1
@@ -61,7 +61,7 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 330px minmax(0, 1fr);
|
grid-template-columns: 330px minmax(0, 1fr) 320px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -358,6 +358,83 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.visitor-panel {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.visitor-panel header {
|
||||||
|
padding: 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.visitor-panel h2 { margin: 0; font-size: 18px; }
|
||||||
|
.visitor-panel header span { color: var(--muted); font-size: 12px; }
|
||||||
|
.visitor-list {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.visitor-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.visitor-card-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px minmax(0, 1fr) 10px;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.visitor-card-head strong,
|
||||||
|
.visitor-card-head small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.visitor-card-head small { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||||
|
.visitor-avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eaf6f1;
|
||||||
|
color: var(--brand);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.online-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2f9e44;
|
||||||
|
box-shadow: 0 0 0 4px rgb(47 158 68 / 14%);
|
||||||
|
}
|
||||||
|
.visitor-card dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 68px minmax(0, 1fr);
|
||||||
|
gap: 5px 8px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.visitor-card dt { color: var(--muted); }
|
||||||
|
.visitor-card dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.visitor-card a { color: var(--ink); text-decoration: none; }
|
||||||
|
.visitor-card a:hover { color: var(--brand); text-decoration: underline; }
|
||||||
.message {
|
.message {
|
||||||
max-width: min(680px, 86%);
|
max-width: min(680px, 86%);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -599,4 +676,5 @@ pre {
|
|||||||
.app { height: auto; min-height: 100vh; grid-template-columns: 1fr; overflow: visible; }
|
.app { height: auto; min-height: 100vh; grid-template-columns: 1fr; overflow: visible; }
|
||||||
.sidebar { height: 42vh; border-right: 0; border-bottom: 1px solid var(--line); }
|
.sidebar { height: 42vh; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
.conversation { height: 58vh; }
|
.conversation { height: 58vh; }
|
||||||
|
.visitor-panel { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,16 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<aside class="visitor-panel">
|
||||||
|
<header>
|
||||||
|
<h2>Navstevnici</h2>
|
||||||
|
<span id="visitorCount">0 online</span>
|
||||||
|
</header>
|
||||||
|
<div id="visitorList" class="visitor-list">
|
||||||
|
<p class="empty-list">Zatim nikdo online.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<aside id="settingsPanel" class="settings hidden">
|
<aside id="settingsPanel" class="settings hidden">
|
||||||
<div class="settings-head">
|
<div class="settings-head">
|
||||||
<h2>Nastaveni widgetu</h2>
|
<h2>Nastaveni widgetu</h2>
|
||||||
|
|||||||
+67
-1
@@ -2,6 +2,7 @@ const state = {
|
|||||||
site: null,
|
site: null,
|
||||||
snippet: "",
|
snippet: "",
|
||||||
conversations: [],
|
conversations: [],
|
||||||
|
visitors: [],
|
||||||
activeId: null,
|
activeId: null,
|
||||||
active: null,
|
active: null,
|
||||||
events: null,
|
events: null,
|
||||||
@@ -202,7 +203,7 @@ async function showApp() {
|
|||||||
$("#loginView").classList.add("hidden");
|
$("#loginView").classList.add("hidden");
|
||||||
$("#appView").classList.remove("hidden");
|
$("#appView").classList.remove("hidden");
|
||||||
await loadBootstrap();
|
await loadBootstrap();
|
||||||
await loadConversations();
|
await Promise.all([loadConversations(), loadVisitors()]);
|
||||||
connectEvents();
|
connectEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +225,12 @@ async function loadConversations() {
|
|||||||
renderConversations();
|
renderConversations();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadVisitors() {
|
||||||
|
const data = await api("/api/admin/visitors");
|
||||||
|
state.visitors = data.visitors;
|
||||||
|
renderVisitors();
|
||||||
|
}
|
||||||
|
|
||||||
async function openConversation(id) {
|
async function openConversation(id) {
|
||||||
stopAttention();
|
stopAttention();
|
||||||
state.activeId = id;
|
state.activeId = id;
|
||||||
@@ -247,6 +254,11 @@ function connectEvents() {
|
|||||||
renderSite();
|
renderSite();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.type === "visitors:update") {
|
||||||
|
state.visitors = data.visitors;
|
||||||
|
renderVisitors();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await loadConversations();
|
await loadConversations();
|
||||||
if (data.type === "conversation:delete" && data.conversationId === state.activeId) {
|
if (data.type === "conversation:delete" && data.conversationId === state.activeId) {
|
||||||
clearActiveConversation();
|
clearActiveConversation();
|
||||||
@@ -305,6 +317,60 @@ function renderConversations() {
|
|||||||
`).join("") : `<p class="empty-list">Zadny chat pro vybrany filtr.</p>`;
|
`).join("") : `<p class="empty-list">Zadny chat pro vybrany filtr.</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderVisitors() {
|
||||||
|
$("#visitorCount").textContent = `${state.visitors.length} online`;
|
||||||
|
$("#visitorList").innerHTML = state.visitors.length ? state.visitors.map((visitor) => `
|
||||||
|
<article class="visitor-card">
|
||||||
|
<div class="visitor-card-head">
|
||||||
|
<span class="visitor-avatar">${escapeHtml(visitor.initials || "??")}</span>
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(visitor.label || "Navstevnik")}</strong>
|
||||||
|
<small>${escapeHtml(browserLabel(visitor.device))}</small>
|
||||||
|
</div>
|
||||||
|
<span class="online-dot" title="Online"></span>
|
||||||
|
</div>
|
||||||
|
<dl>
|
||||||
|
<dt>Zeme</dt>
|
||||||
|
<dd>${visitor.countryFlag || ""} ${escapeHtml(visitor.countryCode || "?")}</dd>
|
||||||
|
<dt>Stranka</dt>
|
||||||
|
<dd><a href="${escapeHtml(visitor.currentUrl || "#")}" target="_blank" rel="noreferrer">${escapeHtml(pageTitle(visitor))}</a></dd>
|
||||||
|
<dt>Prisiel z</dt>
|
||||||
|
<dd>${visitor.referrer ? `<a href="${escapeHtml(visitor.referrer)}" target="_blank" rel="noreferrer">${escapeHtml(referrerLabel(visitor.referrer))}</a>` : "primy vstup"}</dd>
|
||||||
|
<dt>Cas</dt>
|
||||||
|
<dd>${formatDuration(visitor.sessionSeconds)}</dd>
|
||||||
|
<dt>Navstevy</dt>
|
||||||
|
<dd>${Number(visitor.visitCount || visitor.pageCount || 1)}</dd>
|
||||||
|
<dt>Chaty</dt>
|
||||||
|
<dd>${Number(visitor.conversationCount || 0)}</dd>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
`).join("") : `<p class="empty-list">Zatim nikdo online.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function renderActive() {
|
||||||
const { conversation, messages } = state.active;
|
const { conversation, messages } = state.active;
|
||||||
$("#conversationHeader").innerHTML = `
|
$("#conversationHeader").innerHTML = `
|
||||||
|
|||||||
@@ -164,6 +164,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
rememberPage();
|
rememberPage();
|
||||||
|
pingVisitor();
|
||||||
|
setInterval(pingVisitor, 25_000);
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden) pingVisitor();
|
||||||
|
});
|
||||||
if (session.conversationId) {
|
if (session.conversationId) {
|
||||||
loadExistingConversation(messages).finally(() => connectEvents(messages, typing, panel, launcher, settings));
|
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) {
|
function showFormError(root, message) {
|
||||||
const note = root.querySelector(".mf-note");
|
const note = root.querySelector(".mf-note");
|
||||||
note.textContent = message;
|
note.textContent = message;
|
||||||
|
|||||||
@@ -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.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/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 === "/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\/[^/]+$/) && req.method === "GET") return getVisitorConversation(res, url);
|
||||||
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createVisitorMessage(req, 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/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/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/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\/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 === "GET") return adminConversation(res, url);
|
||||||
if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+$/) && req.method === "DELETE") return deleteConversation(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
|
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_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_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")) {
|
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();
|
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) });
|
return json(res, 200, { conversations: rows.map(formatConversationSummary) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adminVisitors(res) {
|
||||||
|
return json(res, 200, { visitors: currentVisitors() });
|
||||||
|
}
|
||||||
|
|
||||||
function adminConversation(res, url) {
|
function adminConversation(res, url) {
|
||||||
const conversation = findConversation(publicIdFrom(url));
|
const conversation = findConversation(publicIdFrom(url));
|
||||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
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 });
|
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) {
|
function getVisitorConversation(res, url) {
|
||||||
const conversation = findConversation(publicIdFrom(url));
|
const conversation = findConversation(publicIdFrom(url));
|
||||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
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) {
|
function conversationDetails(conversation) {
|
||||||
const messages = db.prepare("SELECT * FROM messages WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
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);
|
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;
|
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) {
|
function isAllowedImageAttachment(attachment, buffer) {
|
||||||
const extension = path.extname(attachment.name || "").toLowerCase();
|
const extension = path.extname(attachment.name || "").toLowerCase();
|
||||||
const type = String(attachment.type || "").toLowerCase();
|
const type = String(attachment.type || "").toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user