-
${escapeHtml(conversationVisitorName(conversation))}
+
${presenceDot(conversation.visitorPresence)}
${visitorCountry(conversation)}
${formatDuration(conversation.sessionSeconds)}
@@ -440,6 +450,34 @@ function renderActive() {
$("#messages").scrollTop = $("#messages").scrollHeight;
}
+async function renameVisitor(visitorToken) {
+ if (!state.active?.conversation || !visitorToken) return;
+ const currentName = conversationVisitorName(state.active.conversation);
+ const nextName = window.prompt("Prejmenovat navstevnika", state.active.conversation.visitorAlias || currentName);
+ if (nextName === null) return;
+ const result = await api(`/api/admin/visitors/${encodeURIComponent(visitorToken)}`, {
+ method: "PATCH",
+ body: { displayName: nextName }
+ });
+ applyVisitorRename(visitorToken, result.label, result.displayName);
+}
+
+function applyVisitorRename(visitorToken, label, alias) {
+ const update = (item) => item.visitorToken === visitorToken
+ ? { ...item, visitorLabel: label, visitorAlias: alias || "" }
+ : item;
+ state.conversations = state.conversations.map(update);
+ state.visitors = state.visitors.map((visitor) => visitor.id === visitorToken
+ ? { ...visitor, label, visitorAlias: alias || "" }
+ : visitor);
+ if (state.active?.conversation?.visitorToken === visitorToken) {
+ state.active.conversation = update(state.active.conversation);
+ }
+ renderConversations();
+ renderVisitors();
+ if (state.active) renderActive();
+}
+
function adminReadReceipt(conversation, messages) {
const lastAdminMessage = [...messages].reverse().find((message) => message.senderType === "admin");
if (!lastAdminMessage) return "";
diff --git a/server.js b/server.js
index d18ab91..3b11e74 100644
--- a/server.js
+++ b/server.js
@@ -82,6 +82,7 @@ async function adminApi(req, res, url) {
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\/visitors\/[^/]+$/) && req.method === "PATCH") return updateVisitorProfile(req, 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 === "DELETE") return deleteConversation(res, url);
@@ -184,9 +185,21 @@ function initDb() {
UNIQUE(site_id, visitor_token)
);
+ CREATE TABLE IF NOT EXISTS visitor_profiles (
+ 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,
+ display_name TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_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);
+ CREATE INDEX IF NOT EXISTS idx_visitor_profiles_token ON visitor_profiles(site_id, visitor_token);
`);
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();
@@ -303,7 +316,7 @@ function adminBootstrap(res) {
function adminConversations(res) {
const rows = db.prepare(`
- SELECT c.*, s.site_key,
+ 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 COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count,
(SELECT COUNT(*) FROM attachments WHERE conversation_id = c.id AND mime_type LIKE 'image/%') AS image_count,
@@ -333,6 +346,7 @@ function adminConversations(res) {
(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
JOIN sites s ON s.id = c.site_id
+ LEFT JOIN visitor_profiles vp ON vp.site_id = c.site_id AND vp.visitor_token = c.visitor_token
ORDER BY datetime(c.last_message_at) DESC, c.id DESC
LIMIT 200
`).all();
@@ -343,6 +357,40 @@ function adminVisitors(res) {
return json(res, 200, { visitors: currentVisitors() });
}
+async function updateVisitorProfile(req, res, url) {
+ const site = getDefaultSite();
+ const visitorToken = decodeURIComponent(url.pathname.split("/").pop() || "");
+ if (!visitorToken) return json(res, 400, { error: "visitor_required" });
+ const body = await readJson(req);
+ const displayName = normalizeDisplayName(body.displayName);
+
+ db.prepare(`
+ INSERT INTO visitor_profiles (company_id, site_id, visitor_token, display_name)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(site_id, visitor_token) DO UPDATE SET
+ display_name = excluded.display_name,
+ updated_at = CURRENT_TIMESTAMP
+ `).run(site.company_id, site.id, visitorToken, displayName);
+
+ const conversations = db.prepare(`
+ SELECT public_id FROM conversations
+ WHERE site_id = ? AND visitor_token = ?
+ ORDER BY datetime(last_message_at) DESC
+ `).all(site.id, visitorToken);
+ const current = currentVisitors();
+ publish("admin", { type: "visitors:update", visitors: current });
+ for (const item of conversations) {
+ const conversation = findConversation(item.public_id);
+ publishConversation(conversation, { type: "conversation:update", conversation: formatConversationSummary(conversation) });
+ }
+ return json(res, 200, {
+ ok: true,
+ visitorToken,
+ displayName,
+ label: displayName || visitorLabel(visitorToken)
+ });
+}
+
function adminConversation(res, url) {
const conversation = findConversation(publicIdFrom(url));
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
@@ -668,11 +716,12 @@ function updateVisitorContext(conversationId, body) {
function currentVisitors() {
const rows = db.prepare(`
- SELECT vs.*, s.site_key,
+ 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 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
+ 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')
ORDER BY datetime(vs.last_seen_at) DESC
LIMIT 100
@@ -689,7 +738,9 @@ function formatVisitorSession(row) {
return {
id: row.visitor_token,
initials: visitorInitials(row.visitor_token),
- label: visitorLabel(row.visitor_token),
+ label: row.visitor_display_name || visitorLabel(row.visitor_token),
+ defaultLabel: visitorLabel(row.visitor_token),
+ visitorAlias: row.visitor_display_name || "",
online: presence !== "offline",
presence,
currentUrl: row.current_url,
@@ -745,7 +796,9 @@ function formatConversationSummary(row) {
status: row.status,
visitorToken: row.visitor_token,
visitorInitials: visitorInitials(row.visitor_token),
- visitorLabel: visitorLabel(row.visitor_token),
+ visitorLabel: row.visitor_display_name || visitorLabel(row.visitor_token),
+ visitorDefaultLabel: visitorLabel(row.visitor_token),
+ visitorAlias: row.visitor_display_name || "",
visitorOnline: Boolean(row.visitor_online),
visitorPresence: row.visitor_presence || (row.visitor_online ? "active" : "offline"),
visitorSeenAt: row.visitor_seen_at,
@@ -815,7 +868,7 @@ function getDefaultSite() {
function findConversation(publicId) {
return db.prepare(`
- SELECT c.*, s.site_key,
+ 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 COUNT(*) FROM messages WHERE conversation_id = c.id) AS message_count,
(SELECT COUNT(*) FROM attachments WHERE conversation_id = c.id AND mime_type LIKE 'image/%') AS image_count,
@@ -845,6 +898,7 @@ function findConversation(publicId) {
(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
JOIN sites s ON s.id = c.site_id
+ LEFT JOIN visitor_profiles vp ON vp.site_id = c.site_id AND vp.visitor_token = c.visitor_token
WHERE c.public_id = ?
`).get(publicId);
}
@@ -970,6 +1024,11 @@ function nullish(value) {
return value === undefined || value === "" ? null : value;
}
+function normalizeDisplayName(value) {
+ const text = String(value || "").trim().replace(/\s+/g, " ");
+ return text ? text.slice(0, 80) : null;
+}
+
function timestampOrNow(value) {
const parsed = Date.parse(value || "");
if (!Number.isFinite(parsed)) return new Date().toISOString();