test2 / app /admin /templates /admin.html
joedown11's picture
feat: implement full rating system - empty stars, hover preview, per-thread storage, admin badge
a27005b
Raw
History Blame Contribute Delete
171 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Martechsol — Admin Dashboard</title>
<link rel="icon" href="/martech_sol_logo.jpg" type="image/jpeg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #f8fafc;
--surface: #ffffff;
--surface-hover: #f1f5f9;
--border: #e2e8f0;
--accent: #8b5cf6;
--accent-hover: #7c3aed;
--accent2: #a855f7;
--accent-light: #ede9fe;
--green: #10b981;
--red: #ef4444;
--yellow: #f59e0b;
--text: #0f172a;
--text2: #475569;
--text3: #94a3b8;
--radius: 12px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
html,
body {
height: 100%;
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
/* ── Utilities ── */
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-2 {
gap: 8px;
}
.gap-4 {
gap: 16px;
}
.hidden {
display: none !important;
}
/* ── Login Screen ── */
#login-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
z-index: 9999;
}
.login-card {
width: 380px;
background: var(--surface);
border-radius: 24px;
padding: 48px 40px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border);
animation: fadeUp .5s ease;
}
.login-logo {
text-align: center;
margin-bottom: 32px;
}
.login-logo svg {
width: 56px;
height: 56px;
margin: 0 auto;
display: block;
}
.login-logo h1 {
font-size: 24px;
font-weight: 700;
margin-top: 16px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-logo p {
font-size: 14px;
color: var(--text2);
margin-top: 4px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text2);
text-transform: uppercase;
letter-spacing: .06em;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text);
font-size: 15px;
outline: none;
transition: all .2s;
}
.form-group input:focus {
border-color: var(--accent);
background: #fff;
box-shadow: 0 0 0 3px var(--accent-light);
}
.btn-primary {
width: 100%;
padding: 14px;
border: none;
border-radius: 12px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
transition: all .2s;
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
}
.btn-primary:hover {
opacity: .9;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(139, 92, 246, 0.4);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
width: 100%;
padding: 12px;
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
background: var(--surface);
color: var(--text2);
transition: all .2s;
}
.btn-secondary:hover {
background: var(--surface-hover);
color: var(--text);
}
.login-error {
display: none;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 12px;
padding: 12px;
font-size: 14px;
color: #b91c1c;
margin-top: 16px;
text-align: center;
}
/* ── App Layout ── */
#app {
display: none;
height: 100vh;
width: 100vw;
flex-direction: row;
}
/* ── Resizer ── */
.resizer {
width: 6px;
background: var(--border);
cursor: col-resize;
transition: background 0.2s;
z-index: 20;
}
.resizer:hover,
.resizer.dragging {
background: var(--accent);
}
.btn-icon {
background: transparent;
border: none;
cursor: pointer;
color: var(--text2);
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
transition: background .2s;
position: relative;
}
.btn-icon:hover {
background: var(--surface-hover);
color: var(--text);
}
.notification-badge {
position: absolute;
top: 4px;
right: 4px;
width: 8px;
height: 8px;
background: var(--red);
border-radius: 50%;
border: 2px solid var(--surface);
display: none;
}
.notification-dropdown {
position: absolute;
top: 60px;
right: 32px;
width: 320px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow-lg);
display: none;
flex-direction: column;
z-index: 100;
overflow: hidden;
animation: fadeUp .2s ease;
}
.notification-dropdown.show {
display: flex;
}
.notif-header {
padding: 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
color: var(--text);
font-size: 14px;
}
.notif-list {
max-height: 300px;
overflow-y: auto;
}
.notif-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 4px;
cursor: pointer;
transition: background .2s;
}
.notif-item:hover {
background: var(--surface-hover);
}
.notif-item-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.notif-item-desc {
font-size: 12px;
color: var(--text2);
}
.notif-item-time {
font-size: 10px;
color: var(--text3);
align-self: flex-end;
}
.notif-empty {
padding: 32px 16px;
text-align: center;
color: var(--text3);
font-size: 13px;
}
/* ── Sidebar ── */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 10;
flex: 0 0 280px;
min-width: 200px;
max-width: 600px;
}
.sidebar-header {
padding: 24px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
}
.sidebar-header svg {
width: 32px;
height: 32px;
}
.sidebar-header h2 {
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.nav-menu {
padding: 20px 16px;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 10px;
color: var(--text2);
font-weight: 500;
font-size: 15px;
cursor: pointer;
transition: all .2s;
border: 1px solid transparent;
}
.nav-item svg {
width: 20px;
height: 20px;
opacity: 0.7;
}
.nav-item:hover {
background: var(--surface-hover);
color: var(--text);
}
.nav-item.active {
background: var(--accent-light);
color: var(--accent);
border-color: rgba(139, 92, 246, 0.2);
}
.nav-item.active svg {
opacity: 1;
}
.nav-section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text3);
margin: 20px 0 8px 16px;
}
.quick-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-radius: 8px;
color: var(--text2);
font-size: 13px;
text-decoration: none;
transition: background .2s;
margin-bottom: 4px;
}
.quick-link:hover {
background: var(--surface-hover);
color: var(--accent);
}
.quick-link svg {
width: 14px;
height: 14px;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid var(--border);
}
/* ── Main Area ── */
.main-area {
display: flex;
flex-direction: column;
background: var(--bg);
overflow: hidden;
}
.topbar {
height: 70px;
padding: 0 32px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.topbar-title {
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.view-container {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 32px;
display: none;
}
.view-container.active {
display: flex;
flex-direction: column;
animation: fadeUp .4s ease;
}
/* ── Dashboard View ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.stat-card {
background: var(--surface);
border-radius: 16px;
padding: 24px;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-card-title {
font-size: 14px;
font-weight: 500;
color: var(--text2);
}
.stat-card-value {
font-size: 36px;
font-weight: 700;
color: var(--text);
display: flex;
align-items: baseline;
gap: 8px;
}
.stat-card-value.accent {
color: var(--accent);
}
.chart-container {
background: var(--surface);
border-radius: 16px;
padding: 24px;
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
height: 400px;
width: 100%;
}
/* ── Sessions View ── */
#view-sessions {
padding: 0;
flex-direction: row;
}
.sessions-sidebar {
width: 320px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sessions-header {
padding: 20px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 12px;
}
.search-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg);
font-size: 14px;
outline: none;
}
.search-input:focus {
border-color: var(--accent);
}
.session-actions {
display: flex;
gap: 8px;
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.session-item {
padding: 14px;
border-radius: 12px;
cursor: pointer;
margin-bottom: 8px;
border: 1px solid transparent;
transition: all .2s;
}
.session-item:hover {
background: var(--bg);
border-color: var(--border);
}
.session-item.active {
background: var(--accent-light);
border-color: rgba(139, 92, 246, 0.3);
}
.session-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.session-item-name {
font-weight: 600;
font-size: 14px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-item.active .session-item-name {
color: var(--accent);
}
.session-item-time {
font-size: 11px;
color: var(--text3);
}
.session-item-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text2);
}
.msg-badge {
background: var(--bg);
padding: 2px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
border: 1px solid var(--border);
}
.session-item.active .msg-badge {
background: #fff;
color: var(--accent);
border-color: rgba(139, 92, 246, 0.3);
}
.chat-view {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg);
}
.chat-header {
padding: 20px 32px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.chat-header-info h3 {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
.chat-header-info p {
font-size: 13px;
color: var(--text2);
}
.chat-header-actions {
display: flex;
gap: 12px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 32px;
display: flex;
flex-direction: column;
gap: 24px;
}
.msg-wrapper {
display: flex;
flex-direction: column;
max-width: 80%;
}
.msg-wrapper.user {
align-self: flex-end;
}
.msg-wrapper.bot {
align-self: flex-start;
}
.msg-meta {
font-size: 11px;
color: var(--text3);
margin-bottom: 6px;
display: flex;
gap: 8px;
align-items: center;
}
.msg-wrapper.user .msg-meta {
justify-content: flex-end;
}
.msg-bubble {
padding: 16px 20px;
border-radius: 18px;
font-size: 15px;
line-height: 1.6;
box-shadow: var(--shadow-sm);
white-space: pre-wrap;
word-wrap: break-word;
}
.msg-wrapper.user .msg-bubble {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
border-bottom-right-radius: 4px;
}
.msg-wrapper.bot .msg-bubble {
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
border-bottom-left-radius: 4px;
border: none;
}
.placeholder-view {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text3);
}
.placeholder-view svg {
width: 64px;
height: 64px;
opacity: 0.5;
margin-bottom: 16px;
}
/* ── Permission Dialog ── */
#perm-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
backdrop-filter: blur(6px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn .25s ease;
}
#perm-dialog-overlay.hidden {
display: none;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.perm-card {
background: var(--surface);
border-radius: 24px;
padding: 36px 40px 32px;
width: 440px;
max-width: 95vw;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
animation: slideUp .3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(24px) scale(.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.perm-header {
text-align: center;
margin-bottom: 28px;
}
.perm-header .perm-icon-wrap {
width: 64px;
height: 64px;
border-radius: 18px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
font-size: 28px;
box-shadow: 0 8px 20px rgba(139, 92, 246, 0.35);
}
.perm-header h2 {
font-size: 20px;
font-weight: 700;
color: var(--text);
margin-bottom: 6px;
}
.perm-header p {
font-size: 13px;
color: var(--text2);
line-height: 1.5;
}
.perm-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.perm-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: 14px;
border: 1.5px solid var(--border);
background: var(--bg);
transition: border-color .2s;
}
.perm-row.granted {
border-color: #10b981;
background: #f0fdf4;
}
.perm-row.denied {
border-color: #ef4444;
background: #fef2f2;
}
.perm-row-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--accent-light);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.perm-row.granted .perm-row-icon {
background: #dcfce7;
}
.perm-row.denied .perm-row-icon {
background: #fee2e2;
}
.perm-row-info {
flex: 1;
min-width: 0;
}
.perm-row-info strong {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text);
margin-bottom: 2px;
}
.perm-row-info span {
font-size: 12px;
color: var(--text2);
}
.perm-status {
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 20px;
flex-shrink: 0;
}
.perm-status.ok {
background: #dcfce7;
color: #15803d;
}
.perm-status.bad {
background: #fee2e2;
color: #b91c1c;
}
.perm-status.prompt {
background: var(--accent-light);
color: var(--accent);
}
.perm-grant-btn {
padding: 6px 14px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
transition: opacity .2s, transform .2s;
flex-shrink: 0;
}
.perm-grant-btn:hover {
opacity: .88;
transform: translateY(-1px);
}
.perm-grant-btn:disabled {
opacity: .4;
cursor: not-allowed;
transform: none;
}
.perm-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
#btn-perm-enable-all {
width: 100%;
padding: 14px;
border: none;
border-radius: 12px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
background: linear-gradient(135deg, var(--accent), var(--accent2));
color: #fff;
transition: all .2s;
box-shadow: 0 4px 14px rgba(139, 92, 246, .35);
}
#btn-perm-enable-all:hover {
opacity: .9;
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(139, 92, 246, .45);
}
#btn-perm-skip {
width: 100%;
padding: 11px;
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
background: var(--surface);
color: var(--text2);
transition: all .2s;
}
#btn-perm-skip:hover {
background: var(--surface-hover);
color: var(--text);
}
/* ── Toasts & Modals ── */
.toast {
position: fixed;
bottom: 32px;
right: 32px;
padding: 16px 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
font-size: 14px;
font-weight: 500;
color: var(--text);
box-shadow: var(--shadow-lg);
transform: translateY(20px);
opacity: 0;
transition: all .3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
z-index: 9998;
display: flex;
align-items: center;
gap: 12px;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success {
border-left: 4px solid var(--green);
}
.toast.error {
border-left: 4px solid var(--red);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: all .2s;
}
.modal-overlay.open {
opacity: 1;
visibility: visible;
}
.modal {
background: var(--surface);
border-radius: 20px;
padding: 32px;
width: 420px;
box-shadow: var(--shadow-lg);
transform: scale(0.95);
transition: all .2s;
}
.modal-overlay.open .modal {
transform: scale(1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.modal-header h3 {
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.modal-close {
background: none;
border: none;
cursor: pointer;
color: var(--text3);
padding: 4px;
}
.modal-close:hover {
color: var(--text);
}
.export-option {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 12px;
margin-bottom: 12px;
cursor: pointer;
transition: all .2s;
}
.export-option:hover {
background: var(--bg);
}
.export-option.selected {
border-color: var(--accent);
background: var(--accent-light);
}
.export-option input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.export-option-text strong {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.export-option-text span {
display: block;
font-size: 12px;
color: var(--text2);
margin-top: 4px;
}
.format-btn {
flex: 1;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg);
font-weight: 600;
font-size: 13px;
color: var(--text2);
cursor: pointer;
}
.format-btn.active {
background: var(--surface);
border-color: var(--accent);
color: var(--accent);
box-shadow: var(--shadow-sm);
}
/* ── Animations ── */
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
margin: auto;
}
/* Reply area: hidden by .hidden class, flex when visible */
.chat-reply-area {
display: flex;
}
.chat-reply-area.hidden {
display: none !important;
}
/* Settings view padding */
#view-settings {
padding: 32px;
}
/* Sessions view no padding */
#view-sessions {
padding: 0;
}
/* ── Queued Users Panel ── */
.queue-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px;
margin-top: 24px;
}
.queue-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.queue-panel-title {
font-size: 15px;
font-weight: 600;
color: var(--text1);
display: flex;
align-items: center;
gap: 8px;
}
.queue-panel-title .queue-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
animation: pulse-dot 2s infinite;
}
.queue-panel-count {
font-size: 12px;
font-weight: 600;
color: #f59e0b;
background: rgba(245, 158, 11, 0.12);
padding: 3px 10px;
border-radius: 20px;
}
.queue-items {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
}
.queue-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.queue-item:hover {
border-color: var(--accent);
background: var(--surface-hover);
}
.queue-item-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.queue-item-name {
font-size: 13px;
font-weight: 600;
color: var(--text1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-item-timer {
font-size: 11px;
color: #f59e0b;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.queue-takeover-btn {
padding: 5px 12px;
font-size: 11px;
font-weight: 600;
color: #fff;
background: var(--accent);
border: none;
border-radius: 8px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
flex-shrink: 0;
}
.queue-takeover-btn:hover {
background: var(--accent-hover);
}
.queue-empty {
text-align: center;
padding: 28px 16px;
color: var(--text3);
font-size: 13px;
}
.queue-empty svg {
width: 36px;
height: 36px;
margin-bottom: 8px;
opacity: 0.35;
}
@keyframes queue-flash {
0% {
background: rgba(245, 158, 11, 0.18);
}
100% {
background: var(--bg);
}
}
.queue-item-new {
animation: queue-flash 1.5s ease-out;
}
.queue-badge {
position: absolute;
top: 6px;
right: 6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
font-size: 10px;
font-weight: 700;
color: #fff;
background: #f59e0b;
border-radius: 10px;
display: none;
align-items: center;
justify-content: center;
line-height: 1;
}
/* ── Settings Panel ── */
.settings-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-radius: 9px;
font-size: 13px;
font-weight: 500;
color: var(--text2);
cursor: pointer;
transition: all .15s;
}
.settings-nav-item svg {
opacity: .7;
flex-shrink: 0;
}
.settings-nav-item:hover {
background: var(--surface-hover);
color: var(--text);
}
.settings-nav-item.active {
background: var(--accent-light);
color: var(--accent);
}
.settings-nav-item.active svg {
opacity: 1;
}
.settings-section {
display: none;
}
.settings-section.active {
display: block;
}
.settings-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: var(--surface);
}
.settings-row-icon {
width: 32px;
height: 32px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.settings-toggle {
position: relative;
width: 38px;
height: 22px;
flex-shrink: 0;
cursor: pointer;
}
.settings-toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.settings-toggle-track {
position: absolute;
inset: 0;
border-radius: 11px;
background: var(--border);
transition: background .2s;
}
.settings-toggle input:checked+.settings-toggle-track {
background: var(--accent);
}
.settings-toggle-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: white;
transition: transform .2s;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.settings-toggle input:checked~.settings-toggle-thumb {
transform: translateX(16px);
}
.settings-badge {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
flex-shrink: 0;
background: #dcfce7;
color: #15803d;
}
/* Custom Context Menu */
#custom-context-menu {
position: fixed;
z-index: 10000;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-lg);
padding: 6px;
display: none;
flex-direction: column;
min-width: 160px;
overflow: hidden;
transform-origin: top left;
animation: contextMenuPop 0.15s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes contextMenuPop {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.ctx-menu-item {
padding: 10px 14px;
font-size: 13px;
color: var(--text);
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
border-radius: 8px;
transition: background 0.1s;
}
.ctx-menu-item:hover {
background: var(--surface-hover);
}
.ctx-menu-item.danger {
color: var(--red);
}
.ctx-menu-item.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.ctx-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
.session-item-archived {
opacity: 0.65;
}
</style>
</head>
<body>
<!-- LOGIN SCREEN -->
<div id="login-screen">
<div class="login-card">
<div class="login-logo">
<img src="/martech_sol_logo.jpg" alt="Martechsol Logo"
style="width: 56px; height: 56px; margin-bottom: 16px; border-radius: 12px; object-fit: contain;" />
<h1>Welcome Back</h1>
<p>Sign in to manage your AI agents</p>
</div>
<div id="login-stage-1">
<div class="form-group">
<label>Username</label>
<input type="text" id="inp-user" placeholder="admin" autocomplete="username" />
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="inp-pass" placeholder="••••••••" autocomplete="current-password" />
</div>
<button class="btn-primary" id="btn-login">Sign In</button>
</div>
<div id="login-stage-2" class="hidden">
<div class="form-group">
<label>Security Code (OTP)</label>
<input type="text" id="inp-otp" placeholder="123456" maxlength="6"
style="text-align:center;letter-spacing:8px;font-size:20px;font-weight:700" />
<p id="otp-hint" style="font-size:12px;color:var(--text2);margin-top:12px;text-align:center">Code sent to your
email.</p>
</div>
<button class="btn-primary" id="btn-verify">Verify Code</button>
<button class="btn-secondary" id="btn-back" style="margin-top:12px">Back</button>
</div>
<div class="login-error" id="login-error"></div>
</div>
</div>
<!-- APP CONTAINER -->
<div id="app">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<svg viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="url(#lg)" />
<path d="M10 16h12M16 10v12" stroke="#fff" stroke-width="2.5" stroke-linecap="round" />
</svg>
<h2>Admin Panel</h2>
</div>
<nav class="nav-menu">
<div class="nav-item active" data-view="dashboard" id="nav-dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
Dashboard
</div>
<div class="nav-item" data-view="sla" id="nav-sla">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
SLA Dashboard
</div>
<div class="nav-item" data-view="sessions" id="nav-sessions">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
Live Sessions
</div>
<div class="nav-item" data-view="queued" id="nav-queued" style="position:relative;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
Queued Users
<span class="queue-badge" id="queue-badge">0</span>
</div>
<div class="nav-item" data-view="daily" id="nav-daily">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
Daily Users
</div>
<div class="nav-item" data-view="blocked" id="nav-blocked">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
</svg>
Blocked Users
</div>
<div class="nav-item" data-view="archived" id="nav-archived">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
Archived
</div>
<div class="nav-item" data-view="settings" id="nav-settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z">
</path>
</svg>
Settings
</div>
<div class="nav-section-title">Quick Links</div>
<a href="/widget" target="_blank" class="quick-link">
Chat Widget Demo
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</a>
</nav>
<div class="sidebar-footer">
<button class="btn-secondary" id="btn-logout" style="width:100%; border-color:transparent">Sign Out</button>
</div>
</aside>
<div class="resizer" id="sidebar-resizer"></div>
<!-- Main Content -->
<main class="main-content" style="flex:1; min-width:0; display:flex; flex-direction:column; height:100vh;">
<!-- Notification Banner -->
<div id="notif-permission-banner"
style="display:none; background:var(--red); color:white; padding:10px 20px; font-size:13px; font-weight:500; text-align:center; align-items:center; justify-content:center; gap:10px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
Your browser is blocking notifications. Please click the lock icon in the URL bar, allow notifications, and
refresh the page to receive alerts.
</div>
<header class="topbar">
<div class="topbar-title" id="topbar-title">Dashboard Overview</div>
<div class="flex items-center gap-4">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text2);cursor:pointer">
<input type="checkbox" id="toggle-refresh" checked style="accent-color:var(--accent)" /> Auto-refresh
</label>
<button class="btn-secondary" id="btn-open-export" style="padding: 8px 16px; font-size: 13px;">
Export Data
</button>
<div style="position: relative;">
<button class="btn-icon" id="btn-notifications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
style="width:20px;height:20px;">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
<span class="notification-badge" id="notif-badge"></span>
</button>
<div class="notification-dropdown" id="notif-dropdown">
<div class="notif-header">
Notifications
<label
style="font-size:11px; font-weight:normal; display:flex; align-items:center; gap:4px; color:var(--text2); cursor:pointer;">
<input type="checkbox" id="toggle-snooze" style="accent-color:var(--accent);" /> Snooze
</label>
</div>
<div class="notif-list" id="notif-list">
<div class="notif-empty">No new notifications</div>
</div>
</div>
</div>
</div>
</header>
<!-- View: Dashboard -->
<div class="view-container active" id="view-dashboard">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-title">Total Sessions</div>
<div class="stat-card-value accent" id="dash-total-sessions">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Today's Sessions</div>
<div class="stat-card-value" id="dash-today-sessions">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Total Messages</div>
<div class="stat-card-value" id="dash-total-messages">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Live Chats</div>
<div class="stat-card-value" id="dash-live" style="color:var(--green)">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">In Queue</div>
<div class="stat-card-value" id="dash-queued" style="color:var(--yellow)">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Daily Users</div>
<div class="stat-card-value accent" id="dash-daily">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Blocked</div>
<div class="stat-card-value" id="dash-blocked" style="color:var(--red)">-</div>
</div>
</div>
<div class="chart-container">
<canvas id="messagesChart"></canvas>
</div>
<!-- Queued Users Panel -->
<div class="queue-panel" id="queue-panel-dashboard">
<div class="queue-panel-header">
<div class="queue-panel-title">
<span class="queue-dot"></span>
Users in Queue
</div>
<span class="queue-panel-count" id="queue-panel-count">0 waiting</span>
</div>
<div class="queue-items" id="queue-items"></div>
</div>
</div>
<!-- View: SLA Dashboard -->
<div class="view-container" id="view-sla">
<h3 style="margin-bottom:24px; color:var(--text1); font-size:18px;">SLA Metrics Overview</h3>
<p style="color:var(--text2); font-size:13px; margin-bottom:24px;">Monitoring response times, queue delays, and
overall chat resolution speed.</p>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-title">Avg. Queue Wait Time</div>
<div class="stat-card-value" id="sla-queue-time">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Avg. Agent Reply Time</div>
<div class="stat-card-value" id="sla-agent-time">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Avg. Resolution Time</div>
<div class="stat-card-value" id="sla-res-time">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">SLA Breaches (>3 min)</div>
<div class="stat-card-value" id="sla-breaches" style="color:var(--red)">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Breach Rate</div>
<div class="stat-card-value" id="sla-breach-rate">-</div>
</div>
<div class="stat-card">
<div class="stat-card-title">Human Takeover Rate</div>
<div class="stat-card-value accent" id="sla-takeover-rate">-</div>
</div>
</div>
</div>
<!-- View: Settings -->
<div class="view-container" id="view-settings" style="padding:0; flex-direction:row !important ;">
<!-- Settings nav sidebar -->
<div
style="width:220px; flex-shrink:0; background:var(--surface); border-right:1px solid var(--border); display:flex; flex-direction:column; padding:20px 10px; gap:2px;">
<div
style="font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text3); padding:8px 12px 4px;">
General</div>
<div class="settings-nav-item active" data-settings-sec="notifications">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
Notifications
</div>
<div class="settings-nav-item" data-settings-sec="whatsapp">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
WhatsApp
</div>
<div
style="font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text3); padding:16px 12px 4px;">
System</div>
<div class="settings-nav-item" data-settings-sec="config">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
Config
</div>
<div class="settings-nav-item" data-settings-sec="security">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
Security
</div>
<div class="settings-nav-item" data-settings-sec="version">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
Version info
</div>
</div>
<!-- Settings content area -->
<div style="flex:1; overflow-y:auto; padding:32px 40px;">
<!-- NOTIFICATIONS SECTION -->
<div class="settings-section active" id="settings-sec-notifications">
<h3 style="font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px;">Notifications</h3>
<p style="font-size:13px; color:var(--text2); margin-bottom:24px;">Control exactly which alert paths fire
when a user requests human support.</p>
<div
style="display:flex; flex-direction:column; gap:1px; background:var(--border); border-radius:14px; overflow:hidden; margin-bottom:16px;">
<div class="settings-row">
<div class="settings-row-icon" style="background:#ede9fe; color:#7c3aed;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:600; color:var(--text);">OS popup alerts</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">Browser notification shown even when
this tab is minimised</div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="tog-notif-os" onchange="saveNotifSetting('os_popup', this.checked)" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-row-icon" style="background:#fef3c7; color:#d97706;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Background sound</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">HTMLAudioElement chime — plays while
tab is hidden <strong style="color:var(--text2)">(path 1)</strong></div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="tog-notif-bgsound"
onchange="saveNotifSetting('sound_background', this.checked)" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-row-icon" style="background:#d1fae5; color:#059669;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
</svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Foreground sound</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">AudioContext beep when the tab is
open and active <strong style="color:var(--text2)">(path 2)</strong></div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="tog-notif-fgsound"
onchange="saveNotifSetting('sound_foreground', this.checked)" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
<div class="settings-row" style="background:#fef2f2;">
<div class="settings-row-icon" style="background:#fee2e2; color:#dc2626;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 .49-3.75" />
</svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Re-play on tab focus <span
style="font-size:10px; font-weight:600; background:#fee2e2; color:#b91c1c; padding:2px 7px; border-radius:20px; margin-left:6px; vertical-align:1px;">Double-sound
fix</span></div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">Replay sound when you switch back to
this tab — disable to prevent double-chime</div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="tog-notif-replay"
onchange="saveNotifSetting('sound_onfocus', this.checked)" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
<div class="settings-row">
<div class="settings-row-icon" style="background:#dbeafe; color:#2563eb;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
</div>
<div style="flex:1; min-width:0;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Flash tab title</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">Blink "🔔 New Alert!" in the browser
tab title bar</div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="tog-notif-flash"
onchange="saveNotifSetting('title_flash', this.checked)" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
</div>
<div
style="font-size:12px; color:var(--text2); padding:12px 14px; background:var(--accent-light); border-left:3px solid var(--accent); border-radius:4px; line-height:1.6;">
<strong>How the sound paths work:</strong> Background sound (HTMLAudio) already plays successfully in
hidden tabs once audio is unlocked. Enabling "Re-play on tab focus" fires a second time when you switch
back — leaving it <strong>off</strong> eliminates the double-chime.
</div>
</div>
<!-- WHATSAPP SECTION -->
<div class="settings-section" id="settings-sec-whatsapp">
<h3 style="font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px;">WhatsApp</h3>
<p style="font-size:13px; color:var(--text2); margin-bottom:24px;">Configure who receives WhatsApp
notifications when a user requests HR/supervisor.</p>
<div id="whatsapp-numbers-container" style="display:flex; flex-direction:column; gap:8px;"></div>
<button id="btn-add-whatsapp-number"
style="margin-top:8px; padding:10px 12px; background:var(--surface); border:1px dashed var(--border); border-radius:8px; color:var(--accent); font-size:12px; font-weight:600; cursor:pointer; width:100%; display:flex; align-items:center; justify-content:center; gap:6px;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add Another Number
</button>
<span style="font-size:11px; color:var(--text3); margin-top:4px; display:block;">Country code + number, no
spaces or + sign. Example: 923XXXXXXXXX</span>
<div style="margin-top:20px; display:flex; flex-direction:column; gap:16px; max-width:480px;">
<div>
<label style="font-size:12px; font-weight:600; color:var(--text2); text-transform:uppercase; margin-bottom:6px; display:block;">Provider</label>
<select id="inp-whatsapp-provider" style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1); margin-bottom: 16px;">
<option value="meta">Meta Cloud API</option>
<option value="baileys">Baileys API (e.g. Evolution API)</option>
</select>
</div>
<div id="meta-settings-group">
<div>
<label
style="font-size:12px; font-weight:600; color:var(--text2); text-transform:uppercase; margin-bottom:6px; display:block;">Phone
Number ID (Meta)</label>
<input type="text" id="inp-whatsapp-phone-id" placeholder="e.g. 1143551428842521"
style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1);" />
</div>
<div style="margin-top: 16px;">
<label
style="font-size:12px; font-weight:600; color:var(--text2); text-transform:uppercase; margin-bottom:6px; display:block;">Access
Token (Meta)</label>
<input type="password" id="inp-whatsapp-token" placeholder="Meta WhatsApp API token"
style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1);" />
<span style="font-size:11px; color:var(--text3); margin-top:4px; display:block;">If a token is already
saved, it will show as <b>••••••••••••</b>. Leave blank to keep your current token.</span>
</div>
</div>
<div id="baileys-settings-group" style="display:none;">
<div>
<label
style="font-size:12px; font-weight:600; color:var(--text2); text-transform:uppercase; margin-bottom:6px; display:block;">Baileys API URL</label>
<input type="text" id="inp-baileys-url" placeholder="e.g. http://localhost:8080/message/sendText/instance"
style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1);" />
</div>
<div style="margin-top: 16px;">
<label
style="font-size:12px; font-weight:600; color:var(--text2); text-transform:uppercase; margin-bottom:6px; display:block;">Baileys API Token (Optional)</label>
<input type="password" id="inp-baileys-token" placeholder="Global API Key or Bearer token"
style="width:100%; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1);" />
<span style="font-size:11px; color:var(--text3); margin-top:4px; display:block;">If a token is already
saved, it will show as <b>••••••••••••</b>. Leave blank to keep your current token.</span>
</div>
<div style="margin-top: 16px; padding: 12px; border: 1px dashed var(--border); border-radius: 12px; background: rgba(0,0,0,0.02); display: flex; flex-direction: column; align-items: center;">
<button class="btn-secondary" id="btn-baileys-qr" style="padding:10px 20px; font-size:13px; display:flex; align-items:center; gap:8px;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
Fetch QR Code for Login
</button>
<div id="baileys-qr-status" style="margin-top:12px; font-size:13px; color:var(--text2); text-align:center;"></div>
<img id="baileys-qr-img" style="margin-top:12px; display:none; border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.1); width: 250px; height: 250px;" />
</div>
</div>
<div
style="padding:12px; background:rgba(255,165,0,0.1); border-left:3px solid orange; border-radius:4px; margin-top:8px;">
<span style="font-size:12px; color:var(--text2);"><b>⚠️ Persistence Note:</b> Settings saved here are
temporary if your Hugging Face Space restarts. For permanent settings, add
<code>META_WHATSAPP_PHONE_ID</code>, <code>META_WHATSAPP_TOKEN</code>, and
<code>META_WHATSAPP_ADMIN_NUMBER</code> to your Space's Secrets.</span>
</div>
<div style="display:flex; gap:12px; margin-top:8px;">
<button class="btn-primary" id="btn-save-whatsapp" style="width:auto; padding:12px 24px;">Save
Settings</button>
<button class="btn-secondary" id="btn-test-whatsapp" style="padding:12px 24px;">Send Test
Message</button>
</div>
<div id="whatsapp-status" style="font-size:13px; color:var(--text2);"></div>
</div>
</div>
<!-- CONFIG SECTION -->
<div class="settings-section" id="settings-sec-config">
<h3 style="font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px;">Config</h3>
<p style="font-size:13px; color:var(--text2); margin-bottom:24px;">Runtime settings for session handling and
auto-refresh behaviour.</p>
<div
style="display:flex; flex-direction:column; gap:1px; background:var(--border); border-radius:14px; overflow:hidden; max-width:520px;">
<div class="settings-row">
<div class="settings-row-icon" style="background:#dbeafe; color:#2563eb;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10" />
<path d="M3.51 15a9 9 0 1 0 .49-3.75" />
</svg>
</div>
<div style="flex:1;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Auto-refresh sessions</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">Poll every 3 s for new messages and
session state changes</div>
</div>
<label class="settings-toggle">
<input type="checkbox" id="toggle-refresh-settings" checked
onchange="document.getElementById('toggle-refresh').checked=this.checked; document.getElementById('toggle-refresh').dispatchEvent(new Event('change'));" />
<span class="settings-toggle-track"></span><span class="settings-toggle-thumb"></span>
</label>
</div>
</div>
</div>
<!-- SECURITY SECTION -->
<div class="settings-section" id="settings-sec-security">
<h3 style="font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px;">Security</h3>
<p style="font-size:13px; color:var(--text2); margin-bottom:24px;">OTP and session security controls.</p>
<div
style="display:flex; flex-direction:column; gap:1px; background:var(--border); border-radius:14px; overflow:hidden; max-width:520px;">
<div class="settings-row">
<div class="settings-row-icon" style="background:#fee2e2; color:#dc2626;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div style="flex:1;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Require OTP on login</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">Two-factor code sent to registered
email on every sign-in</div>
</div>
<span
style="font-size:11px; font-weight:600; background:#dcfce7; color:#15803d; padding:3px 10px; border-radius:20px;">Active</span>
</div>
</div>
</div>
<!-- VERSION SECTION -->
<div class="settings-section" id="settings-sec-version">
<h3 style="font-size:16px; font-weight:600; color:var(--text); margin-bottom:4px;">Version Info</h3>
<p style="font-size:13px; color:var(--text2); margin-bottom:24px;">Current version details of the bot.</p>
<div
style="display:flex; flex-direction:column; gap:1px; background:var(--border); border-radius:14px; overflow:hidden; max-width:520px;">
<div class="settings-row">
<div class="settings-row-icon" style="background:#f3e8ff; color:#9333ea;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</div>
<div style="flex:1;">
<div style="font-size:13px; font-weight:600; color:var(--text);">Current Version</div>
<div style="font-size:11px; color:var(--text3); margin-top:2px;">The currently deployed version of the Martechsol Assistant</div>
</div>
<span
style="font-size:11px; font-weight:600; background:#dcfce7; color:#15803d; padding:3px 10px; border-radius:20px;">v1.2</span>
</div>
</div>
</div>
</div>
</div>
<!-- View: Sessions -->
<div class="view-container" id="view-sessions">
<div class="sessions-sidebar">
<div class="sessions-header">
<input type="text" class="search-input" id="search-inp" placeholder="Search sessions..." />
<div class="session-actions">
<button class="btn-secondary" id="btn-refresh-sessions" style="padding: 6px; font-size: 12px; flex:1">
Refresh</button>
</div>
</div>
<div class="session-list" id="session-list">
<!-- Populated by JS -->
<div style="padding: 40px; text-align:center; color: var(--text3)">
<div class="spinner"></div>
</div>
</div>
</div>
<div class="chat-view">
<div id="chat-placeholder" class="placeholder-view">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
<p>Select a session to view the conversation</p>
</div>
<div id="chat-active" style="display:none; flex-direction:column; height:100%;">
<div class="chat-header">
<div class="chat-header-info" style="display:flex; align-items:center; gap:12px;">
<button class="btn-secondary" id="btn-back-to-chats"
style="display:none; width: 32px; height: 32px; border-radius: 50%; padding: 0; align-items: center; justify-content: center; flex-shrink: 0;"
title="Back to chats">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
style="width:16px;height:16px;margin-right:2px;">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div>
<h3 id="chat-user-name">Guest User</h3>
<p id="chat-meta">Session ID: -</p>
</div>
</div>
<div class="chat-header-actions">
<button class="btn-secondary" id="btn-block-user"
style="padding: 6px 12px; font-size:12px; border-color:var(--border)">Block</button>
<button class="btn-secondary" id="btn-takeover"
style="padding: 6px 12px; font-size:12px; border-color:var(--accent); color:var(--accent)">Take
Over</button>
<button class="btn-secondary" id="btn-end-chat"
style="padding: 6px 12px; font-size:12px; border-color:var(--red); color:var(--red)">End Chat
Completely</button>
<button class="btn-secondary" id="btn-export-single"
style="padding: 6px 12px; font-size:12px; border-color:var(--border)">Export</button>
<button class="btn-secondary" id="btn-delete-current-chat"
style="padding: 6px 12px; font-size:12px; color:var(--red); border-color:rgba(239,68,68,0.2)">Delete
Chat</button>
<button class="btn-secondary" id="btn-delete-session"
style="padding: 6px 12px; font-size:12px; color:var(--red); border-color:rgba(239,68,68,0.2)">Delete
Session</button>
</div>
</div>
<div class="chat-messages" id="messages-area">
<!-- Populated by JS -->
</div>
<div id="chat-list-area"
style="display:none; flex-direction:column; gap:16px; padding:32px; overflow-y:auto; flex:1;">
<!-- Populated by JS -->
</div>
<div class="chat-reply-area hidden" id="chat-reply-area"
style="padding: 20px; border-top: 1px solid var(--border); background: var(--surface); gap: 12px;">
<input type="text" id="inp-admin-reply" placeholder="Type a message to the user..."
style="flex: 1; padding: 12px 16px; border: 1px solid var(--border); border-radius: 12px; font-size: 14px; outline: none;" />
<button class="btn-primary" id="btn-send-reply" style="width: auto; padding: 12px 24px;">Send</button>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Export Modal -->
<div class="modal-overlay" id="export-modal">
<div class="modal">
<div class="modal-header">
<h3>Export Chat History</h3>
<button class="modal-close" id="export-modal-close"><svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg></button>
</div>
<label class="export-option selected" id="opt-all">
<input type="radio" name="export-scope" value="all" checked />
<div class="export-option-text">
<strong>All Sessions Combined</strong>
<span>Export every chat session into a single file.</span>
</div>
</label>
<label class="export-option" id="opt-zip">
<input type="radio" name="export-scope" value="zip" />
<div class="export-option-text">
<strong>Separate Files (ZIP)</strong>
<span>Download an archive containing individual session files.</span>
</div>
</label>
<div
style="margin: 24px 0 12px; font-size: 12px; font-weight: 600; color: var(--text3); text-transform: uppercase;">
Format</div>
<div class="flex gap-2" style="margin-bottom: 24px;">
<button class="format-btn active" data-fmt="excel">Excel (.xlsx)</button>
<button class="format-btn" data-fmt="json">JSON (.json)</button>
</div>
<button class="btn-primary" id="btn-submit-export">Generate Export</button>
</div>
</div>
<!-- Takeover Role Modal -->
<div class="modal-overlay" id="takeover-modal">
<div class="modal">
<div class="modal-header">
<h3>Select Role for Takeover</h3>
<button class="modal-close" id="takeover-modal-close"><svg width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg></button>
</div>
<p style="font-size:13px; color:var(--text2); margin-bottom:20px;">
Choose your role. An automated formal message will be sent to the user immediately upon taking over.
</p>
<div style="display:flex; flex-direction:column; gap:12px; margin-bottom:24px;">
<label class="export-option selected" id="opt-role-hr">
<input type="radio" name="takeover-role" value="hr" checked />
<div class="export-option-text">
<strong>Human Resources (HR)</strong>
<span>"Hello, I am from HR. How may I assist you?"</span>
</div>
</label>
<label class="export-option" id="opt-role-sa">
<input type="radio" name="takeover-role" value="supervising_authority" />
<div class="export-option-text">
<strong>Supervising Authority</strong>
<span>"Hello, I am the Supervising Authority. What can I do for you?"</span>
</div>
</label>
<label class="export-option" id="opt-role-custom">
<input type="radio" name="takeover-role" value="custom" />
<div class="export-option-text" style="width: 100%;">
<strong>Custom Role</strong>
<span>Type a custom role to introduce yourself.</span>
<input type="text" id="inp-custom-role" class="search-input" placeholder="e.g. Sales Representative"
style="margin-top:8px; display:none; width: 100%; box-sizing:border-box;" />
</div>
</label>
</div>
<button class="btn-primary" id="btn-confirm-takeover">Confirm Takeover</button>
</div>
</div>
<div class="toast" id="toast">Message</div>
<!-- Permission Request Dialog -->
<div id="perm-dialog-overlay" class="hidden">
<div class="perm-card">
<div class="perm-header">
<div class="perm-icon-wrap">🔔</div>
<h2>Enable Notifications & Sound</h2>
<p>Allow these permissions so you get alerted immediately when a user requests human support — even when this
tab is in the background.</p>
</div>
<div class="perm-list">
<!-- Notifications Row -->
<div class="perm-row" id="perm-row-notif">
<div class="perm-row-icon">💬</div>
<div class="perm-row-info">
<strong>Browser Notifications</strong>
<span>OS popup alerts even when tab is minimized</span>
</div>
<span class="perm-status prompt" id="perm-status-notif">Checking...</span>
<button class="perm-grant-btn" id="perm-btn-notif">Grant</button>
</div>
<!-- Audio Row -->
<div class="perm-row" id="perm-row-audio">
<div class="perm-row-icon">🔊</div>
<div class="perm-row-info">
<strong>Notification Sound</strong>
<span>Audio chime when a new alert arrives</span>
</div>
<span class="perm-status prompt" id="perm-status-audio">Checking...</span>
<button class="perm-grant-btn" id="perm-btn-audio">Enable</button>
</div>
</div>
<div class="perm-actions">
<button id="btn-perm-enable-all">✅ Enable All Permissions</button>
<button id="btn-perm-skip">Skip for now (notifications won't work in background)</button>
</div>
</div>
</div>
<script>
(function () {
let _creds = null;
let _allSessions = [];
let _activeId = null;
let _activeChatId = null;
let _activeStatus = 'AI';
let _activeBlocked = false;
let _currentFilter = 'sessions';
let _refreshInterval = null;
let _activeSessionData = null;
let _sessionDataCache = {}; // Per-session data cache for instant switching
// ────────────────────────────────────────────────────────────────────
// Background Notification Worker
// Chrome throttles setInterval in background tabs to ~1 min or more,
// which stops loadSessions() from running. A Web Worker runs on a
// separate thread that is NOT throttled, so notifications fire
// reliably even when the tab is minimised or in the background.
// ────────────────────────────────────────────────────────────────────
const _NOTIF_WORKER_SRC = `
let _notifiedQueuedIds = new Set();
let _lastAlertIds = new Map();
let _timer = null;
let _cfg = null;
async function poll() {
if (!_cfg) return;
try {
const r = await fetch(_cfg.url, { headers: { Authorization: _cfg.auth } });
if (!r.ok) return;
const data = await r.json();
const sessions = data.sessions || [];
sessions.forEach(s => {
if (s.archived === true) return; // Mute archived sessions
const sid = s.session_id || s.id;
if (s.status === 'QUEUED') {
if (!_notifiedQueuedIds.has(sid)) {
_notifiedQueuedIds.add(sid);
// Seed alert tracking for this session so the alert check below
// doesn't also fire for a stale alert_id on the same poll cycle
if (!_lastAlertIds.has(sid)) {
_lastAlertIds.set(sid, s.alert_id || 0);
}
self.postMessage({ type: 'notification', message: 'User ' + (s.user_name || 'Guest') + ' wants to talk to a human agent.', sessionId: sid, soundType: 'default' });
}
} else {
_notifiedQueuedIds.delete(sid);
}
const lastAlert = _lastAlertIds.get(sid) || 0;
if (s.alert_id && s.alert_id > lastAlert) {
_lastAlertIds.set(sid, s.alert_id);
if (s.alert_type === 'reminder') {
self.postMessage({ type: 'notification', message: 'Reminder: User ' + (s.user_name || 'Guest') + ' is still waiting in the queue!', sessionId: sid, soundType: 'default', alertId: s.alert_id });
} else if (s.alert_type === 'exit_queue') {
self.postMessage({ type: 'notification', message: 'User ' + (s.user_name || 'Guest') + ' exited the queue and went back to AI.', sessionId: sid, soundType: 'disconnect', alertId: s.alert_id });
}
}
});
} catch (e) {}
}
self.onmessage = e => {
const d = e.data;
if (d.cmd === 'start') {
_cfg = d.config;
// Pre-seed already-notified IDs so we don't re-fire on page refresh
if (d.config.alreadyNotifiedIds) {
d.config.alreadyNotifiedIds.forEach(id => _notifiedQueuedIds.add(id));
}
if (d.config.alreadyAlertIds) {
Object.entries(d.config.alreadyAlertIds).forEach(([sid, alertId]) => {
_lastAlertIds.set(sid, alertId);
});
}
if (_timer) clearInterval(_timer);
poll();
_timer = setInterval(poll, d.config.interval || 3000);
} else if (d.cmd === 'updateAuth') {
if (_cfg) _cfg.auth = d.auth;
} else if (d.cmd === 'stop') {
if (_timer) { clearInterval(_timer); _timer = null; }
}
};
`;
let _notifWorker = null;
function startNotifWorker() {
if (_notifWorker) { _notifWorker.terminate(); _notifWorker = null; }
try {
const blob = new Blob([_NOTIF_WORKER_SRC], { type: 'application/javascript' });
_notifWorker = new Worker(URL.createObjectURL(blob));
_notifWorker.onmessage = e => {
const d = e.data;
if (d.type === 'notification') addNotification(d.message, d.sessionId, d.soundType, d.alertId);
};
_notifWorker.onerror = err => console.warn('Notif worker error:', err);
// Load already-notified IDs from localStorage so we don't re-fire on refresh
let storedIds = [];
let storedAlertIds = {};
try {
storedIds = JSON.parse(localStorage.getItem('martech_notified_ids') || '[]');
storedAlertIds = JSON.parse(localStorage.getItem('martech_alert_ids') || '{}');
} catch (e) { }
_notifWorker.postMessage({
cmd: 'start',
config: {
// Use a high limit so the worker sees ALL sessions, not just the first 50.
// Without this, users beyond position #50 would never trigger notifications.
url: window.location.origin + '/api/admin/sessions?limit=10000',
auth: authHeader(),
interval: 3000,
alreadyNotifiedIds: storedIds,
alreadyAlertIds: storedAlertIds
}
});
} catch (err) {
console.warn('Web Worker not available, using main thread polling:', err);
}
}
function invalidateSessionCache(id) {
delete _sessionDataCache[id];
if (String(_activeId) === String(id)) _activeSessionData = null;
}
// Notifications State
let _notifiedQueuedIds = new Set();
let _notifications = [];
let _snoozed = localStorage.getItem('martech_snoozed') === '1';
// Load persisted notifications on startup
function _loadPersistedNotifications() {
try {
const saved = JSON.parse(localStorage.getItem('martech_notifications') || '[]');
// Only keep last 24 hours
const cutoff = Date.now() - 86400000;
_notifications = saved.filter(n => n.timestamp > cutoff).map(n => ({
...n,
time: new Date(n.timestamp)
}));
} catch (e) { _notifications = []; }
}
function _saveNotifications() {
try {
const toSave = _notifications.slice(0, 50).map(n => ({
...n,
time: undefined,
timestamp: n.timestamp || n.time?.getTime() || Date.now()
}));
localStorage.setItem('martech_notifications', JSON.stringify(toSave));
} catch (e) { }
}
// Notification sound using Web Audio API
let _audioCtx = null;
let _audioKeepalive = null;
let _pendingSoundType = null; // queued sound for when tab comes back to foreground
let _titleFlashInterval = null; // for flashing the tab title
const _ORIG_TITLE = document.title;
function _initAudioCtx() {
if (_audioCtx) return;
try {
_audioCtx = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) { console.warn('AudioContext not supported:', e); }
}
// Keep AudioContext alive — play a silent buffer every 20s so Chrome never auto-suspends it
function _startAudioKeepalive() {
if (_audioKeepalive) return;
_audioKeepalive = setInterval(() => {
if (!_audioCtx) return;
try {
if (_audioCtx.state === 'suspended') { _audioCtx.resume().catch(() => { }); return; }
const buf = _audioCtx.createBuffer(1, 1, 22050);
const src = _audioCtx.createBufferSource();
src.buffer = buf;
src.connect(_audioCtx.destination);
src.start(0);
} catch (e) { }
}, 20000);
}
function _scheduleBeeps(beeps) {
if (!_audioCtx) return;
const doPlay = () => {
try {
const base = _audioCtx.currentTime + 0.05;
beeps.forEach(b => {
const osc = _audioCtx.createOscillator();
const gain = _audioCtx.createGain();
osc.connect(gain);
gain.connect(_audioCtx.destination);
osc.type = b.type || 'sine';
if (b.freqEnd !== undefined) {
osc.frequency.setValueAtTime(b.freq, base + b.startOffset);
osc.frequency.linearRampToValueAtTime(b.freqEnd, base + b.stopOffset);
} else {
b.freqs.forEach(([f, t]) => osc.frequency.setValueAtTime(f, base + t));
}
gain.gain.setValueAtTime(0.35, base + b.startOffset);
gain.gain.linearRampToValueAtTime(0, base + b.stopOffset);
osc.start(base + b.startOffset);
osc.stop(base + b.stopOffset + 0.01);
});
} catch (e) { console.warn('Sound playback error:', e); }
};
if (_audioCtx.state === 'suspended') {
_audioCtx.resume().then(doPlay).catch(e => console.warn('AudioContext resume failed:', e));
} else {
doPlay();
}
}
// ── Audio System ──
// We use two approaches in parallel:
// 1. HTMLAudioElement with a WAV blob URL — works in background tabs after user gesture
// 2. AudioContext as fallback when tab is visible
//
// The WAV is generated programmatically so we have zero external dependencies.
let _notifAudioUrl = null;
let _disconnectAudioUrl = null;
function _generateWavUrl(samples, sampleRate = 22050) {
// samples: Float32Array of PCM values in range [-1, 1]
const numSamples = samples.length;
const buffer = new ArrayBuffer(44 + numSamples * 2);
const view = new DataView(buffer);
const write = (o, s) => { for (let i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); };
write(0, 'RIFF');
view.setUint32(4, 36 + numSamples * 2, true);
write(8, 'WAVE');
write(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM
view.setUint16(22, 1, true); // mono
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
write(36, 'data');
view.setUint32(40, numSamples * 2, true);
for (let i = 0; i < numSamples; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(44 + i * 2, s < 0 ? s * 32768 : s * 32767, true);
}
return URL.createObjectURL(new Blob([buffer], { type: 'audio/wav' }));
}
function _buildNotifSamples() {
const sr = 22050, dur = 0.55;
const n = Math.floor(sr * dur);
const s = new Float32Array(n);
// Three-tone chime: 880 → 1100 → 880 Hz
const freqs = [[880, 0, 0.18], [1100, 0.15, 0.35], [880, 0.32, 0.55]];
for (let i = 0; i < n; i++) {
const t = i / sr;
let v = 0;
for (const [f, tStart, tEnd] of freqs) {
if (t >= tStart && t < tEnd) {
const fadeIn = Math.min((t - tStart) / 0.02, 1);
const fadeOut = Math.min((tEnd - t) / 0.05, 1);
v += 0.35 * fadeIn * fadeOut * Math.sin(2 * Math.PI * f * t);
}
}
s[i] = v;
}
return s;
}
function _buildDisconnectSamples() {
const sr = 22050, dur = 0.35;
const n = Math.floor(sr * dur);
const s = new Float32Array(n);
for (let i = 0; i < n; i++) {
const t = i / sr;
const freq = 440 - (330 * t / dur); // sweep 440→110
const fade = Math.min(1 - t / dur, (dur - t) / 0.05);
s[i] = 0.35 * Math.max(0, fade) * Math.sin(2 * Math.PI * freq * t);
}
return s;
}
function _initAudioUrls() {
if (_notifAudioUrl) return;
try {
_notifAudioUrl = _generateWavUrl(_buildNotifSamples());
_disconnectAudioUrl = _generateWavUrl(_buildDisconnectSamples());
} catch (e) { console.warn('WAV generation failed:', e); }
}
function _playAudioUrl(url) {
if (!url) return;
try {
const audio = new Audio(url);
audio.volume = 0.8;
const p = audio.play();
if (p) p.catch(err => {
// If blocked (e.g. very strict browser), fall back to AudioContext
console.warn('Audio element play blocked:', err);
});
} catch (e) { console.warn('Audio element error:', e); }
}
function playNotifSound() {
_initAudioUrls();
_playAudioUrl(_notifAudioUrl);
}
function playDisconnectSound() {
_initAudioUrls();
_playAudioUrl(_disconnectAudioUrl);
}
let _chartInstance = null;
const STORAGE_KEY = 'martech_admin_creds';
const $ = id => document.getElementById(id);
const authHeader = () => 'Basic ' + btoa(_creds.user + ':' + _creds.pass);
function showToast(msg, type = 'success') {
const t = $('toast');
t.innerHTML = (type === 'success' ? '✓ ' : '⚠ ') + msg;
t.className = 'toast ' + type + ' show';
setTimeout(() => t.classList.remove('show'), 3000);
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function shortId(id) {
if (!id) return '—';
return id.length > 12 ? id.slice(0, 6) + '…' + id.slice(-4) : id;
}
// ── Login Flow ──
async function doLogin() {
const u = $('inp-user').value.trim(), p = $('inp-pass').value;
if (!u || !p) return;
$('btn-login').textContent = 'Authenticating...'; $('btn-login').disabled = true; $('login-error').style.display = 'none';
try {
const r = await fetch('/api/admin/request-otp', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p })
});
if (r.ok) {
$('login-stage-1').classList.add('hidden');
$('login-stage-2').classList.remove('hidden');
$('inp-otp').focus();
} else {
const err = await r.json();
$('login-error').textContent = err.detail || 'Login failed.';
$('login-error').style.display = 'block';
}
} catch (e) {
$('login-error').textContent = 'Network error.'; $('login-error').style.display = 'block';
} finally {
$('btn-login').textContent = 'Sign In'; $('btn-login').disabled = false;
}
}
async function doVerifyOtp() {
const u = $('inp-user').value.trim(), p = $('inp-pass').value, otp = $('inp-otp').value.trim();
if (!otp) return;
$('btn-verify').textContent = 'Verifying...'; $('btn-verify').disabled = true; $('login-error').style.display = 'none';
try {
const r = await fetch('/api/admin/verify-otp', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p, code: otp })
});
if (r.ok) {
_creds = { user: u, pass: p, otp_verified: true };
localStorage.setItem(STORAGE_KEY, JSON.stringify(_creds));
// Init audio HERE — this is inside a real user-gesture (button click)
_initAudioCtx();
if (_audioCtx) _audioCtx.resume().catch(() => { });
$('login-screen').classList.add('hidden');
$('app').style.display = 'flex';
initApp();
showToast('Welcome back, Admin!');
} else {
const err = await r.json();
$('login-error').textContent = err.detail || 'Invalid OTP.'; $('login-error').style.display = 'block';
}
} catch (e) {
$('login-error').textContent = 'Network error.'; $('login-error').style.display = 'block';
} finally {
$('btn-verify').textContent = 'Verify Code'; $('btn-verify').disabled = false;
}
}
$('btn-login').onclick = doLogin;
$('btn-verify').onclick = doVerifyOtp;
$('btn-back').onclick = () => {
$('login-stage-2').classList.add('hidden'); $('login-stage-1').classList.remove('hidden'); $('login-error').style.display = 'none'; $('inp-otp').value = '';
};
$('inp-pass').onkeydown = e => { if (e.key === 'Enter') doLogin() };
$('inp-otp').onkeydown = e => { if (e.key === 'Enter') doVerifyOtp() };
$('btn-logout').onclick = () => {
_creds = null; localStorage.removeItem(STORAGE_KEY);
window.location.reload();
};
// Auto Login check
(function checkAuth() {
try {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
if (stored && stored.user && stored.otp_verified) {
_creds = stored;
$('login-screen').classList.add('hidden');
$('app').style.display = 'flex';
initApp();
} else { localStorage.removeItem(STORAGE_KEY); }
} catch (e) { }
})();
// ── App Init & Routing ──
// ── Title flash helpers ──
function _startTitleFlash(msg) {
if (_titleFlashInterval) return; // already flashing
let on = true;
_titleFlashInterval = setInterval(() => {
document.title = on ? '🔔 ' + msg : _ORIG_TITLE;
on = !on;
}, 900);
}
function _stopTitleFlash() {
if (_titleFlashInterval) { clearInterval(_titleFlashInterval); _titleFlashInterval = null; }
document.title = _ORIG_TITLE;
}
// ── Page Visibility: resume audio + flush queued sound when tab becomes active ──
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
const ns = loadNotifSettings();
if (_audioCtx && _audioCtx.state === 'suspended') {
_audioCtx.resume().then(() => {
if (_pendingSoundType && ns.sound_onfocus) {
if (_pendingSoundType === 'disconnect') playDisconnectSound();
else playNotifSound();
}
_pendingSoundType = null;
}).catch(() => { });
} else if (_pendingSoundType && ns.sound_onfocus) {
if (_pendingSoundType === 'disconnect') playDisconnectSound();
else playNotifSound();
_pendingSoundType = null;
} else {
_pendingSoundType = null; // clear even if skipped
}
_stopTitleFlash();
}
});
async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
try {
const reg = await navigator.serviceWorker.register('/sw.js');
console.log('SW registered:', reg.scope);
await navigator.serviceWorker.ready;
} catch (err) {
console.warn('SW registration failed:', err);
}
}
async function _showOsNotif(title, body, tag) {
const ns = loadNotifSettings();
if (!ns.os_popup) return; // user turned it off
if (!('Notification' in window) || Notification.permission !== 'granted') return;
// Use Service Worker notification — this fires OS sound even when tab is backgrounded
if ('serviceWorker' in navigator) {
const reg = await navigator.serviceWorker.getRegistration();
if (reg) {
await reg.showNotification(title, {
body,
tag, // deduplicate per session — browser replaces same tag
renotify: true, // renotify ensures subsequent events for same session pop up
icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="%238b5cf6"/><text x="50" y="68" font-family="sans-serif" font-size="55" font-weight="bold" fill="white" text-anchor="middle">M</text></svg>',
requireInteraction: true,
silent: false
});
return;
}
}
// Fallback to regular Notification API
const n = new Notification(title, { body, tag, silent: false });
n.onclick = () => { window.focus(); n.close(); };
}
function initApp() {
_loadPersistedNotifications(); // ADD THIS FIRST
$('toggle-snooze').checked = _snoozed;
// Ensure AudioContext is initialized (auto-login case: no user gesture available,
// but we still call _initAudioCtx so it's ready the moment user clicks anything)
_initAudioCtx();
// Resume if possible (will succeed if called from a user gesture, silently fail otherwise)
if (_audioCtx) _audioCtx.resume().catch(() => { });
// Start keepalive to prevent Chrome auto-suspend
_startAudioKeepalive();
registerServiceWorker(); // Register Service Worker for reliable background push notifications
// On any click in the app, ensure context is running
document.addEventListener('click', () => {
if (_audioCtx && _audioCtx.state === 'suspended') _audioCtx.resume().catch(() => { });
});
switchView('dashboard');
setInterval(() => {
loadMetrics();
if ($('toggle-refresh') && $('toggle-refresh').checked) {
if (_currentFilter === 'sessions' || _currentFilter === 'queued') {
loadSessions(true, true);
}
}
}, 3000);
setupAutoRefresh();
startNotifWorker();
checkAndRequestPermissions();
}
document.querySelectorAll('.nav-item').forEach(el => {
el.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
switchView(el.dataset.view);
}
});
function switchView(view) {
document.querySelectorAll('.view-container').forEach(v => {
v.style.display = 'none';
v.classList.remove('active');
});
let targetView = view;
if (view === 'daily' || view === 'blocked' || view === 'queued' || view === 'archived') targetView = 'sessions';
const v = $('view-' + targetView);
v.style.display = 'flex';
v.style.flexDirection = (targetView === 'sessions' || targetView === 'settings') ? 'row' : 'column';
setTimeout(() => v.classList.add('active'), 10);
if (view === 'dashboard') $('topbar-title').textContent = 'Dashboard Overview';
else if (view === 'sla') $('topbar-title').textContent = 'SLA Dashboard';
else if (view === 'sessions') $('topbar-title').textContent = 'All Live Chat Sessions';
else if (view === 'queued') $('topbar-title').textContent = 'Users In Queue';
else if (view === 'archived') $('topbar-title').textContent = 'Archived Chats';
else if (view === 'daily') $('topbar-title').textContent = 'Today\'s Users';
else if (view === 'blocked') $('topbar-title').textContent = 'Blocked Users';
else if (view === 'settings') $('topbar-title').textContent = 'Settings';
if (view === 'dashboard') loadAnalytics();
if (view === 'sla') loadSLAMetrics();
if (['sessions', 'daily', 'blocked', 'queued', 'archived'].includes(view)) {
if (_currentFilter !== view) {
_currentFilter = view;
_activeId = null;
_activeSessionData = null;
if ($('chat-placeholder')) $('chat-placeholder').style.display = 'flex';
if ($('chat-active')) $('chat-active').style.display = 'none';
}
loadSessions();
}
if (view === 'settings') {
loadWhatsAppSettings();
initSettingsPanel();
applyNotifSettingsToUI();
}
}
async function loadSLAMetrics() {
try {
const r = await fetch('/api/admin/sla-metrics', { headers: { Authorization: authHeader() } });
if (!r.ok) return;
const data = await r.json();
function fmtTime(sec) {
if (!sec) return '0s';
if (sec < 60) return Math.round(sec) + 's';
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return m + 'm ' + s + 's';
}
$('sla-queue-time').textContent = fmtTime(data.avg_queue_time_sec);
$('sla-agent-time').textContent = fmtTime(data.avg_agent_reply_time_sec);
$('sla-res-time').textContent = fmtTime(data.avg_resolution_time_sec);
$('sla-breaches').textContent = data.breach_count;
$('sla-breach-rate').textContent = (data.breach_rate_pct || 0).toFixed(1) + '%';
$('sla-takeover-rate').textContent = (data.takeover_rate_pct || 0).toFixed(1) + '%';
} catch (e) { console.error('Failed to load SLA metrics', e); }
}
// ── Permission Request Dialog ──
// Shows a modal after login if browser notifications or audio are not yet enabled.
function checkAndRequestPermissions() {
if ('Notification' in window && Notification.permission === 'denied') {
$('notif-permission-banner').style.display = 'flex';
}
const notifGranted = !('Notification' in window) || Notification.permission === 'granted';
const audioAlreadyEnabled = localStorage.getItem('martech_audio_enabled') === '1';
if (notifGranted && audioAlreadyEnabled) return; // both satisfied, skip dialog entirely
const overlay = document.getElementById('perm-dialog-overlay');
const rowNotif = document.getElementById('perm-row-notif');
const rowAudio = document.getElementById('perm-row-audio');
const stNotif = document.getElementById('perm-status-notif');
const stAudio = document.getElementById('perm-status-audio');
const btnNotif = document.getElementById('perm-btn-notif');
const btnAudio = document.getElementById('perm-btn-audio');
function refreshStatuses() {
const np = 'Notification' in window ? Notification.permission : 'denied';
if (np === 'granted') {
stNotif.textContent = '✓ Allowed'; stNotif.className = 'perm-status ok';
rowNotif.className = 'perm-row granted'; btnNotif.disabled = true; btnNotif.textContent = 'Granted';
} else if (np === 'denied') {
stNotif.textContent = '✗ Blocked'; stNotif.className = 'perm-status bad';
rowNotif.className = 'perm-row denied';
btnNotif.textContent = 'Open site settings'; btnNotif.disabled = false;
btnNotif.onclick = () => alert('Please click the 🔒 lock icon in your browser address bar → Site settings → Notifications → Allow');
} else {
stNotif.textContent = 'Not set'; stNotif.className = 'perm-status prompt';
rowNotif.className = 'perm-row'; btnNotif.disabled = false; btnNotif.textContent = 'Grant';
}
const audioOk = localStorage.getItem('martech_audio_enabled') === '1';
if (audioOk) {
stAudio.textContent = '✓ Allowed'; stAudio.className = 'perm-status ok';
rowAudio.className = 'perm-row granted'; btnAudio.disabled = true; btnAudio.textContent = 'Enabled';
} else {
stAudio.textContent = 'Click to enable'; stAudio.className = 'perm-status prompt';
rowAudio.className = 'perm-row'; btnAudio.disabled = false; btnAudio.textContent = 'Enable';
}
const allGood = np === 'granted' && audioOk;
if (allGood) setTimeout(() => overlay.classList.add('hidden'), 900);
}
async function grantNotif() {
btnNotif.disabled = true; btnNotif.textContent = 'Requesting...';
await Notification.requestPermission();
refreshStatuses();
}
async function grantAudio() {
btnAudio.disabled = true; btnAudio.textContent = 'Enabling...';
_initAudioCtx();
if (_audioCtx) await _audioCtx.resume().catch(() => { });
// Pre-generate WAV blob URLs inside this user gesture so the browser
// allows future Audio element playback even in background tabs
_initAudioUrls();
localStorage.setItem('martech_audio_enabled', '1');
playNotifSound(); // play test sound to confirm
refreshStatuses();
}
btnNotif.onclick = grantNotif;
btnAudio.onclick = grantAudio;
document.getElementById('btn-perm-enable-all').onclick = async () => {
await grantNotif();
await grantAudio();
};
document.getElementById('btn-perm-skip').onclick = () => overlay.classList.add('hidden');
refreshStatuses();
overlay.classList.remove('hidden');
}
async function loadMetrics() {
try {
const r = await fetch('/api/admin/metrics', { headers: { Authorization: authHeader() } });
const m = await r.json();
$('dash-live').textContent = m.live;
$('dash-queued').textContent = m.queued;
$('dash-daily').textContent = m.daily;
$('dash-blocked').textContent = m.blocked;
_updateQueueBadge(m.queued);
} catch (e) {
console.error('Failed to load metrics', e);
}
}
// ── Dashboard Analytics ──
async function loadAnalytics() {
if (!_creds) return;
try {
loadMetrics();
const r = await fetch('/api/admin/analytics', { headers: { Authorization: authHeader() } });
if (!r.ok) return;
const data = await r.json();
$('dash-total-sessions').textContent = data.total_sessions;
$('dash-today-sessions').textContent = data.today_sessions;
$('dash-total-messages').textContent = data.total_messages;
renderChart(data.trends);
} catch (e) { console.error('Analytics error:', e); }
}
function renderChart(trends) {
const ctx = $('messagesChart').getContext('2d');
const labels = trends.map(t => {
const d = new Date(t.date); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
});
const msgData = trends.map(t => t.messages);
if (_chartInstance) _chartInstance.destroy();
_chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Messages',
data: msgData,
borderColor: '#8b5cf6',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
borderWidth: 3,
tension: 0.4,
fill: true,
pointBackgroundColor: '#fff',
pointBorderColor: '#8b5cf6',
pointBorderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { borderDash: [4, 4], color: '#e2e8f0' }, border: { display: false } },
x: { grid: { display: false }, border: { display: false } }
},
interaction: { intersect: false, mode: 'index' }
}
});
}
// Notifications Logic
$('btn-notifications').onclick = (e) => {
e.stopPropagation();
$('notif-dropdown').classList.toggle('show');
if ($('notif-dropdown').classList.contains('show')) {
// Mark all as read when opened
_notifications.forEach(n => n.read = true);
_saveNotifications();
_updateBadge();
renderNotifications();
}
};
document.addEventListener('click', (e) => {
if ($('btn-notifications') && !$('btn-notifications').contains(e.target) && $('notif-dropdown') && !$('notif-dropdown').contains(e.target)) {
$('notif-dropdown').classList.remove('show');
}
});
$('toggle-snooze').onchange = (e) => {
_snoozed = e.target.checked;
localStorage.setItem('martech_snoozed', _snoozed ? '1' : '0');
};
function addNotification(message, sessionId, soundType = 'default', alertId = null) {
// Persist so refresh doesn't re-fire
if (sessionId) {
try {
const ids = JSON.parse(localStorage.getItem('martech_notified_ids') || '[]');
if (!ids.includes(sessionId)) {
ids.push(sessionId);
// Keep only last 50 to avoid unbounded growth
localStorage.setItem('martech_notified_ids', JSON.stringify(ids.slice(-50)));
}
// Persist alert IDs for reminders
if (alertId) {
const alertIds = JSON.parse(localStorage.getItem('martech_alert_ids') || '{}');
alertIds[sessionId] = alertId;
localStorage.setItem('martech_alert_ids', JSON.stringify(alertIds));
}
} catch (e) { }
}
_notifications.unshift({ message, sessionId, soundType, time: new Date(), timestamp: Date.now(), read: false });
if (_notifications.length > 50) _notifications = _notifications.slice(0, 50);
renderNotifications();
_saveNotifications();
_updateBadge();
if (!_snoozed) {
const ns = loadNotifSettings();
if (ns.sound_background) {
// Path 1: HTMLAudioElement — plays in background tabs
if (soundType === 'disconnect') playDisconnectSound();
else playNotifSound();
}
if (document.visibilityState !== 'visible') {
// Only queue the focus-replay if the user has explicitly enabled it
// Default is OFF — this prevents the double-chime
if (ns.sound_onfocus) {
_pendingSoundType = soundType;
}
if (ns.title_flash) {
_startTitleFlash('New Alert!');
}
}
}
// ── OS Browser Notification (works even when tab is in background) ──
const notifTitle = soundType === 'disconnect' ? '🔕 User Left Queue' : '🔔 Human Handoff Request';
_showOsNotif(
notifTitle,
message,
'admin-notif-' + (sessionId || Date.now())
);
}
function renderNotifications() {
if (_notifications.length === 0) {
$('notif-list').innerHTML = '<div class="notif-empty">No new notifications</div>';
return;
}
$('notif-list').innerHTML = _notifications.map((n, idx) => `
<div class="notif-item notif-item-link ${n.read ? 'notif-read' : ''}" data-id="${esc(n.sessionId)}" data-idx="${idx}">
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div class="notif-item-title" style="color:${n.read ? 'var(--text3)' : 'var(--text)'}">
${n.soundType === 'disconnect' ? '🔕 User Left Queue' : '🔔 Human Request'}
</div>
<button class="notif-dismiss" data-idx="${idx}" style="background:none;border:none;cursor:pointer;color:var(--text3);font-size:14px;padding:0 0 0 8px;line-height:1;" title="Dismiss">✕</button>
</div>
<div class="notif-item-desc">${esc(n.message)}</div>
<div class="notif-item-time">${n.time.toLocaleTimeString()}${n.read ? ' · Read' : ''}</div>
</div>
`).join('');
// Click notification to open session
document.querySelectorAll('.notif-item-link').forEach(el => {
el.onclick = (e) => {
if (e.target.classList.contains('notif-dismiss')) return;
const idx = parseInt(el.dataset.idx);
_notifications[idx].read = true;
_saveNotifications();
renderNotifications();
_updateBadge();
e.stopPropagation();
$('notif-dropdown').classList.remove('show');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
$('nav-sessions').classList.add('active');
switchView('sessions');
invalidateSessionCache(el.dataset.id);
fetch('/api/admin/sessions/' + encodeURIComponent(el.dataset.id), { headers: { Authorization: authHeader() } })
.then(r => r.json())
.then(data => {
_sessionDataCache[el.dataset.id] = data;
openSession(el.dataset.id, data.current_chat_id || 1);
}).catch(() => openSession(el.dataset.id));
};
});
// Dismiss button
document.querySelectorAll('.notif-dismiss').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.idx);
_notifications.splice(idx, 1);
_saveNotifications();
renderNotifications();
_updateBadge();
};
});
}
function _updateBadge() {
const unread = _notifications.filter(n => !n.read).length;
$('notif-badge').style.display = unread > 0 ? 'block' : 'none';
}
// Sessions Management
let _lastAlertIds = new Map();
let _sessionSkip = 0;
const _sessionLimit = 50;
let _hasMoreSessions = true;
let _isLoadingSessions = false;
let _searchQuery = '';
async function loadSessions(reset = true, isPolling = false) {
if (_isLoadingSessions) return;
if (reset && !isPolling) {
_sessionSkip = 0;
_hasMoreSessions = true;
_allSessions = [];
$('session-list').innerHTML = `<div style="text-align:center;padding:20px;color:var(--text3);">Loading...</div>`;
}
if (!_hasMoreSessions && !isPolling) return;
_isLoadingSessions = true;
try {
const fetchSkip = isPolling ? 0 : _sessionSkip;
const fetchLimit = isPolling ? Math.max(_sessionSkip, _sessionLimit) : _sessionLimit;
const url = `/api/admin/sessions?search=${encodeURIComponent(_searchQuery)}&filter=${encodeURIComponent(_currentFilter)}&skip=${fetchSkip}&limit=${fetchLimit}`;
const r = await fetch(url, { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error('Failed to fetch');
const data = await r.json();
if (!isPolling) {
if (data.sessions.length < _sessionLimit) {
_hasMoreSessions = false;
}
if (reset) {
_allSessions = data.sessions;
renderSessionList(_allSessions);
} else {
_allSessions = _allSessions.concat(data.sessions);
renderSessionList(data.sessions, true);
}
_sessionSkip += data.sessions.length;
} else {
_allSessions = data.sessions;
renderSessionList(_allSessions);
}
// If we are in the queued view, update the queued panel too
if (_currentFilter === 'queued') {
renderQueuePanel();
}
} catch (e) {
if (reset) $('session-list').innerHTML = `<div style="text-align:center;color:var(--red);padding:20px">Error loading sessions</div>`;
} finally {
_isLoadingSessions = false;
}
}
// ── Queued Users Panel ──
let _prevQueuedIds = new Set();
let _queueTimerInterval = null;
function renderQueuePanel() {
const queued = (_allSessions || []).filter(s => s.status === 'QUEUED' && s.archived !== true);
const container = $('queue-items');
const countEl = $('queue-panel-count');
const badgeEl = $('queue-badge');
// Update count label
if (countEl) countEl.textContent = queued.length + (queued.length === 1 ? ' waiting' : ' waiting');
// Update nav badge
_updateQueueBadge(queued.length);
if (!container) return;
if (queued.length === 0) {
container.innerHTML = `
<div class="queue-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
<line x1="9" y1="9" x2="9.01" y2="9"></line>
<line x1="15" y1="9" x2="15.01" y2="9"></line>
</svg>
<div>No users in queue</div>
</div>`;
_stopQueueTimers();
_prevQueuedIds = new Set();
return;
}
// Determine which IDs are new (for flash animation)
const currentIds = new Set(queued.map(s => s.session_id || s.id));
const newIds = new Set();
currentIds.forEach(id => { if (!_prevQueuedIds.has(id)) newIds.add(id); });
container.innerHTML = queued.map(s => {
const sid = s.session_id || s.id;
const isDefault = !s.user_name || s.user_name === 'default name' || s.user_name === 'default_name';
const name = isDefault ? shortId(sid) : s.user_name;
const isNew = newIds.has(sid);
const queuedAt = s.queued_at || s.last_active || new Date().toISOString();
return `
<div class="queue-item ${isNew ? 'queue-item-new' : ''}" data-sid="${esc(sid)}" data-queued="${esc(queuedAt)}">
<div class="queue-item-info">
<div class="queue-item-name" title="${esc(name)}">${esc(name)}</div>
<div class="queue-item-timer" data-queued="${esc(queuedAt)}">Waiting...</div>
</div>
<button class="queue-takeover-btn" data-sid="${esc(sid)}">Take Over</button>
</div>`;
}).join('');
// Bind click handlers — click item to view, click button to takeover
container.querySelectorAll('.queue-item').forEach(el => {
el.onclick = (e) => {
if (e.target.classList.contains('queue-takeover-btn')) return;
const sid = el.dataset.sid;
// Navigate to sessions view and open this session
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
$('nav-sessions').classList.add('active');
switchView('sessions');
invalidateSessionCache(sid);
openSession(sid);
};
});
container.querySelectorAll('.queue-takeover-btn').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
_queueTakeover(btn.dataset.sid);
};
});
_prevQueuedIds = currentIds;
_startQueueTimers();
}
function _startQueueTimers() {
_stopQueueTimers();
_queueTimerTick(); // immediate first tick
_queueTimerInterval = setInterval(_queueTimerTick, 1000);
}
function _stopQueueTimers() {
if (_queueTimerInterval) { clearInterval(_queueTimerInterval); _queueTimerInterval = null; }
}
function _queueTimerTick() {
document.querySelectorAll('.queue-item-timer[data-queued]').forEach(el => {
const queued = new Date(el.dataset.queued);
const diff = Math.max(0, Math.floor((Date.now() - queued.getTime()) / 1000));
const m = Math.floor(diff / 60);
const s = diff % 60;
el.textContent = m > 0
? `Waiting ${m}m ${String(s).padStart(2, '0')}s`
: `Waiting ${s}s`;
});
}
async function _queueTakeover(sessionId) {
if (!sessionId || sessionId === 'undefined') return;
try {
const r = await fetch('/api/admin/sessions/' + encodeURIComponent(sessionId) + '/takeover', {
method: 'POST',
headers: { Authorization: authHeader() }
});
if (!r.ok) throw new Error('Takeover failed');
showToast('You are now chatting with this user');
// Navigate to sessions view and open the session
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
$('nav-sessions').classList.add('active');
switchView('sessions');
invalidateSessionCache(sessionId);
// Fetch session data so we can open the specific chat thread directly
const r2 = await fetch('/api/admin/sessions/' + encodeURIComponent(sessionId), { headers: { Authorization: authHeader() } });
const data = await r2.json();
_sessionDataCache[sessionId] = data;
openSession(sessionId, data.current_chat_id || 1);
loadSessions(); // refresh to update queue panel
} catch (e) {
showToast('Takeover failed', 'error');
}
}
function _updateQueueBadge(count) {
const badge = $('queue-badge');
if (!badge) return;
if (count > 0) {
badge.textContent = count;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}
function renderSessionList(sessions, append = false) {
const html = sessions.map(s => {
const isDefault = !s.user_name || s.user_name === 'default name' || s.user_name === 'default_name';
const name = isDefault ? shortId(s.session_id || s.id) : s.user_name;
return `
<div class="session-item ${(s.session_id || s.id) === _activeId ? 'active' : ''} ${s.archived ? 'session-item-archived' : ''}" data-id="${s.session_id || s.id}">
<div class="session-item-header">
<span class="session-item-name" title="${name}">${name}</span>
<span class="session-item-time">${fmtDate(s.last_active).split(' ')[1] || ''}</span>
</div>
<div class="session-item-meta">
<span>${fmtDate(s.last_active).split(' ')[0] || ''}</span>
<span class="msg-badge">${s.message_count} msg</span>
</div>
</div>
`;
}).join('');
if (append) {
$('session-list').insertAdjacentHTML('beforeend', html);
} else {
$('session-list').innerHTML = html;
}
document.querySelectorAll('.session-item').forEach(el => {
el.onclick = () => openSession(el.dataset.id);
el.oncontextmenu = (e) => {
const sid = el.dataset.id;
const s = _allSessions.find(x => String(x.session_id || x.id) === String(sid));
window.showContextMenu(e, {
type: 'session',
id: sid,
isArchived: s && s.archived === true
});
};
});
}
// Infinite Scroll
$('session-list').addEventListener('scroll', () => {
const el = $('session-list');
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 50) {
loadSessions(false);
}
});
// Search debounce
let _searchTimeout;
$('search-inp').oninput = () => {
clearTimeout(_searchTimeout);
_searchTimeout = setTimeout(() => {
_searchQuery = $('search-inp').value;
loadSessions(true);
}, 300);
};
$('btn-refresh-sessions').onclick = () => { _sessionDataCache = {}; _activeSessionData = null; loadSessions(true); if (_activeId) openSession(_activeId, _activeChatId); };
$('btn-back-to-chats').onclick = () => openSession(_activeId);
async function openSession(id, chatId = null) {
if (!id || id === 'undefined') return;
// ALWAYS set _activeId so action buttons (takeover, block, etc.) work
_activeId = String(id);
if (!chatId) {
_activeChatId = null;
renderSessionList(_allSessions);
} else {
_activeChatId = chatId;
}
$('chat-placeholder').style.display = 'none';
$('chat-active').style.display = 'flex';
try {
// Use per-session cache for instant switching
let data = _sessionDataCache[id];
if (!data) {
$('messages-area').innerHTML = '<div class="spinner"></div>';
$('chat-list-area').style.display = 'none';
$('messages-area').style.display = 'flex';
const r = await fetch('/api/admin/sessions/' + encodeURIComponent(id), { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error('Fetch failed');
data = await r.json();
_sessionDataCache[id] = data;
}
_activeSessionData = data;
const currentChatId = data.current_chat_id || 1;
// Only show chat-list if multiple chats exist AND no specific chat is requested
if (!chatId && currentChatId > 1) {
renderChatList(data);
return;
}
const targetChatId = chatId || currentChatId;
_activeChatId = targetChatId;
renderChatMessages(data, targetChatId);
} catch (e) {
console.error(e);
$('messages-area').innerHTML = '<div style="color:red; text-align:center; margin-top:20px;">Failed to load.</div>';
}
}
function renderChatList(data) {
$('messages-area').style.display = 'none';
$('chat-reply-area').classList.add('hidden');
$('btn-back-to-chats').style.display = 'none';
$('chat-list-area').style.display = 'flex';
$('btn-delete-current-chat').style.display = 'none';
$('chat-user-name').textContent = data.user_name || 'Guest User';
$('chat-meta').textContent = 'ID: ' + data.session_id + ' • Select a chat thread';
const msgs = data.messages || [];
const currentChatId = data.current_chat_id || 1;
let html = '';
for (let i = currentChatId; i >= 1; i--) {
const isThreadArchived = (data.archived_chats || []).includes(i);
const isSessionArchived = data.archived === true;
const isEffectivelyArchived = isThreadArchived || isSessionArchived;
// Hide archived chat threads unless we are currently in the Archived tab
if (_currentFilter === 'archived') {
if (!isEffectivelyArchived) continue;
} else {
if (isEffectivelyArchived) continue;
}
const chatMsgs = msgs.filter(m => (m.chat_id || 1) === i);
if (chatMsgs.length === 0 && i !== currentChatId) continue;
const firstUserMsg = chatMsgs.find(m => m.question);
const firstQuestion = firstUserMsg ? firstUserMsg.question : 'New Chat';
const truncatedQ = firstQuestion.length > 80 ? firstQuestion.substring(0, 80) + '...' : firstQuestion;
const chatRating = (data.chat_ratings || {})[String(i)];
const ratingBadge = chatRating
? `<span style="font-size:12px; color:#f59e0b; font-weight:700; background:#fef9c3; border:1px solid #fde68a; border-radius:20px; padding:2px 8px;">&#9733; ${chatRating}/5</span>`
: '';
html += `
<div class="session-item chat-thread-item ${isEffectivelyArchived ? 'session-item-archived' : ''}" data-chat-id="${i}" style="padding:16px; background:var(--surface); border:1px solid var(--border); border-radius:12px; cursor:pointer;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<div style="font-weight:600; font-size:15px; color:var(--text);">Chat ${i}</div>
${ratingBadge}
</div>
<div style="font-size:13px; color:var(--text2); margin-bottom:8px;">"${esc(truncatedQ)}"</div>
<div style="font-size:11px; color:var(--text3);">${chatMsgs.length} messages</div>
</div>
`;
}
$('chat-list-area').innerHTML = html;
document.querySelectorAll('.chat-thread-item').forEach(el => {
const chatId = parseInt(el.dataset.chatId);
el.onclick = () => openSession(data.session_id, chatId);
el.oncontextmenu = (e) => {
const isEnded = (chatId < currentChatId) || (data.status === 'ENDED');
const isThreadArchived = (data.archived_chats || []).includes(chatId);
const isSessionArchived = data.archived === true;
window.showContextMenu(e, {
type: 'thread',
id: data.session_id,
chatId: chatId,
isEnded: isEnded,
isArchived: isThreadArchived || isSessionArchived,
isSessionArchived: isSessionArchived
});
};
});
$('btn-takeover').style.display = 'none';
$('btn-end-chat').style.display = 'none';
$('btn-export-single').style.display = 'none';
$('btn-block-user').style.display = 'none';
}
function renderChatMessages(data, chatId) {
$('chat-list-area').style.display = 'none';
$('messages-area').style.display = 'flex';
if ((data.current_chat_id || 1) > 1) {
$('btn-back-to-chats').style.display = 'flex';
} else {
$('btn-back-to-chats').style.display = 'none';
}
$('chat-user-name').textContent = (data.user_name || 'Guest User') + ((data.current_chat_id || 1) > 1 ? ` (Chat ${chatId})` : '');
const msgs = data.messages || [];
const filteredMsgs = msgs.filter(m => (m.chat_id || 1) === chatId);
$('chat-meta').textContent = 'ID: ' + data.session_id + ' • ' + filteredMsgs.length + ' messages total';
if (!filteredMsgs.length) {
$('messages-area').innerHTML = '<div style="text-align:center;color:var(--text3);margin-top:40px">No conversation history.</div>';
} else {
let html = '';
filteredMsgs.forEach(m => {
if (m.question) {
html += `<div class="msg-wrapper user"><div class="msg-meta">User • ${fmtDate(m.timestamp).split(' ')[1] || ''}</div><div class="msg-bubble">${esc(m.question)}</div></div>`;
}
if (m.answer) {
html += `<div class="msg-wrapper bot"><div class="msg-meta">${!m.question ? 'Human Agent' : 'AI Agent'}${fmtDate(m.timestamp).split(' ')[1] || ''}</div><div class="msg-bubble">${esc(m.answer)}</div></div>`;
}
});
$('messages-area').innerHTML = html;
$('messages-area').scrollTop = $('messages-area').scrollHeight;
}
_activeStatus = data.status || 'AI';
_activeBlocked = data.blocked || false;
const isLatestChat = chatId === (data.current_chat_id || 1);
const btnTakeover = $('btn-takeover');
const btnEndChat = $('btn-end-chat');
$('btn-export-single').style.display = 'inline-block';
$('btn-delete-current-chat').style.display = 'inline-block';
$('btn-block-user').style.display = 'inline-block';
if (_activeStatus === 'ENDED' || !isLatestChat) {
btnTakeover.style.display = 'none';
btnEndChat.style.display = 'none';
$('chat-reply-area').classList.add('hidden');
} else {
btnTakeover.style.display = 'inline-block';
btnEndChat.style.display = 'inline-block';
if (_activeStatus === 'HUMAN_TAKEOVER') {
btnTakeover.textContent = 'Hand back to AI';
btnTakeover.style.color = '#ef4444';
btnTakeover.style.borderColor = '#ef4444';
$('chat-reply-area').classList.remove('hidden');
} else {
btnTakeover.textContent = 'Take Over';
btnTakeover.style.color = 'var(--accent)';
btnTakeover.style.borderColor = 'var(--accent)';
$('chat-reply-area').classList.add('hidden');
}
}
// Update Block Button
const btnBlock = $('btn-block-user');
if (_activeBlocked) {
btnBlock.textContent = 'Unblock User';
btnBlock.style.color = '#10b981';
btnBlock.style.borderColor = '#10b981';
} else {
btnBlock.textContent = 'Block User';
btnBlock.style.color = 'var(--text2)';
btnBlock.style.borderColor = 'var(--border)';
}
}
function esc(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
$('btn-delete-session').onclick = async () => {
if (!_activeId || !confirm('Permanently delete this entire session and all its chats?')) return;
try {
await fetch('/api/admin/sessions/' + encodeURIComponent(_activeId), { method: 'DELETE', headers: { Authorization: authHeader() } });
showToast('Session deleted');
// Remove notifications for this session
_notifications = _notifications.filter(n => n.sessionId !== _activeId);
_saveNotifications();
renderNotifications();
_updateBadge();
// Clean from localStorage ID lists
try {
const ids = JSON.parse(localStorage.getItem('martech_notified_ids') || '[]');
localStorage.setItem('martech_notified_ids', JSON.stringify(ids.filter(id => id !== _activeId)));
const alertIds = JSON.parse(localStorage.getItem('martech_alert_ids') || '{}');
delete alertIds[_activeId];
localStorage.setItem('martech_alert_ids', JSON.stringify(alertIds));
} catch (e) { }
delete _sessionDataCache[_activeId];
_activeId = null;
_activeSessionData = null;
$('chat-placeholder').style.display = 'flex';
$('chat-active').style.display = 'none';
loadSessions(); loadAnalytics();
} catch (e) { showToast('Delete failed', 'error'); }
};
$('btn-delete-current-chat').onclick = async () => {
if (!_activeId || !_activeChatId || !confirm('Permanently delete this specific chat thread?')) return;
try {
const r = await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/chats/${_activeChatId}`, {
method: 'DELETE',
headers: { Authorization: authHeader() }
});
if (r.ok) {
showToast('Chat deleted');
invalidateSessionCache(_activeId);
openSession(_activeId); // re-opens session, which will go back to chat list if multiple chats exist, or load remaining chat
} else {
showToast('Delete failed', 'error');
}
} catch (e) { showToast('Delete failed', 'error'); }
};
// Takeover Modal Logic
document.querySelectorAll('input[name="takeover-role"]').forEach(radio => {
radio.addEventListener('change', (e) => {
document.querySelectorAll('#takeover-modal .export-option').forEach(el => el.classList.remove('selected'));
e.target.closest('.export-option').classList.add('selected');
if (e.target.value === 'custom') {
$('inp-custom-role').style.display = 'block';
$('inp-custom-role').focus();
} else {
$('inp-custom-role').style.display = 'none';
}
});
});
$('takeover-modal-close').onclick = () => $('takeover-modal').classList.remove('open');
$('btn-takeover').onclick = async () => {
if (!_activeId) return;
if (_activeStatus === 'HUMAN_TAKEOVER') {
try {
const r = await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/end_takeover`, {
method: 'POST', headers: { Authorization: authHeader() }
});
if (r.ok) {
_activeStatus = 'AI';
// Inform the user that the AI is back
const leavingRole = window._activeTakeoverRoleName || 'The representative';
await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader() },
body: JSON.stringify({ message: `${leavingRole} has left the chat. I am your AI assistant again—how can I help you?`, chat_id: _activeChatId })
});
invalidateSessionCache(_activeId);
openSession(_activeId, _activeChatId);
} else {
showToast('Action failed', 'error');
}
} catch (e) { showToast('Action failed', 'error'); }
} else {
$('takeover-modal').classList.add('open');
}
};
$('btn-confirm-takeover').onclick = async () => {
if (!_activeId) return;
const role = document.querySelector('input[name="takeover-role"]:checked').value;
let msg = "";
if (role === 'hr') {
window._activeTakeoverRoleName = "Human Resources";
msg = "Hello, I am from Human Resources. I understand you need some assistance. How may I help you today?";
} else if (role === 'supervising_authority') {
window._activeTakeoverRoleName = "The Supervising Authority";
msg = "Hello, this is the Supervising Authority. I have taken over this chat to assist you further. What can I do for you?";
} else if (role === 'custom') {
const customRole = $('inp-custom-role').value.trim() || 'The representative';
window._activeTakeoverRoleName = customRole;
msg = `Hello, I am ${customRole}. How may I assist you today?`;
}
$('btn-confirm-takeover').textContent = 'Processing...';
$('btn-confirm-takeover').disabled = true;
try {
let r = await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/takeover`, {
method: 'POST', headers: { Authorization: authHeader() }
});
if (r.ok) {
_activeStatus = 'HUMAN_TAKEOVER';
await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader() },
body: JSON.stringify({ message: msg, chat_id: _activeChatId })
});
$('takeover-modal').classList.remove('open');
invalidateSessionCache(_activeId);
openSession(_activeId, _activeChatId);
} else {
showToast('Takeover failed', 'error');
}
} catch (e) {
showToast('Takeover failed', 'error');
} finally {
$('btn-confirm-takeover').textContent = 'Confirm Takeover';
$('btn-confirm-takeover').disabled = false;
}
};
$('btn-end-chat').onclick = async () => {
if (!_activeId || !confirm('Are you sure you want to completely end this chat? The user will be disconnected.')) return;
try {
const r = await fetch(`/api/admin/sessions/${encodeURIComponent(_activeId)}/end_chat`, {
method: 'POST',
headers: { Authorization: authHeader() }
});
if (r.ok) {
_activeStatus = 'ENDED';
invalidateSessionCache(_activeId);
openSession(_activeId);
}
} catch (e) { }
};
$('btn-block-user').onclick = async () => {
if (!_activeId) return;
try {
const endpoint = _activeBlocked ? 'unblock' : 'block';
const r = await fetch('/api/admin/sessions/' + encodeURIComponent(_activeId) + '/' + endpoint, { method: 'POST', headers: { Authorization: authHeader() } });
if (r.ok) {
showToast(_activeBlocked ? 'User unblocked' : 'User blocked');
invalidateSessionCache(_activeId);
openSession(_activeId);
loadSessions(); // refresh session list to show block status
}
} catch (e) { showToast('Action failed', 'error'); }
};
$('btn-send-reply').onclick = async () => {
if (!_activeId) return;
const msg = $('inp-admin-reply').value.trim();
if (!msg) return;
$('inp-admin-reply').value = '';
$('btn-send-reply').disabled = true;
try {
const r = await fetch('/api/admin/sessions/' + encodeURIComponent(_activeId) + '/reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader() },
body: JSON.stringify({ message: msg, chat_id: _activeChatId })
});
if (r.ok) {
invalidateSessionCache(_activeId);
await openSession(_activeId, _activeChatId);
showToast('Message sent');
} else {
showToast('Send failed', 'error');
}
} catch (e) { showToast('Send failed', 'error'); }
finally { $('btn-send-reply').disabled = false; }
};
$('inp-admin-reply').onkeydown = e => { if (e.key === 'Enter') $('btn-send-reply').click(); };
// Auto Refresh
async function silentRefreshActive() {
if (!_activeId || !_activeChatId) return;
try {
const r = await fetch('/api/admin/sessions/' + encodeURIComponent(_activeId), { headers: { Authorization: authHeader() } });
if (!r.ok) return;
const newData = await r.json();
const oldMsgCount = (_activeSessionData?.messages || []).length;
const newMsgCount = (newData.messages || []).length;
const oldStatus = _activeSessionData?.status;
const oldBlocked = _activeSessionData?.blocked;
const needsUpdate = newMsgCount > oldMsgCount ||
newData.status !== oldStatus ||
newData.blocked !== oldBlocked;
if (needsUpdate) {
_sessionDataCache[_activeId] = newData;
_activeSessionData = newData;
renderChatMessages(newData, _activeChatId);
}
} catch (e) { }
}
function setupAutoRefresh() {
clearInterval(_refreshInterval);
if ($('toggle-refresh').checked) {
_refreshInterval = setInterval(() => {
// Only silently refresh the active open chat — session list polling is
// already handled by the main setInterval in initApp() to avoid double-fetching.
const activeView = document.querySelector('.view-container.active');
if (!activeView) return;
if (activeView.id === 'view-dashboard') loadAnalytics();
if (activeView.id === 'view-sessions') {
silentRefreshActive(); // Silently refresh open chat to show new user messages
}
}, 3000); // 3 seconds — extremely fast notifications
}
}
$('toggle-refresh').onchange = setupAutoRefresh;
// ── Export functionality ──
let _exportMode = 'global';
$('btn-open-export').onclick = () => {
_exportMode = 'global';
document.querySelector('#export-modal h3').textContent = 'Export Chat History';
$('opt-all').style.display = 'block';
$('opt-zip').style.display = 'block';
$('export-modal').classList.add('open');
};
$('export-modal-close').onclick = () => $('export-modal').classList.remove('open');
document.querySelectorAll('input[name="export-scope"]').forEach(rad => {
rad.onchange = () => {
$('opt-all').classList.toggle('selected', $('opt-all').querySelector('input').checked);
$('opt-zip').classList.toggle('selected', $('opt-zip').querySelector('input').checked);
};
});
let _exportFmt = 'excel';
document.querySelectorAll('.format-btn').forEach(btn => {
btn.onclick = () => {
document.querySelectorAll('.format-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_exportFmt = btn.dataset.fmt;
}
});
async function downloadFile(url, filename) {
const r = await fetch(url, { headers: { Authorization: authHeader() } });
if (!r.ok) throw new Error('Fetch failed');
const blob = await r.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename; a.click();
}
$('btn-export-single').onclick = () => {
if (!_activeId) return;
_exportMode = 'single';
document.querySelector('#export-modal h3').textContent = 'Export Specific Chat';
$('opt-all').style.display = 'none';
$('opt-zip').style.display = 'none';
$('export-modal').classList.add('open');
};
$('btn-submit-export').onclick = async () => {
const scope = document.querySelector('input[name="export-scope"]:checked').value;
const fmt = _exportFmt;
const btn = $('btn-submit-export');
btn.disabled = true; btn.textContent = 'Generating...';
try {
if (_exportMode === 'single') {
const ext = fmt === 'excel' ? 'xlsx' : 'json';
await downloadFile(`/api/admin/export/session/${encodeURIComponent(_activeId)}?format=${fmt}`, `session_${_activeId}.${ext}`);
showToast('Export downloaded');
$('export-modal').classList.remove('open');
} else if (scope === 'all') {
const ext = fmt === 'excel' ? 'xlsx' : 'json';
await downloadFile(`/api/admin/export/all?format=${fmt}`, `all_chats_export.${ext}`);
showToast('Export downloaded');
} else {
if (!_allSessions.length) throw new Error('No sessions');
const zip = new JSZip();
const ext = fmt === 'excel' ? 'xlsx' : 'json';
for (const s of _allSessions) {
try {
const r = await fetch(`/api/admin/export/session/${encodeURIComponent(s.session_id)}?format=${fmt}`, { headers: { Authorization: authHeader() } });
if (r.ok) {
const blob = await r.blob();
const name = (s.user_name || s.session_id).replace(/[^a-z0-9_\-]/gi, '_');
zip.file(`${name}_${s.session_id.slice(-6)}.${ext}`, blob);
}
} catch (e) { }
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
const a = document.createElement('a');
a.href = URL.createObjectURL(zipBlob);
a.download = `chats_export_${new Date().toISOString().slice(0, 10)}.zip`;
a.click();
showToast('ZIP Export downloaded');
}
$('export-modal').classList.remove('open');
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false; btn.textContent = 'Generate Export';
}
};
// ── WhatsApp Settings ──
function renderWhatsAppNumberInput(value = '') {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.gap = '8px';
div.innerHTML = `
<input type="text" class="inp-whatsapp-number" value="${value}" placeholder="e.g. 1234567890" style="flex:1; padding:12px 16px; border:1px solid var(--border); border-radius:12px; font-size:14px; outline:none; background:var(--surface); color:var(--text1);" />
<button type="button" class="btn-remove-number" style="background:transparent; border:none; color:var(--red); cursor:pointer; padding:0 8px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
`;
div.querySelector('.btn-remove-number').onclick = () => {
div.remove();
if (document.querySelectorAll('.inp-whatsapp-number').length === 0) {
renderWhatsAppNumberInput();
}
};
$('whatsapp-numbers-container').appendChild(div);
}
$('btn-add-whatsapp-number').onclick = () => renderWhatsAppNumberInput();
$('inp-whatsapp-provider').addEventListener('change', (e) => {
if (e.target.value === 'baileys') {
$('meta-settings-group').style.display = 'none';
$('baileys-settings-group').style.display = 'block';
} else {
$('meta-settings-group').style.display = 'block';
$('baileys-settings-group').style.display = 'none';
}
});
async function loadWhatsAppSettings() {
try {
const r = await fetch('/api/admin/settings/whatsapp', { headers: { Authorization: authHeader() } });
if (r.ok) {
const data = await r.json();
$('whatsapp-numbers-container').innerHTML = '';
if (data.admin_number) {
const nums = data.admin_number.split(',').map(n => n.trim()).filter(Boolean);
if (nums.length) {
nums.forEach(n => renderWhatsAppNumberInput(n));
} else {
renderWhatsAppNumberInput();
}
} else {
renderWhatsAppNumberInput();
}
if (data.provider) {
$('inp-whatsapp-provider').value = data.provider;
$('inp-whatsapp-provider').dispatchEvent(new Event('change'));
}
$('inp-whatsapp-phone-id').value = data.phone_id || '';
$('inp-whatsapp-token').value = data.token ? '••••••••••••' : '';
$('inp-baileys-url').value = data.baileys_url || '';
$('inp-baileys-token').value = data.baileys_token ? '••••••••••••' : '';
}
} catch (e) { console.error('Failed to load WhatsApp settings', e); }
}
$('btn-save-whatsapp').onclick = async () => {
const inputs = Array.from(document.querySelectorAll('.inp-whatsapp-number'))
.map(i => i.value.trim().replace(/[^0-9]/g, ''))
.filter(Boolean);
const number = inputs.join(',');
const provider = $('inp-whatsapp-provider').value;
const phoneId = $('inp-whatsapp-phone-id').value.trim();
const token = $('inp-whatsapp-token').value.trim();
const baileysUrl = $('inp-baileys-url').value.trim();
const baileysToken = $('inp-baileys-token').value.trim();
if (!number) { showToast('Please enter a WhatsApp number', 'error'); return; }
$('btn-save-whatsapp').disabled = true;
$('btn-save-whatsapp').textContent = 'Saving...';
try {
const body = { provider: provider, admin_number: number, phone_id: phoneId, baileys_url: baileysUrl };
if (token && !token.startsWith('••')) body.token = token;
if (baileysToken && !baileysToken.startsWith('••')) body.baileys_token = baileysToken;
const r = await fetch('/api/admin/settings/whatsapp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: authHeader() },
body: JSON.stringify(body)
});
if (r.ok) {
showToast('WhatsApp settings saved');
$('whatsapp-status').innerHTML = '<span style="color:var(--accent);">✓ Settings saved successfully</span>';
} else {
showToast('Save failed', 'error');
}
} catch (e) { showToast('Save failed', 'error'); }
finally {
$('btn-save-whatsapp').disabled = false;
$('btn-save-whatsapp').textContent = 'Save Settings';
}
};
$('btn-test-whatsapp').onclick = async () => {
$('btn-test-whatsapp').disabled = true;
$('btn-test-whatsapp').textContent = 'Sending...';
try {
const r = await fetch('/api/admin/settings/whatsapp/test', {
method: 'POST',
headers: { Authorization: authHeader() }
});
const data = await r.json();
if (r.ok) {
showToast('Test message sent!');
$('whatsapp-status').innerHTML = '<span style="color:var(--accent);">✓ Test message sent to admin number</span>';
} else {
showToast(data.detail || 'Test failed', 'error');
$('whatsapp-status').innerHTML = `<span style="color:var(--red);">✗ ${data.detail || 'Failed to send test message'}</span>`;
}
} catch (e) { showToast('Test failed', 'error'); }
finally {
$('btn-test-whatsapp').disabled = false;
$('btn-test-whatsapp').textContent = 'Send Test Message';
}
};
// ── Baileys QR Code ──
$('btn-baileys-qr').onclick = async () => {
const btn = $('btn-baileys-qr');
const status = $('baileys-qr-status');
const img = $('baileys-qr-img');
btn.disabled = true;
img.style.display = 'none';
let retryCount = 0;
const maxRetries = 15; // 45 seconds max
const pollQR = async () => {
status.innerHTML = '<span class="spinner" style="display:inline-block; width:14px; height:14px; border-width:2px; vertical-align:middle; margin-right:6px;"></span> Fetching QR code (this takes up to 20s)...';
try {
const r = await fetch('/api/admin/settings/whatsapp/baileys/qr', {
headers: { Authorization: authHeader() }
});
const data = await r.json();
if (!r.ok) throw new Error(data.detail || 'Failed to fetch QR');
if (data.status === 'connected') {
status.innerHTML = '<span style="color:var(--green); font-weight:600;">✓ WhatsApp is already connected!</span>';
btn.disabled = false;
} else if (data.status === 'generating') {
retryCount++;
if (retryCount >= maxRetries) {
status.innerHTML = '<span style="color:var(--red); font-weight:600;">Timed out waiting for QR code. Please click fetch again.</span>';
btn.disabled = false;
} else {
setTimeout(pollQR, 3000);
}
} else if (data.qr_base64) {
status.innerHTML = 'Scan this code with WhatsApp:';
img.src = data.qr_base64;
img.style.display = 'block';
btn.disabled = false;
} else {
status.innerHTML = 'Unexpected response from Baileys server.';
btn.disabled = false;
}
} catch (e) {
status.innerHTML = `<span style="color:var(--red);">Error: ${e.message}</span>`;
btn.disabled = false;
}
};
pollQR();
};
// ── Settings Panel Navigation ──
function initSettingsPanel() {
document.querySelectorAll('.settings-nav-item').forEach(el => {
el.onclick = () => {
document.querySelectorAll('.settings-nav-item').forEach(n => n.classList.remove('active'));
document.querySelectorAll('.settings-section').forEach(s => s.classList.remove('active'));
el.classList.add('active');
$('settings-sec-' + el.dataset.settingsSec).classList.add('active');
};
});
}
// ── Notification settings: persist each toggle in localStorage ──
const NOTIF_SETTINGS_KEY = 'martech_notif_settings';
function loadNotifSettings() {
let saved = {};
try { saved = JSON.parse(localStorage.getItem(NOTIF_SETTINGS_KEY) || '{}'); } catch (e) { }
// Defaults: os_popup ON, sound_background ON, sound_foreground ON, sound_onfocus OFF, title_flash ON
const defaults = { os_popup: true, sound_background: true, sound_foreground: true, sound_onfocus: false, title_flash: true };
return Object.assign({}, defaults, saved);
}
function saveNotifSetting(key, value) {
const current = loadNotifSettings();
current[key] = value;
localStorage.setItem(NOTIF_SETTINGS_KEY, JSON.stringify(current));
}
window.saveNotifSetting = saveNotifSetting;
function applyNotifSettingsToUI() {
const s = loadNotifSettings();
const togOs = $('tog-notif-os');
const togBg = $('tog-notif-bgsound');
const togFg = $('tog-notif-fgsound');
const togReplay = $('tog-notif-replay');
const togFlash = $('tog-notif-flash');
const badgeOs = $('badge-notif-os');
if (togOs) togOs.checked = s.os_popup;
if (togBg) togBg.checked = s.sound_background;
if (togFg) togFg.checked = s.sound_foreground;
if (togReplay) togReplay.checked = s.sound_onfocus;
if (togFlash) togFlash.checked = s.title_flash;
// OS badge reflects actual browser permission, not just the toggle
if (badgeOs) {
const perm = 'Notification' in window ? Notification.permission : 'denied';
badgeOs.textContent = perm === 'granted' ? 'Granted' : perm === 'denied' ? 'Blocked' : 'Not set';
badgeOs.style.background = perm === 'granted' ? '#dcfce7' : '#fee2e2';
badgeOs.style.color = perm === 'granted' ? '#15803d' : '#b91c1c';
}
}
// Resizer Logic
const resizer = document.getElementById('sidebar-resizer');
const sidebar = document.getElementById('sidebar');
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
resizer.classList.add('dragging');
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const newWidth = e.clientX;
if (newWidth >= 200 && newWidth <= 600) {
sidebar.style.flexBasis = newWidth + 'px';
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
resizer.classList.remove('dragging');
document.body.style.cursor = 'default';
}
});
// ── Custom Context Menu Logic ──
let _contextTarget = null; // { type: 'session' | 'thread', id: string, chatId?: int, isEnded: boolean, isArchived: boolean }
document.addEventListener('click', (e) => {
const m = $('custom-context-menu');
if (!m) return;
if (!m.contains(e.target)) {
m.style.display = 'none';
_contextTarget = null;
}
});
window.showContextMenu = function (e, targetObj) {
e.preventDefault();
_contextTarget = targetObj;
const ctxMenu = $('custom-context-menu');
if (!ctxMenu) return;
const actionArchive = $('ctx-action-archive');
const actionUnarchive = $('ctx-action-unarchive');
const actionBlock = $('ctx-action-block');
// Configure options
if (targetObj.type === 'session') {
if (actionBlock) actionBlock.style.display = 'flex';
if (targetObj.isArchived) {
if (actionArchive) actionArchive.style.display = 'none';
if (actionUnarchive) actionUnarchive.style.display = 'flex';
} else {
if (actionArchive) actionArchive.style.display = 'flex';
if (actionUnarchive) actionUnarchive.style.display = 'none';
}
} else if (targetObj.type === 'thread') {
if (actionBlock) actionBlock.style.display = 'none';
if (targetObj.isArchived) {
if (actionArchive) actionArchive.style.display = 'none';
if (actionUnarchive) actionUnarchive.style.display = 'flex';
} else {
if (actionArchive) actionArchive.style.display = 'flex';
if (actionUnarchive) actionUnarchive.style.display = 'none';
}
}
ctxMenu.style.display = 'flex';
// Bounds checking
let x = e.clientX;
let y = e.clientY;
if (x + ctxMenu.offsetWidth > window.innerWidth) x -= ctxMenu.offsetWidth;
if (y + ctxMenu.offsetHeight > window.innerHeight) y -= ctxMenu.offsetHeight;
ctxMenu.style.left = x + 'px';
ctxMenu.style.top = y + 'px';
_bindContextMenuActions();
};
function _bindContextMenuActions() {
const ctxMenu = $('custom-context-menu');
const actionArchive = $('ctx-action-archive');
const actionUnarchive = $('ctx-action-unarchive');
const actionBlock = $('ctx-action-block');
const actionDelete = $('ctx-action-delete');
if (actionArchive) actionArchive.onclick = async (e) => {
e.stopPropagation();
ctxMenu.style.display = 'none';
if (!_contextTarget) return;
if (_contextTarget.type === 'thread' && !_contextTarget.isEnded) {
showToast('Cannot archive an active chat. End the chat first.', 'error');
return;
}
try {
const url = _contextTarget.type === 'session'
? '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/archive'
: '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/chats/' + _contextTarget.chatId + '/archive';
// Optimistic UI
if (_contextTarget.type === 'session') {
const s = _allSessions.find(x => String(x.session_id || x.id) === String(_contextTarget.id));
if (s) s.archived = true;
renderSessionList(_allSessions);
} else if (_contextTarget.type === 'thread') {
const s = _allSessions.find(x => String(x.session_id || x.id) === String(_contextTarget.id));
if (s) {
if (!s.archived_chats) s.archived_chats = [];
if (!s.archived_chats.includes(_contextTarget.chatId)) {
s.archived_chats.push(_contextTarget.chatId);
}
// Immediately re-render the chat list to hide it
if (String(_activeId) === String(s.session_id || s.id)) {
if (_activeSessionData) {
if (!_activeSessionData.archived_chats) _activeSessionData.archived_chats = [];
if (!_activeSessionData.archived_chats.includes(_contextTarget.chatId)) {
_activeSessionData.archived_chats.push(_contextTarget.chatId);
}
renderChatList(_activeSessionData);
}
}
}
}
const res = await fetch(url, { method: 'POST', headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error('Failed');
showToast('Archived successfully');
} catch (e) {
showToast('Failed to archive', 'error');
loadSessions(); // Rollback
}
};
if (actionUnarchive) actionUnarchive.onclick = async () => {
ctxMenu.style.display = 'none';
if (!_contextTarget) return;
try {
let url;
if (_contextTarget.type === 'session') {
// Unarchiving the whole session
url = '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/unarchive';
// Optimistic UI: mark session as unarchived
const s = _allSessions.find(x => String(x.session_id || x.id) === String(_contextTarget.id));
if (s) s.archived = false;
renderSessionList(_allSessions);
// Invalidate cache so the re-opened session loads fresh data
invalidateSessionCache(_contextTarget.id);
} else {
// Unarchiving a single chat thread.
// ALWAYS use the chat-level endpoint — even if the parent session was
// archived, because we only want to restore this one thread, not the
// whole session. The backend should handle promoting the session out of
// archived state if it has at least one active thread.
url = '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) +
'/chats/' + _contextTarget.chatId + '/unarchive';
// Optimistic UI: remove this chatId from archived_chats only
const s = _allSessions.find(x => String(x.session_id || x.id) === String(_contextTarget.id));
if (s) {
if (!s.archived_chats) s.archived_chats = [];
const idx = s.archived_chats.indexOf(_contextTarget.chatId);
if (idx > -1) s.archived_chats.splice(idx, 1);
// If the session was fully archived, also clear session-level archived
// flag so it reappears in live sessions
if (_contextTarget.isSessionArchived) s.archived = false;
}
renderSessionList(_allSessions);
// Update the currently-open session data if it matches
if (String(_activeId) === String(_contextTarget.id)) {
if (_activeSessionData) {
if (!_activeSessionData.archived_chats) _activeSessionData.archived_chats = [];
const idx2 = _activeSessionData.archived_chats.indexOf(_contextTarget.chatId);
if (idx2 > -1) _activeSessionData.archived_chats.splice(idx2, 1);
if (_contextTarget.isSessionArchived) _activeSessionData.archived = false;
renderChatList(_activeSessionData);
}
// Invalidate cache so clicking the thread loads fresh data from server
invalidateSessionCache(_contextTarget.id);
}
}
const res = await fetch(url, { method: 'POST', headers: { Authorization: authHeader() } });
if (!res.ok) throw new Error('Failed');
showToast('Unarchived successfully');
// Reload the session list from server to get the authoritative state
loadSessions();
} catch (e) {
showToast('Failed to unarchive', 'error');
loadSessions(); // Rollback
}
};
if (actionBlock) actionBlock.onclick = async () => {
ctxMenu.style.display = 'none';
if (!_contextTarget || _contextTarget.type !== 'session') return;
if (!confirm('Block this user completely?')) return;
try {
await fetch('/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/block', { method: 'POST', headers: { Authorization: authHeader() } });
showToast('User blocked');
loadSessions();
} catch (e) { showToast('Block failed', 'error'); }
};
if (actionDelete) actionDelete.onclick = async () => {
ctxMenu.style.display = 'none';
if (!_contextTarget) return;
if (!confirm('Permanently delete this ' + _contextTarget.type + '?')) return;
try {
if (_contextTarget.type === 'session') {
await fetch('/api/admin/sessions/' + encodeURIComponent(_contextTarget.id), { method: 'DELETE', headers: { Authorization: authHeader() } });
} else {
await fetch('/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/chats/' + _contextTarget.chatId, { method: 'DELETE', headers: { Authorization: authHeader() } });
}
showToast('Deleted successfully');
if (_contextTarget.type === 'session' && String(_activeId) === String(_contextTarget.id)) {
_activeId = null;
$('chat-placeholder').style.display = 'flex';
$('chat-active').style.display = 'none';
} else if (_contextTarget.type === 'thread' && String(_activeId) === String(_contextTarget.id) && String(_activeChatId) === String(_contextTarget.chatId)) {
openSession(_activeId); // go back to threads
}
loadSessions();
loadAnalytics();
} catch (e) { showToast('Delete failed', 'error'); }
};
}
})();
</script>
<!-- Custom Context Menu -->
<div id="custom-context-menu">
<div class="ctx-menu-item" id="ctx-action-archive">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="21 8 21 21 3 21 3 8"></polyline>
<rect x="1" y="3" width="22" height="5"></rect>
<line x1="10" y1="12" x2="14" y2="12"></line>
</svg>
<span>Archive</span>
</div>
<div class="ctx-menu-item" id="ctx-action-unarchive" style="display:none;">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3h18v18H3z"></path>
<path d="M8 12l4-4 4 4"></path>
<path d="M12 8v8"></path>
</svg>
<span>Unarchive</span>
</div>
<div class="ctx-menu-item" id="ctx-action-block">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
</svg>
<span>Block User</span>
</div>
<div class="ctx-divider"></div>
<div class="ctx-menu-item danger" id="ctx-action-delete">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"></path>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
<span>Delete</span>
</div>
</div>
</body>
</html>