Add live visitor monitoring
This commit is contained in:
+79
-1
@@ -61,7 +61,7 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 330px minmax(0, 1fr);
|
||||
grid-template-columns: 330px minmax(0, 1fr) 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar {
|
||||
@@ -358,6 +358,83 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.visitor-panel {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.visitor-panel header {
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.visitor-panel h2 { margin: 0; font-size: 18px; }
|
||||
.visitor-panel header span { color: var(--muted); font-size: 12px; }
|
||||
.visitor-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.visitor-card {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: white;
|
||||
}
|
||||
.visitor-card-head {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 10px;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.visitor-card-head strong,
|
||||
.visitor-card-head small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.visitor-card-head small { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.visitor-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #eaf6f1;
|
||||
color: var(--brand);
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
}
|
||||
.online-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #2f9e44;
|
||||
box-shadow: 0 0 0 4px rgb(47 158 68 / 14%);
|
||||
}
|
||||
.visitor-card dl {
|
||||
display: grid;
|
||||
grid-template-columns: 68px minmax(0, 1fr);
|
||||
gap: 5px 8px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.visitor-card dt { color: var(--muted); }
|
||||
.visitor-card dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.visitor-card a { color: var(--ink); text-decoration: none; }
|
||||
.visitor-card a:hover { color: var(--brand); text-decoration: underline; }
|
||||
.message {
|
||||
max-width: min(680px, 86%);
|
||||
padding: 10px 12px;
|
||||
@@ -599,4 +676,5 @@ pre {
|
||||
.app { height: auto; min-height: 100vh; grid-template-columns: 1fr; overflow: visible; }
|
||||
.sidebar { height: 42vh; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.conversation { height: 58vh; }
|
||||
.visitor-panel { display: none; }
|
||||
}
|
||||
|
||||
@@ -74,6 +74,16 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="visitor-panel">
|
||||
<header>
|
||||
<h2>Navstevnici</h2>
|
||||
<span id="visitorCount">0 online</span>
|
||||
</header>
|
||||
<div id="visitorList" class="visitor-list">
|
||||
<p class="empty-list">Zatim nikdo online.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside id="settingsPanel" class="settings hidden">
|
||||
<div class="settings-head">
|
||||
<h2>Nastaveni widgetu</h2>
|
||||
|
||||
+67
-1
@@ -2,6 +2,7 @@ const state = {
|
||||
site: null,
|
||||
snippet: "",
|
||||
conversations: [],
|
||||
visitors: [],
|
||||
activeId: null,
|
||||
active: null,
|
||||
events: null,
|
||||
@@ -202,7 +203,7 @@ async function showApp() {
|
||||
$("#loginView").classList.add("hidden");
|
||||
$("#appView").classList.remove("hidden");
|
||||
await loadBootstrap();
|
||||
await loadConversations();
|
||||
await Promise.all([loadConversations(), loadVisitors()]);
|
||||
connectEvents();
|
||||
}
|
||||
|
||||
@@ -224,6 +225,12 @@ async function loadConversations() {
|
||||
renderConversations();
|
||||
}
|
||||
|
||||
async function loadVisitors() {
|
||||
const data = await api("/api/admin/visitors");
|
||||
state.visitors = data.visitors;
|
||||
renderVisitors();
|
||||
}
|
||||
|
||||
async function openConversation(id) {
|
||||
stopAttention();
|
||||
state.activeId = id;
|
||||
@@ -247,6 +254,11 @@ function connectEvents() {
|
||||
renderSite();
|
||||
return;
|
||||
}
|
||||
if (data.type === "visitors:update") {
|
||||
state.visitors = data.visitors;
|
||||
renderVisitors();
|
||||
return;
|
||||
}
|
||||
await loadConversations();
|
||||
if (data.type === "conversation:delete" && data.conversationId === state.activeId) {
|
||||
clearActiveConversation();
|
||||
@@ -305,6 +317,60 @@ function renderConversations() {
|
||||
`).join("") : `<p class="empty-list">Zadny chat pro vybrany filtr.</p>`;
|
||||
}
|
||||
|
||||
function renderVisitors() {
|
||||
$("#visitorCount").textContent = `${state.visitors.length} online`;
|
||||
$("#visitorList").innerHTML = state.visitors.length ? state.visitors.map((visitor) => `
|
||||
<article class="visitor-card">
|
||||
<div class="visitor-card-head">
|
||||
<span class="visitor-avatar">${escapeHtml(visitor.initials || "??")}</span>
|
||||
<div>
|
||||
<strong>${escapeHtml(visitor.label || "Navstevnik")}</strong>
|
||||
<small>${escapeHtml(browserLabel(visitor.device))}</small>
|
||||
</div>
|
||||
<span class="online-dot" title="Online"></span>
|
||||
</div>
|
||||
<dl>
|
||||
<dt>Zeme</dt>
|
||||
<dd>${visitor.countryFlag || ""} ${escapeHtml(visitor.countryCode || "?")}</dd>
|
||||
<dt>Stranka</dt>
|
||||
<dd><a href="${escapeHtml(visitor.currentUrl || "#")}" target="_blank" rel="noreferrer">${escapeHtml(pageTitle(visitor))}</a></dd>
|
||||
<dt>Prisiel z</dt>
|
||||
<dd>${visitor.referrer ? `<a href="${escapeHtml(visitor.referrer)}" target="_blank" rel="noreferrer">${escapeHtml(referrerLabel(visitor.referrer))}</a>` : "primy vstup"}</dd>
|
||||
<dt>Cas</dt>
|
||||
<dd>${formatDuration(visitor.sessionSeconds)}</dd>
|
||||
<dt>Navstevy</dt>
|
||||
<dd>${Number(visitor.visitCount || visitor.pageCount || 1)}</dd>
|
||||
<dt>Chaty</dt>
|
||||
<dd>${Number(visitor.conversationCount || 0)}</dd>
|
||||
</dl>
|
||||
</article>
|
||||
`).join("") : `<p class="empty-list">Zatim nikdo online.</p>`;
|
||||
}
|
||||
|
||||
function pageTitle(visitor) {
|
||||
const history = Array.isArray(visitor.browsingHistory) ? visitor.browsingHistory : [];
|
||||
const current = history.find((item) => item.url === visitor.currentUrl) || history[history.length - 1];
|
||||
return current?.title || shortUrl(visitor.currentUrl) || "neznamá stranka";
|
||||
}
|
||||
|
||||
function referrerLabel(value) {
|
||||
try {
|
||||
return new URL(value).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return shortUrl(value) || value;
|
||||
}
|
||||
}
|
||||
|
||||
function browserLabel(device = {}) {
|
||||
const ua = String(device.userAgent || "");
|
||||
const browser = ua.includes("Edg/") ? "Edge"
|
||||
: ua.includes("Chrome/") ? "Chrome"
|
||||
: ua.includes("Firefox/") ? "Firefox"
|
||||
: ua.includes("Safari/") ? "Safari"
|
||||
: "Browser";
|
||||
return [device.platform || "zarizeni", browser].filter(Boolean).join(" - ");
|
||||
}
|
||||
|
||||
function renderActive() {
|
||||
const { conversation, messages } = state.active;
|
||||
$("#conversationHeader").innerHTML = `
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
});
|
||||
|
||||
rememberPage();
|
||||
pingVisitor();
|
||||
setInterval(pingVisitor, 25_000);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) pingVisitor();
|
||||
});
|
||||
if (session.conversationId) {
|
||||
loadExistingConversation(messages).finally(() => connectEvents(messages, typing, panel, launcher, settings));
|
||||
}
|
||||
@@ -345,6 +350,10 @@
|
||||
};
|
||||
}
|
||||
|
||||
function pingVisitor() {
|
||||
postJson(`${apiBase}/api/widget/visitors/ping`, withVisitorContext({})).catch(() => {});
|
||||
}
|
||||
|
||||
function showFormError(root, message) {
|
||||
const note = root.querySelector(".mf-note");
|
||||
note.textContent = message;
|
||||
|
||||
Reference in New Issue
Block a user