Spaces:
Running
Running
| <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 ; | |
| } | |
| /* ── 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 ; | |
| } | |
| /* 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> | |
| </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;">★ ${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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| } | |
| $('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> | |