Add unread chat indicator
This commit is contained in:
@@ -95,6 +95,8 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
}
|
}
|
||||||
.conversation-item.active { background: #eaf6f1; }
|
.conversation-item.active { background: #eaf6f1; }
|
||||||
.conversation-item.fresh { animation: pulse 1.2s ease-in-out 4; }
|
.conversation-item.fresh { animation: pulse 1.2s ease-in-out 4; }
|
||||||
|
.conversation-item.unread { background: #fffdf4; }
|
||||||
|
.conversation-item.unread.active { background: #eaf6f1; }
|
||||||
.conversation-open {
|
.conversation-open {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -108,6 +110,31 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
}
|
}
|
||||||
.conversation-open:hover { filter: none; background: #f3f7f4; }
|
.conversation-open:hover { filter: none; background: #f3f7f4; }
|
||||||
.row { display: flex; justify-content: space-between; gap: 10px; align-items: center; }
|
.row { display: flex; justify-content: space-between; gap: 10px; align-items: center; }
|
||||||
|
.visitor-row { min-width: 0; }
|
||||||
|
.visitor-name-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.visitor-name-wrap strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.unread-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #c9d4ce;
|
||||||
|
}
|
||||||
|
.conversation-item.unread .unread-dot {
|
||||||
|
background: #ff3b30;
|
||||||
|
box-shadow: 0 0 0 0 rgb(255 59 48 / 55%);
|
||||||
|
animation: unreadPulse 1.25s ease-out infinite;
|
||||||
|
}
|
||||||
.status {
|
.status {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -492,6 +519,12 @@ pre {
|
|||||||
50% { background: #dff7eb; }
|
50% { background: #dff7eb; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes unreadPulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgb(255 59 48 / 55%); }
|
||||||
|
70% { box-shadow: 0 0 0 8px rgb(255 59 48 / 0%); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgb(255 59 48 / 0%); }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
body { overflow: auto; }
|
body { overflow: auto; }
|
||||||
.app { height: auto; min-height: 100vh; grid-template-columns: 1fr; overflow: visible; }
|
.app { height: auto; min-height: 100vh; grid-template-columns: 1fr; overflow: visible; }
|
||||||
|
|||||||
+10
-3
@@ -199,6 +199,8 @@ async function openConversation(id) {
|
|||||||
state.activeId = id;
|
state.activeId = id;
|
||||||
const data = await api(`/api/admin/conversations/${id}`);
|
const data = await api(`/api/admin/conversations/${id}`);
|
||||||
state.active = data;
|
state.active = data;
|
||||||
|
const index = state.conversations.findIndex((item) => item.id === id);
|
||||||
|
if (index >= 0) state.conversations[index] = { ...state.conversations[index], ...data.conversation };
|
||||||
renderConversations();
|
renderConversations();
|
||||||
renderActive();
|
renderActive();
|
||||||
}
|
}
|
||||||
@@ -246,10 +248,15 @@ function renderSite() {
|
|||||||
function renderConversations() {
|
function renderConversations() {
|
||||||
const conversations = filteredConversations();
|
const conversations = filteredConversations();
|
||||||
$("#conversationList").innerHTML = conversations.length ? conversations.map((item) => `
|
$("#conversationList").innerHTML = conversations.length ? conversations.map((item) => `
|
||||||
<article class="conversation-item ${item.id === state.activeId ? "active" : ""} ${item.status === "new" ? "fresh" : ""}">
|
<article class="conversation-item ${item.id === state.activeId ? "active" : ""} ${item.hasUnread ? "unread fresh" : ""}">
|
||||||
<button class="conversation-open" type="button" data-open="${item.id}">
|
<button class="conversation-open" type="button" data-open="${item.id}">
|
||||||
<span class="row"><strong>${escapeHtml(item.visitor.name || item.visitor.email || "Navstevnik")}</strong>${item.imageCount ? `<span class="photo-count">foto ${item.imageCount}</span>` : ""}</span>
|
<span class="row visitor-row">
|
||||||
<span class="preview">${escapeHtml(item.lastBody || "")}</span>
|
<span class="visitor-name-wrap">
|
||||||
|
<span class="unread-dot" aria-label="${item.hasUnread ? "Neprecteno" : "Precteno"}"></span>
|
||||||
|
<strong>${escapeHtml(item.visitor.name || item.visitor.email || "Navstevnik")}</strong>
|
||||||
|
</span>
|
||||||
|
${item.imageCount ? `<span class="photo-count">foto ${item.imageCount}</span>` : ""}
|
||||||
|
</span>
|
||||||
<span class="visitor-facts">
|
<span class="visitor-facts">
|
||||||
<span title="Zeme">${visitorCountry(item)}</span>
|
<span title="Zeme">${visitorCountry(item)}</span>
|
||||||
<span title="Doba na webu">${formatDuration(item.sessionSeconds)}</span>
|
<span title="Doba na webu">${formatDuration(item.sessionSeconds)}</span>
|
||||||
|
|||||||
@@ -162,6 +162,16 @@ function initDb() {
|
|||||||
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);
|
||||||
`);
|
`);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureColumn(table, column, definition) {
|
||||||
|
const exists = db.prepare(`PRAGMA table_info(${table})`).all().some((item) => item.name === column);
|
||||||
|
if (exists) return false;
|
||||||
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedDefaults() {
|
function seedDefaults() {
|
||||||
@@ -261,6 +271,12 @@ function adminConversations(res) {
|
|||||||
(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,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM messages unread
|
||||||
|
WHERE unread.conversation_id = c.id
|
||||||
|
AND unread.sender_type = 'visitor'
|
||||||
|
AND datetime(unread.created_at) > datetime(COALESCE(c.admin_seen_at, '1970-01-01 00:00:00'))
|
||||||
|
) AS has_unread,
|
||||||
(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
|
||||||
@@ -273,7 +289,8 @@ function adminConversations(res) {
|
|||||||
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" });
|
||||||
return json(res, 200, conversationDetails(conversation));
|
markConversationSeen(conversation.id);
|
||||||
|
return json(res, 200, conversationDetails(findConversation(conversation.public_id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSite(req, res) {
|
async function updateSite(req, res) {
|
||||||
@@ -377,7 +394,7 @@ async function createConversation(req, res) {
|
|||||||
});
|
});
|
||||||
const insert = insertConversation();
|
const insert = insertConversation();
|
||||||
|
|
||||||
const conversation = db.prepare("SELECT * FROM conversations WHERE id = ?").get(insert);
|
const conversation = findConversation(publicId);
|
||||||
publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) });
|
publish("admin", { type: "conversation:new", conversation: formatConversationSummary(conversation) });
|
||||||
publishConversation(conversation, { type: "message:new", payload: conversationDetails(conversation) });
|
publishConversation(conversation, { type: "message:new", payload: conversationDetails(conversation) });
|
||||||
return json(res, 201, conversationDetails(conversation));
|
return json(res, 201, conversationDetails(conversation));
|
||||||
@@ -392,6 +409,7 @@ async function createVisitorMessage(req, res, url) {
|
|||||||
const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message).trim());
|
const messageId = insertMessage(conversation.id, "visitor", conversation.visitor_name || "Visitor", String(body.message).trim());
|
||||||
saveAttachments(conversation.id, messageId, body.attachments || []);
|
saveAttachments(conversation.id, messageId, body.attachments || []);
|
||||||
updateVisitorContext(conversation.id, body);
|
updateVisitorContext(conversation.id, body);
|
||||||
|
markConversationUnread(conversation.id);
|
||||||
bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status);
|
bumpConversation(conversation.id, conversation.status === "resolved" ? "open" : conversation.status);
|
||||||
const updated = findConversation(conversation.public_id);
|
const updated = findConversation(conversation.public_id);
|
||||||
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
|
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
|
||||||
@@ -407,6 +425,7 @@ async function createAdminMessage(req, res, url) {
|
|||||||
const messageId = insertMessage(conversation.id, "admin", req.adminUser.username, String(body.message || "").trim());
|
const messageId = insertMessage(conversation.id, "admin", req.adminUser.username, String(body.message || "").trim());
|
||||||
saveAttachments(conversation.id, messageId, body.attachments || []);
|
saveAttachments(conversation.id, messageId, body.attachments || []);
|
||||||
bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status);
|
bumpConversation(conversation.id, conversation.status === "new" ? "open" : conversation.status);
|
||||||
|
markConversationSeen(conversation.id);
|
||||||
const updated = findConversation(conversation.public_id);
|
const updated = findConversation(conversation.public_id);
|
||||||
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
|
publishConversation(updated, { type: "message:new", payload: conversationDetails(updated) });
|
||||||
return json(res, 201, conversationDetails(updated));
|
return json(res, 201, conversationDetails(updated));
|
||||||
@@ -575,6 +594,7 @@ function formatConversationSummary(row) {
|
|||||||
lastBody: row.last_body,
|
lastBody: row.last_body,
|
||||||
messageCount: row.message_count,
|
messageCount: row.message_count,
|
||||||
imageCount: row.image_count || 0,
|
imageCount: row.image_count || 0,
|
||||||
|
hasUnread: Boolean(row.has_unread),
|
||||||
pageCount: pageCount(row.browsing_history_json),
|
pageCount: pageCount(row.browsing_history_json),
|
||||||
sessionSeconds: sessionSeconds(row.browsing_history_json, row.created_at),
|
sessionSeconds: sessionSeconds(row.browsing_history_json, row.created_at),
|
||||||
visitorConversationCount: row.visitor_conversation_count || 1,
|
visitorConversationCount: row.visitor_conversation_count || 1,
|
||||||
@@ -622,6 +642,15 @@ function getDefaultSite() {
|
|||||||
function findConversation(publicId) {
|
function findConversation(publicId) {
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
SELECT c.*, s.site_key,
|
SELECT c.*, s.site_key,
|
||||||
|
(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,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM messages unread
|
||||||
|
WHERE unread.conversation_id = c.id
|
||||||
|
AND unread.sender_type = 'visitor'
|
||||||
|
AND datetime(unread.created_at) > datetime(COALESCE(c.admin_seen_at, '1970-01-01 00:00:00'))
|
||||||
|
) AS has_unread,
|
||||||
(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
|
||||||
@@ -629,6 +658,14 @@ function findConversation(publicId) {
|
|||||||
`).get(publicId);
|
`).get(publicId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markConversationSeen(conversationId) {
|
||||||
|
db.prepare("UPDATE conversations SET admin_seen_at = CURRENT_TIMESTAMP WHERE id = ?").run(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markConversationUnread(conversationId) {
|
||||||
|
db.prepare("UPDATE conversations SET admin_seen_at = NULL WHERE id = ?").run(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
function publicIdFrom(url) {
|
function publicIdFrom(url) {
|
||||||
const parts = url.pathname.split("/").filter(Boolean);
|
const parts = url.pathname.split("/").filter(Boolean);
|
||||||
return parts[parts.indexOf("conversations") + 1];
|
return parts[parts.indexOf("conversations") + 1];
|
||||||
|
|||||||
Reference in New Issue
Block a user