Improve admin visitor context layout
This commit is contained in:
+102
-8
@@ -10,7 +10,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; background: var(--bg); color: var(--ink); }
|
body { margin: 0; background: var(--bg); color: var(--ink); overflow: hidden; }
|
||||||
button, input, textarea, select { font: inherit; }
|
button, input, textarea, select { font: inherit; }
|
||||||
button {
|
button {
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -47,15 +47,19 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
.error { min-height: 20px; color: #b42318; margin: 0; }
|
.error { min-height: 20px; color: #b42318; margin: 0; }
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 330px minmax(0, 1fr);
|
grid-template-columns: 330px minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sidebar {
|
.sidebar {
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto auto minmax(0, 1fr);
|
grid-template-rows: auto auto auto minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.brand, .sitebar {
|
.brand, .sitebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -76,7 +80,7 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
}
|
}
|
||||||
.list-tools label { color: var(--muted); }
|
.list-tools label { color: var(--muted); }
|
||||||
|
|
||||||
.conversation-list { overflow: auto; }
|
.conversation-list { overflow: auto; min-height: 0; }
|
||||||
.conversation-item {
|
.conversation-item {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
@@ -173,10 +177,12 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation { display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto; min-width: 0; }
|
.conversation { display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto; min-width: 0; min-height: 0; overflow: hidden; }
|
||||||
.conversation-header {
|
.conversation-header {
|
||||||
min-height: 74px;
|
min-height: 74px;
|
||||||
padding: 16px 20px;
|
max-height: 42vh;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 14px 20px;
|
||||||
background: white;
|
background: white;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -186,8 +192,95 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
|||||||
.conversation-header h2 { margin: 0 0 5px; font-size: 20px; }
|
.conversation-header h2 { margin: 0 0 5px; font-size: 20px; }
|
||||||
.conversation-header p { margin: 0; color: var(--muted); font-size: 13px; }
|
.conversation-header p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||||
.conversation-header select { width: 150px; align-self: start; }
|
.conversation-header select { width: 150px; align-self: start; }
|
||||||
|
.visitor-head { width: 100%; min-width: 0; display: grid; gap: 7px; }
|
||||||
|
.visitor-title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.visitor-title-row h2 { margin: 0; }
|
||||||
|
.country-pill {
|
||||||
|
border: 1px solid #e4ebe6;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: #f8fbf9;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.visitor-quick {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.visitor-quick span {
|
||||||
|
border: 1px solid #e4ebe6;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: #f8fbf9;
|
||||||
|
}
|
||||||
|
.visitor-details {
|
||||||
|
max-width: 980px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.visitor-details summary {
|
||||||
|
width: fit-content;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.visitor-details summary::-webkit-details-marker { display: none; }
|
||||||
|
.visitor-details summary span {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--brand);
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.visitor-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
|
gap: 5px 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.visitor-detail-grid span { color: var(--muted); }
|
||||||
|
.visitor-detail-grid a,
|
||||||
|
.history-list a {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--brand);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.visitor-detail-grid em {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: normal;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.history-list {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.history-list li {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.history-list small { color: var(--muted); }
|
||||||
.messages {
|
.messages {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -400,7 +493,8 @@ pre {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
.app { grid-template-columns: 1fr; }
|
body { overflow: auto; }
|
||||||
.sidebar { min-height: 42vh; border-right: 0; border-bottom: 1px solid var(--line); }
|
.app { height: auto; min-height: 100vh; grid-template-columns: 1fr; overflow: visible; }
|
||||||
.conversation { min-height: 58vh; }
|
.sidebar { height: 42vh; border-right: 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.conversation { height: 58vh; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@
|
|||||||
<option value="new">nove</option>
|
<option value="new">nove</option>
|
||||||
<option value="open">otevrene</option>
|
<option value="open">otevrene</option>
|
||||||
<option value="resolved">vyresene</option>
|
<option value="resolved">vyresene</option>
|
||||||
<option value="spam">spam</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+77
-5
@@ -270,10 +270,17 @@ function renderConversations() {
|
|||||||
function renderActive() {
|
function renderActive() {
|
||||||
const { conversation, messages } = state.active;
|
const { conversation, messages } = state.active;
|
||||||
$("#conversationHeader").innerHTML = `
|
$("#conversationHeader").innerHTML = `
|
||||||
<div>
|
<div class="visitor-head">
|
||||||
<h2>${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}</h2>
|
<div class="visitor-title-row">
|
||||||
<p>${visitorCountry(conversation)} ${escapeHtml(conversation.currentUrl || "")}</p>
|
<h2>${escapeHtml(conversation.visitor.name || conversation.visitor.email || "Navstevnik")}</h2>
|
||||||
<p>${escapeHtml(conversation.device.userAgent || "")}</p>
|
<span class="country-pill">${visitorCountry(conversation)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="visitor-quick">
|
||||||
|
<span title="Doba na webu">${formatDuration(conversation.sessionSeconds)}</span>
|
||||||
|
<span title="Navstivene stranky">${Number(conversation.pageCount || 0)} str.</span>
|
||||||
|
<span title="Pocet chatu">${Number(conversation.visitorConversationCount || 1)}x chat</span>
|
||||||
|
</div>
|
||||||
|
${visitorDetails(conversation)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
$("#replyForm").classList.remove("hidden");
|
$("#replyForm").classList.remove("hidden");
|
||||||
@@ -306,10 +313,37 @@ function statusButtons(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function visitorCountry(item) {
|
function visitorCountry(item) {
|
||||||
const countryCode = item.countryCode || countryFromLanguage(item.language);
|
const countryCode = item.countryCode || countryFromLanguage(item.language) || countryFromTimezone(item.timezone);
|
||||||
return countryCode ? `${flag(countryCode)} ${escapeHtml(countryCode)}` : "zeme ?";
|
return countryCode ? `${flag(countryCode)} ${escapeHtml(countryCode)}` : "zeme ?";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visitorDetails(conversation) {
|
||||||
|
const history = Array.isArray(conversation.browsingHistory) ? conversation.browsingHistory : [];
|
||||||
|
const historyItems = history.length ? history.slice().reverse().map((item) => `
|
||||||
|
<li>
|
||||||
|
<a href="${escapeHtml(item.url || "#")}" target="_blank" rel="noreferrer">${escapeHtml(pageLabel(item))}</a>
|
||||||
|
<small>${escapeHtml(formatVisitedAt(item.at))}</small>
|
||||||
|
</li>
|
||||||
|
`).join("") : `<li><span>Zatim bez historie.</span></li>`;
|
||||||
|
return `
|
||||||
|
<details class="visitor-details">
|
||||||
|
<summary title="Detail navstevnika">
|
||||||
|
<span aria-hidden="true">i</span>
|
||||||
|
<strong>Detail navstevnika</strong>
|
||||||
|
</summary>
|
||||||
|
<div class="visitor-detail-grid">
|
||||||
|
<span>Aktualne</span>
|
||||||
|
<a href="${escapeHtml(conversation.currentUrl || "#")}" target="_blank" rel="noreferrer">${escapeHtml(shortUrl(conversation.currentUrl) || "-")}</a>
|
||||||
|
<span>Prisiel z</span>
|
||||||
|
${conversation.referrer ? `<a href="${escapeHtml(conversation.referrer)}" target="_blank" rel="noreferrer">${escapeHtml(shortUrl(conversation.referrer))}</a>` : `<em>primy vstup / nezname</em>`}
|
||||||
|
<span>Zarizeni</span>
|
||||||
|
<em>${escapeHtml(deviceLabel(conversation.device, conversation.language, conversation.timezone))}</em>
|
||||||
|
</div>
|
||||||
|
<ol class="history-list">${historyItems}</ol>
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(seconds) {
|
function formatDuration(seconds) {
|
||||||
const value = Number(seconds || 0);
|
const value = Number(seconds || 0);
|
||||||
if (value < 60) return `${Math.max(0, value)}s`;
|
if (value < 60) return `${Math.max(0, value)}s`;
|
||||||
@@ -415,6 +449,44 @@ function countryFromLanguage(language) {
|
|||||||
return match ? match[1].toUpperCase() : null;
|
return match ? match[1].toUpperCase() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countryFromTimezone(timezone) {
|
||||||
|
const zones = {
|
||||||
|
"Europe/Prague": "CZ",
|
||||||
|
"Europe/Bratislava": "SK",
|
||||||
|
"Europe/Warsaw": "PL",
|
||||||
|
"Europe/Berlin": "DE",
|
||||||
|
"Europe/Vienna": "AT",
|
||||||
|
"Europe/Budapest": "HU"
|
||||||
|
};
|
||||||
|
return zones[String(timezone || "")] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortUrl(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
const path = `${url.pathname}${url.search}`.replace(/\/$/, "");
|
||||||
|
return `${url.hostname}${path || "/"}`;
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageLabel(item) {
|
||||||
|
return item.title || shortUrl(item.url) || item.url || "stranka";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVisitedAt(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
return date.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceLabel(device = {}, language, timezone) {
|
||||||
|
return [device.platform, device.viewport, language, timezone].filter(Boolean).join(" | ");
|
||||||
|
}
|
||||||
|
|
||||||
function openPhotoPreview(url, name) {
|
function openPhotoPreview(url, name) {
|
||||||
let modal = $("#photoModal");
|
let modal = $("#photoModal");
|
||||||
if (!modal) {
|
if (!modal) {
|
||||||
|
|||||||
@@ -570,7 +570,7 @@ function formatConversationSummary(row) {
|
|||||||
language: row.language,
|
language: row.language,
|
||||||
timezone: row.timezone,
|
timezone: row.timezone,
|
||||||
ip: row.ip,
|
ip: row.ip,
|
||||||
countryCode: row.country_code,
|
countryCode: row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language),
|
||||||
vpnRisk: Boolean(row.vpn_risk),
|
vpnRisk: Boolean(row.vpn_risk),
|
||||||
lastBody: row.last_body,
|
lastBody: row.last_body,
|
||||||
messageCount: row.message_count,
|
messageCount: row.message_count,
|
||||||
@@ -750,6 +750,23 @@ function countryFromHeaders(req) {
|
|||||||
return req.headers["cf-ipcountry"] || req.headers["x-vercel-ip-country"] || null;
|
return req.headers["cf-ipcountry"] || req.headers["x-vercel-ip-country"] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countryFromTimezone(timezone) {
|
||||||
|
const zones = {
|
||||||
|
"Europe/Prague": "CZ",
|
||||||
|
"Europe/Bratislava": "SK",
|
||||||
|
"Europe/Warsaw": "PL",
|
||||||
|
"Europe/Berlin": "DE",
|
||||||
|
"Europe/Vienna": "AT",
|
||||||
|
"Europe/Budapest": "HU"
|
||||||
|
};
|
||||||
|
return zones[String(timezone || "")] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryFromLanguage(language) {
|
||||||
|
const match = String(language || "").match(/-([A-Za-z]{2})\b/);
|
||||||
|
return match ? match[1].toUpperCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
function isAllowedImageAttachment(attachment, buffer) {
|
function isAllowedImageAttachment(attachment, buffer) {
|
||||||
const extension = path.extname(attachment.name || "").toLowerCase();
|
const extension = path.extname(attachment.name || "").toLowerCase();
|
||||||
const type = String(attachment.type || "").toLowerCase();
|
const type = String(attachment.type || "").toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user