Track visitor activity presence
This commit is contained in:
+12
-2
@@ -172,7 +172,7 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.visitor-name-wrap .online-dot {
|
.visitor-name-wrap .presence-dot {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.unread-dot {
|
.unread-dot {
|
||||||
@@ -442,13 +442,23 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.online-dot {
|
.presence-dot {
|
||||||
width: 9px;
|
width: 9px;
|
||||||
height: 9px;
|
height: 9px;
|
||||||
|
flex: 0 0 auto;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.presence-dot.active {
|
||||||
background: #2f9e44;
|
background: #2f9e44;
|
||||||
box-shadow: 0 0 0 4px rgb(47 158 68 / 14%);
|
box-shadow: 0 0 0 4px rgb(47 158 68 / 14%);
|
||||||
}
|
}
|
||||||
|
.presence-dot.idle {
|
||||||
|
background: #f59f00;
|
||||||
|
box-shadow: 0 0 0 4px rgb(245 159 0 / 16%);
|
||||||
|
}
|
||||||
|
.presence-dot.offline {
|
||||||
|
background: #c9d4ce;
|
||||||
|
}
|
||||||
.visitor-compact-meta {
|
.visitor-compact-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+12
-5
@@ -321,7 +321,7 @@ function renderConversations() {
|
|||||||
<span class="visitor-name-wrap">
|
<span class="visitor-name-wrap">
|
||||||
<span class="unread-dot" aria-label="${item.hasUnread ? "Neprecteno" : "Precteno"}"></span>
|
<span class="unread-dot" aria-label="${item.hasUnread ? "Neprecteno" : "Precteno"}"></span>
|
||||||
<strong>${escapeHtml(conversationVisitorName(item))}</strong>
|
<strong>${escapeHtml(conversationVisitorName(item))}</strong>
|
||||||
${item.visitorOnline ? `<span class="online-dot" aria-label="Online" title="Online"></span>` : ""}
|
${presenceDot(item.visitorPresence)}
|
||||||
</span>
|
</span>
|
||||||
${item.imageCount ? `<span class="photo-count">foto ${item.imageCount}</span>` : ""}
|
${item.imageCount ? `<span class="photo-count">foto ${item.imageCount}</span>` : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -344,10 +344,11 @@ function renderConversations() {
|
|||||||
|
|
||||||
function syncConversationVisitors() {
|
function syncConversationVisitors() {
|
||||||
if (!state.visitorsLoaded) return;
|
if (!state.visitorsLoaded) return;
|
||||||
const onlineIds = new Set(state.visitors.map((visitor) => visitor.id));
|
const visitorsById = new Map(state.visitors.map((visitor) => [visitor.id, visitor]));
|
||||||
state.conversations = state.conversations.map((item) => ({
|
state.conversations = state.conversations.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
visitorOnline: onlineIds.has(item.visitorToken)
|
visitorOnline: visitorsById.has(item.visitorToken),
|
||||||
|
visitorPresence: visitorsById.get(item.visitorToken)?.presence || "offline"
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +361,7 @@ function renderVisitors() {
|
|||||||
const botCount = state.visitors.filter((visitor) => visitor.isBot).length;
|
const botCount = state.visitors.filter((visitor) => visitor.isBot).length;
|
||||||
$("#hideBotsToggle").classList.toggle("active", state.hideBots);
|
$("#hideBotsToggle").classList.toggle("active", state.hideBots);
|
||||||
$("#hideBotsToggle").textContent = state.hideBots ? "Skryt boty" : "Boty videt";
|
$("#hideBotsToggle").textContent = state.hideBots ? "Skryt boty" : "Boty videt";
|
||||||
$("#visitorCount").textContent = `${visitors.length} online${state.hideBots && botCount ? ` · ${botCount} bot` : ""}`;
|
$("#visitorCount").textContent = `${visitors.length} na webu${state.hideBots && botCount ? ` · ${botCount} bot` : ""}`;
|
||||||
$("#visitorList").innerHTML = visitors.length ? visitors.map((visitor) => `
|
$("#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 || "")}">
|
<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">
|
<div class="visitor-card-head">
|
||||||
@@ -369,7 +370,7 @@ function renderVisitors() {
|
|||||||
<strong>${escapeHtml(visitor.label || "Navstevnik")}</strong>
|
<strong>${escapeHtml(visitor.label || "Navstevnik")}</strong>
|
||||||
<small>${escapeHtml(visitor.isBot ? `bot: ${visitor.botReason || "crawler"}` : browserLabel(visitor.device))}</small>
|
<small>${escapeHtml(visitor.isBot ? `bot: ${visitor.botReason || "crawler"}` : browserLabel(visitor.device))}</small>
|
||||||
</div>
|
</div>
|
||||||
<span class="online-dot" title="Online"></span>
|
${presenceDot(visitor.presence)}
|
||||||
</div>
|
</div>
|
||||||
<div class="visitor-compact-meta">
|
<div class="visitor-compact-meta">
|
||||||
<span>${visitor.countryFlag || escapeHtml(visitor.countryCode || "?")}</span>
|
<span>${visitor.countryFlag || escapeHtml(visitor.countryCode || "?")}</span>
|
||||||
@@ -383,6 +384,12 @@ function renderVisitors() {
|
|||||||
`).join("") : `<p class="empty-list">Zatim nikdo online.</p>`;
|
`).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) {
|
function pageTitle(visitor) {
|
||||||
const history = Array.isArray(visitor.browsingHistory) ? visitor.browsingHistory : [];
|
const history = Array.isArray(visitor.browsingHistory) ? visitor.browsingHistory : [];
|
||||||
const current = history.find((item) => item.url === visitor.currentUrl) || history[history.length - 1];
|
const current = history.find((item) => item.url === visitor.currentUrl) || history[history.length - 1];
|
||||||
|
|||||||
+21
-1
@@ -7,6 +7,7 @@
|
|||||||
const session = loadSession();
|
const session = loadSession();
|
||||||
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
||||||
const maxAttachmentBytes = 3_000_000;
|
const maxAttachmentBytes = 3_000_000;
|
||||||
|
let lastActivityAt = Date.now();
|
||||||
|
|
||||||
fetch(`${apiBase}/api/widget/config?siteKey=${encodeURIComponent(siteKey)}`)
|
fetch(`${apiBase}/api/widget/config?siteKey=${encodeURIComponent(siteKey)}`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
@@ -164,10 +165,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
rememberPage();
|
rememberPage();
|
||||||
|
trackVisitorActivity(root);
|
||||||
pingVisitor();
|
pingVisitor();
|
||||||
setInterval(pingVisitor, 25_000);
|
setInterval(pingVisitor, 25_000);
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (!document.hidden) pingVisitor();
|
if (!document.hidden) {
|
||||||
|
noteActivity();
|
||||||
|
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));
|
||||||
@@ -340,6 +345,7 @@
|
|||||||
currentUrl: location.href,
|
currentUrl: location.href,
|
||||||
referrer: document.referrer,
|
referrer: document.referrer,
|
||||||
browsingHistory: rememberPage(),
|
browsingHistory: rememberPage(),
|
||||||
|
lastActivityAt: new Date(lastActivityAt).toISOString(),
|
||||||
language: navigator.language,
|
language: navigator.language,
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
device: {
|
device: {
|
||||||
@@ -354,6 +360,20 @@
|
|||||||
postJson(`${apiBase}/api/widget/visitors/ping`, withVisitorContext({})).catch(() => {});
|
postJson(`${apiBase}/api/widget/visitors/ping`, withVisitorContext({})).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trackVisitorActivity(root) {
|
||||||
|
const passive = { passive: true, capture: true };
|
||||||
|
for (const eventName of ["pointerdown", "keydown", "mousemove", "touchstart", "scroll"]) {
|
||||||
|
document.addEventListener(eventName, noteActivity, passive);
|
||||||
|
root.addEventListener(eventName, noteActivity, passive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteActivity() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastActivityAt < 1_000) return;
|
||||||
|
lastActivityAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const COOKIE_NAME = "mf_session";
|
|||||||
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;
|
||||||
|
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
@@ -175,6 +177,7 @@ function initDb() {
|
|||||||
ip TEXT,
|
ip TEXT,
|
||||||
country_code TEXT,
|
country_code TEXT,
|
||||||
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_activity_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(site_id, visitor_token)
|
UNIQUE(site_id, visitor_token)
|
||||||
);
|
);
|
||||||
@@ -186,6 +189,9 @@ function initDb() {
|
|||||||
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();
|
||||||
}
|
}
|
||||||
|
if (ensureColumn("visitor_sessions", "last_activity_at", "TEXT")) {
|
||||||
|
db.prepare("UPDATE visitor_sessions SET last_activity_at = last_seen_at WHERE last_activity_at IS NULL").run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureColumn(table, column, definition) {
|
function ensureColumn(table, column, definition) {
|
||||||
@@ -308,8 +314,19 @@ 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', '-90 seconds')
|
AND datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
|
||||||
) AS visitor_online,
|
) 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'
|
||||||
|
ELSE 'idle'
|
||||||
|
END
|
||||||
|
FROM visitor_sessions vs
|
||||||
|
WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token
|
||||||
|
ORDER BY datetime(vs.last_seen_at) DESC
|
||||||
|
LIMIT 1
|
||||||
|
), 'offline') AS visitor_presence,
|
||||||
(SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_count
|
(SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_count
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
JOIN sites s ON s.id = c.site_id
|
JOIN sites s ON s.id = c.site_id
|
||||||
@@ -386,12 +403,13 @@ async function visitorPing(req, res) {
|
|||||||
const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(body.siteKey || "9b-plus");
|
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" });
|
if (!site) return json(res, 404, { error: "site_not_found" });
|
||||||
const visitorToken = body.visitorToken || id("vis");
|
const visitorToken = body.visitorToken || id("vis");
|
||||||
|
const lastActivityAt = timestampOrNow(body.lastActivityAt);
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO visitor_sessions (
|
INSERT INTO visitor_sessions (
|
||||||
company_id, site_id, visitor_token, current_url, referrer, browsing_history_json,
|
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
|
device_json, language, timezone, ip, country_code, first_seen_at, last_activity_at, last_seen_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(site_id, visitor_token) DO UPDATE SET
|
ON CONFLICT(site_id, visitor_token) DO UPDATE SET
|
||||||
current_url = excluded.current_url,
|
current_url = excluded.current_url,
|
||||||
referrer = COALESCE(excluded.referrer, visitor_sessions.referrer),
|
referrer = COALESCE(excluded.referrer, visitor_sessions.referrer),
|
||||||
@@ -401,6 +419,11 @@ async function visitorPing(req, res) {
|
|||||||
timezone = excluded.timezone,
|
timezone = excluded.timezone,
|
||||||
ip = excluded.ip,
|
ip = excluded.ip,
|
||||||
country_code = COALESCE(excluded.country_code, visitor_sessions.country_code),
|
country_code = COALESCE(excluded.country_code, visitor_sessions.country_code),
|
||||||
|
last_activity_at = CASE
|
||||||
|
WHEN datetime(excluded.last_activity_at) > datetime(visitor_sessions.last_activity_at)
|
||||||
|
THEN excluded.last_activity_at
|
||||||
|
ELSE visitor_sessions.last_activity_at
|
||||||
|
END,
|
||||||
last_seen_at = CURRENT_TIMESTAMP
|
last_seen_at = CURRENT_TIMESTAMP
|
||||||
`).run(
|
`).run(
|
||||||
site.company_id,
|
site.company_id,
|
||||||
@@ -413,7 +436,8 @@ async function visitorPing(req, res) {
|
|||||||
nullish(body.language),
|
nullish(body.language),
|
||||||
nullish(body.timezone),
|
nullish(body.timezone),
|
||||||
clientIp(req),
|
clientIp(req),
|
||||||
nullish(countryFromHeaders(req))
|
nullish(countryFromHeaders(req)),
|
||||||
|
lastActivityAt
|
||||||
);
|
);
|
||||||
publish("admin", { type: "visitors:update", visitors: currentVisitors() });
|
publish("admin", { type: "visitors:update", visitors: currentVisitors() });
|
||||||
return json(res, 200, { ok: true });
|
return json(res, 200, { ok: true });
|
||||||
@@ -635,7 +659,7 @@ function currentVisitors() {
|
|||||||
(SELECT COUNT(*) FROM conversations c WHERE c.site_id = vs.site_id AND c.visitor_token = vs.visitor_token) AS visitor_conversation_count
|
(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
|
FROM visitor_sessions vs
|
||||||
JOIN sites s ON s.id = vs.site_id
|
JOIN sites s ON s.id = vs.site_id
|
||||||
WHERE datetime(vs.last_seen_at) >= datetime('now', '-90 seconds')
|
WHERE datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
|
||||||
ORDER BY datetime(vs.last_seen_at) DESC
|
ORDER BY datetime(vs.last_seen_at) DESC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
`).all();
|
`).all();
|
||||||
@@ -647,11 +671,13 @@ function formatVisitorSession(row) {
|
|||||||
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);
|
||||||
return {
|
return {
|
||||||
id: row.visitor_token,
|
id: row.visitor_token,
|
||||||
initials: visitorInitials(row.visitor_token),
|
initials: visitorInitials(row.visitor_token),
|
||||||
label: visitorLabel(row.visitor_token),
|
label: visitorLabel(row.visitor_token),
|
||||||
online: true,
|
online: presence !== "offline",
|
||||||
|
presence,
|
||||||
currentUrl: row.current_url,
|
currentUrl: row.current_url,
|
||||||
referrer: row.referrer,
|
referrer: row.referrer,
|
||||||
browsingHistory: history,
|
browsingHistory: history,
|
||||||
@@ -667,10 +693,18 @@ function formatVisitorSession(row) {
|
|||||||
countryCode,
|
countryCode,
|
||||||
countryFlag: countryCode ? flagEmoji(countryCode) : "",
|
countryFlag: countryCode ? flagEmoji(countryCode) : "",
|
||||||
sessionSeconds: sessionSeconds(row.browsing_history_json, row.first_seen_at),
|
sessionSeconds: sessionSeconds(row.browsing_history_json, row.first_seen_at),
|
||||||
|
lastActivityAt: row.last_activity_at,
|
||||||
lastSeenAt: row.last_seen_at
|
lastSeenAt: row.last_seen_at
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visitorPresence(row) {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -699,6 +733,7 @@ function formatConversationSummary(row) {
|
|||||||
visitorInitials: visitorInitials(row.visitor_token),
|
visitorInitials: visitorInitials(row.visitor_token),
|
||||||
visitorLabel: visitorLabel(row.visitor_token),
|
visitorLabel: visitorLabel(row.visitor_token),
|
||||||
visitorOnline: Boolean(row.visitor_online),
|
visitorOnline: Boolean(row.visitor_online),
|
||||||
|
visitorPresence: row.visitor_presence || (row.visitor_online ? "active" : "offline"),
|
||||||
visitor: {
|
visitor: {
|
||||||
name: row.visitor_name,
|
name: row.visitor_name,
|
||||||
email: row.visitor_email,
|
email: row.visitor_email,
|
||||||
@@ -779,8 +814,19 @@ 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', '-90 seconds')
|
AND datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds')
|
||||||
) AS visitor_online,
|
) 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'
|
||||||
|
ELSE 'idle'
|
||||||
|
END
|
||||||
|
FROM visitor_sessions vs
|
||||||
|
WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token
|
||||||
|
ORDER BY datetime(vs.last_seen_at) DESC
|
||||||
|
LIMIT 1
|
||||||
|
), 'offline') AS visitor_presence,
|
||||||
(SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_count
|
(SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_count
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
JOIN sites s ON s.id = c.site_id
|
JOIN sites s ON s.id = c.site_id
|
||||||
@@ -909,6 +955,21 @@ function nullish(value) {
|
|||||||
return value === undefined || value === "" ? null : value;
|
return value === undefined || value === "" ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timestampOrNow(value) {
|
||||||
|
const parsed = Date.parse(value || "");
|
||||||
|
if (!Number.isFinite(parsed)) return new Date().toISOString();
|
||||||
|
const now = Date.now();
|
||||||
|
const bounded = Math.min(Math.max(parsed, now - 24 * 60 * 60 * 1000), now);
|
||||||
|
return new Date(bounded).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimestampMs(value) {
|
||||||
|
if (!value) return NaN;
|
||||||
|
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;
|
||||||
|
return Date.parse(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
function clientIp(req) {
|
function clientIp(req) {
|
||||||
return (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim();
|
return (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user