Allow renaming visitors
This commit is contained in:
+28
-2
@@ -280,13 +280,39 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.visitor-summary-row h2 {
|
.visitor-name-edit {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0 4px 0 0;
|
margin: 0 4px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.visitor-summary-row span {
|
.visitor-name-edit:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--brand);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
.visitor-name-edit span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.visitor-name-edit svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
fill: currentColor;
|
||||||
|
opacity: .64;
|
||||||
|
}
|
||||||
|
.visitor-summary-row > span {
|
||||||
border: 1px solid #e4ebe6;
|
border: 1px solid #e4ebe6;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
|
|||||||
+40
-2
@@ -215,6 +215,13 @@ $("#visitorList").addEventListener("click", async (event) => {
|
|||||||
await openConversation(conversationId);
|
await openConversation(conversationId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#conversationHeader").addEventListener("click", async (event) => {
|
||||||
|
const button = event.target.closest("[data-rename-visitor]");
|
||||||
|
if (!button) return;
|
||||||
|
event.preventDefault();
|
||||||
|
await renameVisitor(button.dataset.renameVisitor);
|
||||||
|
});
|
||||||
|
|
||||||
async function showApp() {
|
async function showApp() {
|
||||||
$("#loginView").classList.add("hidden");
|
$("#loginView").classList.add("hidden");
|
||||||
$("#appView").classList.remove("hidden");
|
$("#appView").classList.remove("hidden");
|
||||||
@@ -353,7 +360,7 @@ function syncConversationVisitors() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function conversationVisitorName(item) {
|
function conversationVisitorName(item) {
|
||||||
return item.visitor?.name || item.visitor?.email || item.visitorLabel || "Navstevnik";
|
return item.visitorAlias || item.visitor?.name || item.visitor?.email || item.visitorLabel || "Navstevnik";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderVisitors() {
|
function renderVisitors() {
|
||||||
@@ -419,7 +426,10 @@ function renderActive() {
|
|||||||
$("#conversationHeader").innerHTML = `
|
$("#conversationHeader").innerHTML = `
|
||||||
<div class="visitor-head">
|
<div class="visitor-head">
|
||||||
<div class="visitor-summary-row">
|
<div class="visitor-summary-row">
|
||||||
<h2>${escapeHtml(conversationVisitorName(conversation))}</h2>
|
<button class="visitor-name-edit" type="button" data-rename-visitor="${escapeHtml(conversation.visitorToken)}" title="Prejmenovat navstevnika">
|
||||||
|
<span>${escapeHtml(conversationVisitorName(conversation))}</span>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 17.5V20h2.5L17.8 8.7l-2.5-2.5L4 17.5Zm15.7-11.1a1 1 0 0 0 0-1.4L18.3 3.6a1 1 0 0 0-1.4 0l-1 1 2.5 2.5 1.3-.7Z"></path></svg>
|
||||||
|
</button>
|
||||||
${presenceDot(conversation.visitorPresence)}
|
${presenceDot(conversation.visitorPresence)}
|
||||||
<span title="Zeme">${visitorCountry(conversation)}</span>
|
<span title="Zeme">${visitorCountry(conversation)}</span>
|
||||||
<span title="Doba na webu">${formatDuration(conversation.sessionSeconds)}</span>
|
<span title="Doba na webu">${formatDuration(conversation.sessionSeconds)}</span>
|
||||||
@@ -440,6 +450,34 @@ function renderActive() {
|
|||||||
$("#messages").scrollTop = $("#messages").scrollHeight;
|
$("#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) {
|
function adminReadReceipt(conversation, messages) {
|
||||||
const lastAdminMessage = [...messages].reverse().find((message) => message.senderType === "admin");
|
const lastAdminMessage = [...messages].reverse().find((message) => message.senderType === "admin");
|
||||||
if (!lastAdminMessage) return "";
|
if (!lastAdminMessage) return "";
|
||||||
|
|||||||
@@ -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/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 === "/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\/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);
|
||||||
@@ -184,9 +185,21 @@ function initDb() {
|
|||||||
UNIQUE(site_id, visitor_token)
|
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_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);
|
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")) {
|
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();
|
||||||
@@ -303,7 +316,7 @@ function adminBootstrap(res) {
|
|||||||
|
|
||||||
function adminConversations(res) {
|
function adminConversations(res) {
|
||||||
const rows = db.prepare(`
|
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 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 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,
|
(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
|
(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
|
||||||
|
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
|
ORDER BY datetime(c.last_message_at) DESC, c.id DESC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
`).all();
|
`).all();
|
||||||
@@ -343,6 +357,40 @@ function adminVisitors(res) {
|
|||||||
return json(res, 200, { visitors: currentVisitors() });
|
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) {
|
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" });
|
||||||
@@ -668,11 +716,12 @@ function updateVisitorContext(conversationId, body) {
|
|||||||
|
|
||||||
function currentVisitors() {
|
function currentVisitors() {
|
||||||
const rows = db.prepare(`
|
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 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
|
(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
|
||||||
|
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', '-${PRESENT_VISITOR_SECONDS} seconds')
|
||||||
ORDER BY datetime(vs.last_seen_at) DESC
|
ORDER BY datetime(vs.last_seen_at) DESC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
@@ -689,7 +738,9 @@ function formatVisitorSession(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: row.visitor_display_name || visitorLabel(row.visitor_token),
|
||||||
|
defaultLabel: visitorLabel(row.visitor_token),
|
||||||
|
visitorAlias: row.visitor_display_name || "",
|
||||||
online: presence !== "offline",
|
online: presence !== "offline",
|
||||||
presence,
|
presence,
|
||||||
currentUrl: row.current_url,
|
currentUrl: row.current_url,
|
||||||
@@ -745,7 +796,9 @@ function formatConversationSummary(row) {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
visitorToken: row.visitor_token,
|
visitorToken: row.visitor_token,
|
||||||
visitorInitials: visitorInitials(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),
|
visitorOnline: Boolean(row.visitor_online),
|
||||||
visitorPresence: row.visitor_presence || (row.visitor_online ? "active" : "offline"),
|
visitorPresence: row.visitor_presence || (row.visitor_online ? "active" : "offline"),
|
||||||
visitorSeenAt: row.visitor_seen_at,
|
visitorSeenAt: row.visitor_seen_at,
|
||||||
@@ -815,7 +868,7 @@ function getDefaultSite() {
|
|||||||
|
|
||||||
function findConversation(publicId) {
|
function findConversation(publicId) {
|
||||||
return db.prepare(`
|
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 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 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,
|
(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
|
(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
|
||||||
|
LEFT JOIN visitor_profiles vp ON vp.site_id = c.site_id AND vp.visitor_token = c.visitor_token
|
||||||
WHERE c.public_id = ?
|
WHERE c.public_id = ?
|
||||||
`).get(publicId);
|
`).get(publicId);
|
||||||
}
|
}
|
||||||
@@ -970,6 +1024,11 @@ function nullish(value) {
|
|||||||
return value === undefined || value === "" ? null : 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) {
|
function timestampOrNow(value) {
|
||||||
const parsed = Date.parse(value || "");
|
const parsed = Date.parse(value || "");
|
||||||
if (!Number.isFinite(parsed)) return new Date().toISOString();
|
if (!Number.isFinite(parsed)) return new Date().toISOString();
|
||||||
|
|||||||
Reference in New Issue
Block a user