diff --git a/public/admin.css b/public/admin.css index 52891cb..440da2c 100644 --- a/public/admin.css +++ b/public/admin.css @@ -280,13 +280,39 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } color: var(--muted); font-size: 12px; } -.visitor-summary-row h2 { +.visitor-name-edit { min-width: 0; margin: 0 4px 0 0; + padding: 0; + display: inline-flex; + align-items: center; + gap: 7px; + border: 0; + background: transparent; color: var(--ink); 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-radius: 999px; padding: 2px 7px; diff --git a/public/admin.js b/public/admin.js index caf1ffb..1bd31f9 100644 --- a/public/admin.js +++ b/public/admin.js @@ -215,6 +215,13 @@ $("#visitorList").addEventListener("click", async (event) => { 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() { $("#loginView").classList.add("hidden"); $("#appView").classList.remove("hidden"); @@ -353,7 +360,7 @@ function syncConversationVisitors() { } 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() { @@ -419,7 +426,10 @@ function renderActive() { $("#conversationHeader").innerHTML = `
-

${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();