| <!DOCTYPE html> |
| <html lang="en"> |
|
|
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width,initial-scale=1.0" /> |
| <title>Martechsol — Admin Dashboard</title> |
| <link rel="icon" href="/martech_sol_logo.jpg" type="image/jpeg" /> |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> |
| <style> |
| *, |
| *::before, |
| *::after { |
| box-sizing: border-box; |
| margin: 0; |
| padding: 0; |
| } |
| |
| :root { |
| --bg: #f8fafc; |
| --surface: #ffffff; |
| --surface-hover: #f1f5f9; |
| --border: #e2e8f0; |
| --accent: #8b5cf6; |
| --accent-hover: #7c3aed; |
| --accent2: #a855f7; |
| --accent-light: #ede9fe; |
| --green: #10b981; |
| --red: #ef4444; |
| --yellow: #f59e0b; |
| --text: #0f172a; |
| --text2: #475569; |
| --text3: #94a3b8; |
| --radius: 12px; |
| --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); |
| --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); |
| --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); |
| } |
| |
| html, |
| body { |
| height: 100%; |
| font-family: 'Inter', sans-serif; |
| background: var(--bg); |
| color: var(--text); |
| overflow: hidden; |
| } |
| |
| |
| .flex { |
| display: flex; |
| } |
| |
| .items-center { |
| align-items: center; |
| } |
| |
| .justify-between { |
| justify-content: space-between; |
| } |
| |
| .gap-2 { |
| gap: 8px; |
| } |
| |
| .gap-4 { |
| gap: 16px; |
| } |
| |
| .hidden { |
| display: none !important; |
| } |
| |
| |
| #login-screen { |
| 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 { |
| display: none; |
| height: 100vh; |
| width: 100vw; |
| flex-direction: row; |
| } |
| |
| |
| .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 { |
| 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 { |
| 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; |
| } |
| |
| |
| .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%; |
| } |
| |
| |
| #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; |
| } |
| |
| |
| #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); |
| } |
| |
| |
| .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); |
| } |
| |
| |
| @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; |
| } |
| |
| |
| .chat-reply-area { |
| display: flex; |
| } |
| |
| .chat-reply-area.hidden { |
| display: none !important; |
| } |
| |
| |
| #view-settings { |
| padding: 32px; |
| } |
| |
| |
| #view-sessions { |
| padding: 0; |
| } |
| |
| |
| .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-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 { |
| 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> |
|
|
| |
| <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> |
|
|
| |
| <div id="app"> |
|
|
| |
| <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 class="main-content" style="flex:1; min-width:0; display:flex; flex-direction:column; height:100vh;"> |
| |
| <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> |
|
|
| |
| <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> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div class="view-container" id="view-settings" style="padding:0; flex-direction:row !important ;"> |
|
|
| |
| <div |
| style="width:220px; flex-shrink:0; background:var(--surface); border-right:1px solid var(--border); display:flex; flex-direction:column; padding:20px 10px; gap:2px;"> |
| <div |
| style="font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text3); padding:8px 12px 4px;"> |
| General</div> |
|
|
| <div class="settings-nav-item active" data-settings-sec="notifications"> |
| <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"> |
| <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" /> |
| <path d="M13.73 21a2 2 0 0 1-3.46 0" /> |
| </svg> |
| Notifications |
| </div> |
| <div class="settings-nav-item" data-settings-sec="whatsapp"> |
| <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"> |
| <path |
| d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> |
| </svg> |
| WhatsApp |
| </div> |
|
|
| <div |
| style="font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--text3); padding:16px 12px 4px;"> |
| System</div> |
|
|
| <div class="settings-nav-item" data-settings-sec="config"> |
| <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"> |
| <circle cx="12" cy="12" r="3" /> |
| <path |
| d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" /> |
| </svg> |
| Config |
| </div> |
| <div class="settings-nav-item" data-settings-sec="security"> |
| <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"> |
| <rect x="3" y="11" width="18" height="11" rx="2" ry="2" /> |
| <path d="M7 11V7a5 5 0 0 1 10 0v4" /> |
| </svg> |
| Security |
| </div> |
| <div class="settings-nav-item" data-settings-sec="version"> |
| <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"> |
| <circle cx="12" cy="12" r="10" /> |
| <line x1="12" y1="16" x2="12" y2="12" /> |
| <line x1="12" y1="8" x2="12.01" y2="8" /> |
| </svg> |
| Version info |
| </div> |
| </div> |
|
|
| |
| <div style="flex:1; overflow-y:auto; padding:32px 40px;"> |
|
|
| |
| <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> |
| <span class="settings-badge" id="badge-notif-os">Checking…</span> |
| <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> |
|
|
| |
| <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;">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> |
| <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 |
| 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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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"> |
| |
| </div> |
| <div id="chat-list-area" |
| style="display:none; flex-direction:column; gap:16px; padding:32px; overflow-y:auto; flex:1;"> |
| |
| </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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| <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> |
|
|
| |
| <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 = {}; |
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
| 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); |
| |
| |
| 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: { |
| |
| |
| 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; |
| } |
| |
| |
| let _notifiedQueuedIds = new Set(); |
| let _notifications = []; |
| let _snoozed = localStorage.getItem('martech_snoozed') === '1'; |
| |
| |
| function _loadPersistedNotifications() { |
| try { |
| const saved = JSON.parse(localStorage.getItem('martech_notifications') || '[]'); |
| |
| 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) { } |
| } |
| |
| let _audioCtx = null; |
| let _audioKeepalive = null; |
| let _pendingSoundType = null; |
| let _titleFlashInterval = null; |
| 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); } |
| } |
| |
| |
| 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(); |
| } |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| let _notifAudioUrl = null; |
| let _disconnectAudioUrl = null; |
| |
| function _generateWavUrl(samples, sampleRate = 22050) { |
| |
| 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); |
| view.setUint16(22, 1, true); |
| 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); |
| |
| 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); |
| 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 => { |
| |
| console.warn('Audio element play blocked:', err); |
| }); |
| } catch (e) { console.warn('Audio element error:', e); } |
| } |
| |
| function playNotifSound() { |
| _initAudioUrls(); |
| _playAudioUrl(_notifAudioUrl); |
| |
| if (_audioCtx && document.visibilityState === 'visible') { |
| _scheduleBeeps([ |
| { type: 'sine', freqs: [[880, 0], [1100, 0.1], [880, 0.2]], startOffset: 0, stopOffset: 0.5 } |
| ]); |
| } |
| } |
| |
| function playDisconnectSound() { |
| _initAudioUrls(); |
| _playAudioUrl(_disconnectAudioUrl); |
| if (_audioCtx && document.visibilityState === 'visible') { |
| _scheduleBeeps([ |
| { type: 'triangle', freq: 440, freqEnd: 110, startOffset: 0, stopOffset: 0.3 } |
| ]); |
| } |
| } |
| 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; |
| } |
| |
| |
| 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)); |
| |
| _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(); |
| }; |
| |
| |
| (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) { } |
| })(); |
| |
| |
| |
| function _startTitleFlash(msg) { |
| if (_titleFlashInterval) return; |
| 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; |
| } |
| |
| |
| 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; |
| } |
| _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; |
| if (!('Notification' in window) || Notification.permission !== 'granted') return; |
| |
| |
| if ('serviceWorker' in navigator) { |
| const reg = await navigator.serviceWorker.getRegistration(); |
| if (reg) { |
| await reg.showNotification(title, { |
| body, |
| tag, |
| renotify: true, |
| 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; |
| } |
| } |
| |
| |
| const n = new Notification(title, { body, tag, silent: false }); |
| n.onclick = () => { window.focus(); n.close(); }; |
| } |
| |
| function initApp() { |
| _loadPersistedNotifications(); |
| $('toggle-snooze').checked = _snoozed; |
| |
| |
| |
| _initAudioCtx(); |
| |
| if (_audioCtx) _audioCtx.resume().catch(() => { }); |
| |
| _startAudioKeepalive(); |
| |
| registerServiceWorker(); |
| |
| |
| 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); } |
| } |
| |
| |
| |
| 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; |
| |
| 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(() => { }); |
| |
| |
| _initAudioUrls(); |
| localStorage.setItem('martech_audio_enabled', '1'); |
| playNotifSound(); |
| 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); |
| } |
| } |
| |
| |
| 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' } |
| } |
| }); |
| } |
| |
| |
| $('btn-notifications').onclick = (e) => { |
| e.stopPropagation(); |
| $('notif-dropdown').classList.toggle('show'); |
| if ($('notif-dropdown').classList.contains('show')) { |
| |
| _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) { |
| |
| if (sessionId) { |
| try { |
| const ids = JSON.parse(localStorage.getItem('martech_notified_ids') || '[]'); |
| if (!ids.includes(sessionId)) { |
| ids.push(sessionId); |
| |
| localStorage.setItem('martech_notified_ids', JSON.stringify(ids.slice(-50))); |
| } |
| |
| 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) { |
| |
| if (soundType === 'disconnect') playDisconnectSound(); |
| else playNotifSound(); |
| } |
| |
| if (document.visibilityState !== 'visible') { |
| |
| |
| if (ns.sound_onfocus) { |
| _pendingSoundType = soundType; |
| } |
| if (ns.title_flash) { |
| _startTitleFlash('New Alert!'); |
| } |
| } |
| } |
| |
| |
| 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(''); |
| |
| |
| 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)); |
| }; |
| }); |
| |
| |
| 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'; |
| } |
| |
| |
| 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 (_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; |
| } |
| } |
| |
| |
| 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'); |
| |
| |
| if (countEl) countEl.textContent = queued.length + (queued.length === 1 ? ' waiting' : ' waiting'); |
| |
| |
| _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; |
| } |
| |
| |
| 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(''); |
| |
| |
| container.querySelectorAll('.queue-item').forEach(el => { |
| el.onclick = (e) => { |
| if (e.target.classList.contains('queue-takeover-btn')) return; |
| const sid = el.dataset.sid; |
| |
| 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(); |
| _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'); |
| |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); |
| $('nav-sessions').classList.add('active'); |
| switchView('sessions'); |
| invalidateSessionCache(sessionId); |
| |
| |
| 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(); |
| } 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 |
| }); |
| }; |
| }); |
| } |
| |
| |
| $('session-list').addEventListener('scroll', () => { |
| const el = $('session-list'); |
| if (el.scrollTop + el.clientHeight >= el.scrollHeight - 50) { |
| loadSessions(false); |
| } |
| }); |
| |
| |
| 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; |
| |
| _activeId = String(id); |
| |
| if (!chatId) { |
| _activeChatId = null; |
| renderSessionList(_allSessions); |
| } else { |
| _activeChatId = chatId; |
| } |
| |
| $('chat-placeholder').style.display = 'none'; |
| $('chat-active').style.display = 'flex'; |
| |
| try { |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| 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; |
| |
| 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="font-weight:600; font-size:15px; color:var(--text); margin-bottom:6px;">Chat ${i}</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'); |
| } |
| } |
| |
| |
| 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'); |
| |
| |
| _notifications = _notifications.filter(n => n.sessionId !== _activeId); |
| _saveNotifications(); |
| renderNotifications(); |
| _updateBadge(); |
| |
| |
| 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); |
| } else { |
| showToast('Delete failed', 'error'); |
| } |
| } catch (e) { showToast('Delete failed', 'error'); } |
| }; |
| |
| |
| 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'; |
| |
| |
| 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(); |
| } |
| } 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(); }; |
| |
| |
| 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(() => { |
| |
| |
| const activeView = document.querySelector('.view-container.active'); |
| if (!activeView) return; |
| if (activeView.id === 'view-dashboard') loadAnalytics(); |
| if (activeView.id === 'view-sessions') { |
| silentRefreshActive(); |
| } |
| }, 3000); |
| } |
| } |
| $('toggle-refresh').onchange = setupAutoRefresh; |
| |
| |
| $('btn-open-export').onclick = () => $('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 = async () => { |
| if (!_activeId) return; |
| try { |
| const fmt = _exportFmt || 'excel'; |
| const ext = fmt === 'excel' ? 'xlsx' : 'json'; |
| await downloadFile(`/api/admin/export/session/${encodeURIComponent(_activeId)}?format=${fmt}`, `session_${_activeId}.${ext}`); |
| showToast('Export downloaded'); |
| } catch (e) { showToast('Export failed', 'error'); } |
| }; |
| |
| $('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 (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'; |
| } |
| }; |
| |
| |
| 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(); |
| |
| 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(); |
| } |
| |
| $('inp-whatsapp-phone-id').value = data.phone_id || ''; |
| $('inp-whatsapp-token').value = data.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 phoneId = $('inp-whatsapp-phone-id').value.trim(); |
| const token = $('inp-whatsapp-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 = { admin_number: number, phone_id: phoneId }; |
| |
| if (token && !token.startsWith('••')) body.token = token; |
| |
| 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'; |
| } |
| }; |
| |
| |
| 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'); |
| }; |
| }); |
| } |
| |
| |
| const NOTIF_SETTINGS_KEY = 'martech_notif_settings'; |
| |
| function loadNotifSettings() { |
| let saved = {}; |
| try { saved = JSON.parse(localStorage.getItem(NOTIF_SETTINGS_KEY) || '{}'); } catch (e) { } |
| |
| 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)); |
| } |
| |
| 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; |
| |
| |
| 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'; |
| } |
| } |
| |
| |
| 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'; |
| } |
| }); |
| |
| let _contextTarget = null; |
| |
| 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'); |
| |
| |
| 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'; |
| |
| 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'; |
| |
| |
| 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); |
| } |
| |
| 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(); |
| } |
| }; |
| |
| if (actionUnarchive) actionUnarchive.onclick = async () => { |
| ctxMenu.style.display = 'none'; |
| if (!_contextTarget) return; |
| |
| try { |
| let url; |
| if (_contextTarget.type === 'session') { |
| |
| url = '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + '/unarchive'; |
| |
| |
| const s = _allSessions.find(x => String(x.session_id || x.id) === String(_contextTarget.id)); |
| if (s) s.archived = false; |
| renderSessionList(_allSessions); |
| |
| invalidateSessionCache(_contextTarget.id); |
| |
| } else { |
| |
| |
| |
| |
| |
| url = '/api/admin/sessions/' + encodeURIComponent(_contextTarget.id) + |
| '/chats/' + _contextTarget.chatId + '/unarchive'; |
| |
| |
| 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 (_contextTarget.isSessionArchived) s.archived = false; |
| } |
| renderSessionList(_allSessions); |
| |
| |
| 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); |
| } |
| |
| invalidateSessionCache(_contextTarget.id); |
| } |
| } |
| |
| const res = await fetch(url, { method: 'POST', headers: { Authorization: authHeader() } }); |
| if (!res.ok) throw new Error('Failed'); |
| |
| showToast('Unarchived successfully'); |
| |
| loadSessions(); |
| } catch (e) { |
| showToast('Failed to unarchive', 'error'); |
| loadSessions(); |
| } |
| }; |
| |
| 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); |
| } |
| |
| loadSessions(); |
| loadAnalytics(); |
| } catch (e) { showToast('Delete failed', 'error'); } |
| }; |
| } |
| })(); |
| </script> |
| |
| <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> |