Tighten admin visitor facts and add widget branding
This commit is contained in:
+15
-14
@@ -117,21 +117,22 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.snippet, .preview, .meta { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.snippet, .preview, .meta { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.list-photo {
|
.visitor-facts {
|
||||||
grid-column: 1 / -1;
|
display: flex;
|
||||||
width: 100%;
|
flex-wrap: wrap;
|
||||||
height: 74px;
|
gap: 4px;
|
||||||
padding: 0;
|
color: var(--muted);
|
||||||
border: 1px solid var(--line);
|
font-size: 11px;
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #edf3ef;
|
|
||||||
}
|
}
|
||||||
.list-photo img {
|
.visitor-facts span {
|
||||||
width: 100%;
|
max-width: 96px;
|
||||||
height: 100%;
|
overflow: hidden;
|
||||||
display: block;
|
text-overflow: ellipsis;
|
||||||
object-fit: cover;
|
white-space: nowrap;
|
||||||
|
border: 1px solid #e4ebe6;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: #f8fbf9;
|
||||||
}
|
}
|
||||||
.conversation-actions {
|
.conversation-actions {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
|||||||
+18
-2
@@ -192,9 +192,13 @@ function renderConversations() {
|
|||||||
<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"><strong>${escapeHtml(item.visitor.name || item.visitor.email || "Navstevnik")}</strong>${item.imageCount ? `<span class="photo-count">foto ${item.imageCount}</span>` : ""}</span>
|
||||||
<span class="preview">${escapeHtml(item.lastBody || "")}</span>
|
<span class="preview">${escapeHtml(item.lastBody || "")}</span>
|
||||||
<span class="meta">${flag(item.countryCode)} ${escapeHtml(item.language || "")} ${escapeHtml(item.currentUrl || "")}</span>
|
<span class="visitor-facts">
|
||||||
|
<span title="Zeme">${visitorCountry(item)}</span>
|
||||||
|
<span title="Doba na webu">${formatDuration(item.sessionSeconds)}</span>
|
||||||
|
<span title="Navstivene stranky">${Number(item.pageCount || 0)} str.</span>
|
||||||
|
<span title="Pocet chatu">${Number(item.visitorConversationCount || 1)}x chat</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
${item.firstImageUrl ? `<button class="list-photo" type="button" data-photo-url="${escapeHtml(item.firstImageUrl)}" data-photo-name="Fotka v chatu"><img src="${escapeHtml(item.firstImageUrl)}" alt="Fotka v chatu"></button>` : ""}
|
|
||||||
<div class="conversation-actions">
|
<div class="conversation-actions">
|
||||||
<select class="status-select ${item.status}" data-status-for="${item.id}" aria-label="Zmenit stav chatu">
|
<select class="status-select ${item.status}" data-status-for="${item.id}" aria-label="Zmenit stav chatu">
|
||||||
${statusOptions(item.status)}
|
${statusOptions(item.status)}
|
||||||
@@ -242,6 +246,18 @@ function statusOptions(currentStatus) {
|
|||||||
].map(([value, label]) => `<option value="${value}" ${value === currentStatus ? "selected" : ""}>${label}</option>`).join("");
|
].map(([value, label]) => `<option value="${value}" ${value === currentStatus ? "selected" : ""}>${label}</option>`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visitorCountry(item) {
|
||||||
|
return item.countryCode ? `${flag(item.countryCode)} ${escapeHtml(item.countryCode)}` : "zeme ?";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const value = Number(seconds || 0);
|
||||||
|
if (value < 60) return `${Math.max(0, value)}s`;
|
||||||
|
const minutes = Math.floor(value / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||||||
|
}
|
||||||
|
|
||||||
async function updateConversationStatus(id, status) {
|
async function updateConversationStatus(id, status) {
|
||||||
await api(`/api/admin/conversations/${id}/status`, {
|
await api(`/api/admin/conversations/${id}/status`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="mf-attachment-list"></div>
|
<div class="mf-attachment-list"></div>
|
||||||
<p class="mf-note">Odeslanim zpravy nam predavate udaje potrebne pro odpoved.</p>
|
<p class="mf-note">Odeslanim zpravy nam predavate udaje potrebne pro odpoved.</p>
|
||||||
|
<a class="mf-powered" href="https://app.black-week.cz" target="_blank" rel="noopener noreferrer">Powered by MaalFlows</a>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
@@ -276,6 +277,8 @@
|
|||||||
.mf-attachment-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 210px; }
|
.mf-attachment-chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 210px; }
|
||||||
.mf-attachment-chip button { width: 22px; height: 22px; border: 0; border-radius: 50%; background: #e8eee9; color: ${c.text}; cursor: pointer; padding: 0; }
|
.mf-attachment-chip button { width: 22px; height: 22px; border: 0; border-radius: 50%; background: #e8eee9; color: ${c.text}; cursor: pointer; padding: 0; }
|
||||||
.mf-note { margin: 0; color: ${c.muted}; font-size: 11px; line-height: 1.3; }
|
.mf-note { margin: 0; color: ${c.muted}; font-size: 11px; line-height: 1.3; }
|
||||||
|
.mf-powered { justify-self: center; color: ${c.muted}; font-size: 11px; text-decoration: none; }
|
||||||
|
.mf-powered:hover { color: ${c.brand}; text-decoration: underline; }
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.mf-launcher { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx}px; }
|
.mf-launcher { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx}px; }
|
||||||
.mf-panel { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx + 68}px; width: min(${mobile.widthPx}px, calc(100vw - 24px)); max-height: calc(100vh - 96px); }
|
.mf-panel { ${mobile.side}: ${mobile.sideOffsetPx}px; bottom: ${mobile.bottomPx + 68}px; width: min(${mobile.widthPx}px, calc(100vw - 24px)); max-height: calc(100vh - 96px); }
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ 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,
|
||||||
(SELECT public_url FROM attachments WHERE conversation_id = c.id AND mime_type LIKE 'image/%' ORDER BY id DESC LIMIT 1) AS first_image_url
|
(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
|
||||||
ORDER BY datetime(c.last_message_at) DESC, c.id DESC
|
ORDER BY datetime(c.last_message_at) DESC, c.id DESC
|
||||||
@@ -523,7 +523,9 @@ 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,
|
||||||
firstImageUrl: row.first_image_url,
|
pageCount: pageCount(row.browsing_history_json),
|
||||||
|
sessionSeconds: sessionSeconds(row.browsing_history_json, row.created_at),
|
||||||
|
visitorConversationCount: row.visitor_conversation_count || 1,
|
||||||
lastMessageAt: row.last_message_at,
|
lastMessageAt: row.last_message_at,
|
||||||
createdAt: row.created_at
|
createdAt: row.created_at
|
||||||
};
|
};
|
||||||
@@ -567,7 +569,8 @@ 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 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
|
||||||
WHERE c.public_id = ?
|
WHERE c.public_id = ?
|
||||||
@@ -665,6 +668,20 @@ function parseJson(value, fallback) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pageCount(historyJson) {
|
||||||
|
return new Set(parseJson(historyJson, []).map((item) => item.url).filter(Boolean)).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionSeconds(historyJson, createdAt) {
|
||||||
|
const history = parseJson(historyJson, []);
|
||||||
|
const times = history
|
||||||
|
.map((item) => Date.parse(item.at))
|
||||||
|
.filter((value) => Number.isFinite(value));
|
||||||
|
if (times.length > 1) return Math.max(0, Math.round((Math.max(...times) - Math.min(...times)) / 1000));
|
||||||
|
const created = Date.parse(`${createdAt}Z`);
|
||||||
|
return Number.isFinite(created) ? Math.max(0, Math.round((Date.now() - created) / 1000)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function id(prefix) {
|
function id(prefix) {
|
||||||
return `${prefix}_${crypto.randomBytes(12).toString("hex")}`;
|
return `${prefix}_${crypto.randomBytes(12).toString("hex")}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user