Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_admin_chat.html
Diff
/var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_admin_chat.html
added on local at 2026-07-01 15:03:07
Added
+0
lines
Removed
-0
lines
Context
516
unchanged
Blobs
from 93bde25bc08b
to 93bde25bc08b
to 93bde25bc08b
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | <!-- ===================================================================== |
| 2 | 2 | Admin Chat -- 3-column layout: convo list / thread / visitor info. |
| 3 | 3 | ===================================================================== --> |
| 4 | 4 | |
| 5 | 5 | <style> |
| 6 | 6 | .ch-grid { display:grid; grid-template-columns:300px 1fr 260px; gap:14px; height:calc(100vh - 220px); min-height:520px; } |
| 7 | 7 | @media (max-width: 1100px) { .ch-grid { grid-template-columns:240px 1fr; } .ch-side { display:none; } } |
| 8 | 8 | @media (max-width: 800px) { .ch-grid { grid-template-columns:1fr; height:auto; } .ch-list { max-height:360px; overflow-y:auto; } } |
| 9 | 9 | |
| 10 | 10 | .ch-panel { background: var(--col-surface-1); border:1px solid var(--col-border); border-radius:12px; overflow:hidden; display:flex; flex-direction:column; } |
| 11 | 11 | |
| 12 | 12 | /* Convo list */ |
| 13 | 13 | .ch-list-head { padding:10px 12px; border-bottom:1px solid var(--col-border); display:flex; flex-direction:column; gap:8px; } |
| 14 | 14 | .ch-list-head .ch-list-title { font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:var(--col-text-3);font-weight:700; } |
| 15 | 15 | .ch-tabs { display:flex; gap:4px; flex-wrap:wrap; } |
| 16 | 16 | .ch-tab { display:inline-flex; align-items:center; gap:6px; padding:4px 9px; border-radius:6px; font-size:11px; font-weight:700; letter-spacing:0.4px; text-transform:uppercase; color:var(--col-text-3); text-decoration:none; background:transparent; border:1px solid transparent; transition: background 0.12s, color 0.12s; } |
| 17 | 17 | .ch-tab:hover { color:var(--col-text); background: rgba(59,130,246,0.06); } |
| 18 | 18 | .ch-tab.is-current { color:#fff; background:#1d4ed8; } |
| 19 | 19 | .ch-tab .cnt { font-size:10px; padding:1px 5px; border-radius:4px; background:rgba(255,255,255,0.1); } |
| 20 | 20 | .ch-tab.is-current .cnt { background:rgba(255,255,255,0.22); } |
| 21 | 21 | .ch-search { position:relative; } |
| 22 | 22 | .ch-search input { width:100%; box-sizing:border-box; background: var(--col-surface-2); border:1px solid var(--col-border); color:var(--col-text); padding:7px 28px 7px 10px; border-radius:7px; font-size:12px; font-family:inherit; outline:none; } |
| 23 | 23 | .ch-search input:focus { border-color:#3b82f6; } |
| 24 | 24 | .ch-search .clear { position:absolute; right:6px; top:50%; transform:translateY(-50%); background:transparent; border:0; color:var(--col-text-3); cursor:pointer; padding:2px 4px; font-size:14px; line-height:1; } |
| 25 | 25 | .ch-search .clear:hover { color:#fff; } |
| 26 | 26 | .ch-list { overflow-y:auto; flex:1; } |
| 27 | 27 | .ch-item { display:block; padding:12px 14px; border-bottom:1px solid var(--col-border); text-decoration:none; color:inherit; transition: background 0.12s; } |
| 28 | 28 | .ch-item:hover, .ch-item.is-selected { background: rgba(59,130,246,0.08); } |
| 29 | 29 | .ch-item.is-selected { border-left:3px solid #3b82f6; padding-left:11px; } |
| 30 | 30 | .ch-item.is-closed, .ch-item.is-archived { opacity:0.6; } |
| 31 | 31 | .ch-item.is-closed:hover, .ch-item.is-archived:hover, .ch-item.is-closed.is-selected, .ch-item.is-archived.is-selected { opacity:1; } |
| 32 | 32 | .ch-item .top { display:flex; justify-content:space-between; gap:8px; align-items:center; margin-bottom:4px; } |
| 33 | 33 | .ch-item .name { font-weight:700; font-size:13px; color:var(--col-text); display:inline-flex; align-items:center; gap:6px; } |
| 34 | 34 | .ch-item .name .live-dot { width:7px; height:7px; border-radius:50%; background:#22c55e; box-shadow:0 0 5px #22c55e; } |
| 35 | 35 | .ch-item .badge { font-size:10px; padding:1px 6px; border-radius:6px; background:#ef4444; color:#fff; font-weight:700; } |
| 36 | 36 | .ch-state-pill { font-size:9px; padding:1px 6px; border-radius:5px; font-weight:700; letter-spacing:0.6px; text-transform:uppercase; } |
| 37 | 37 | .ch-state-pill.closed { background:rgba(100,116,139,0.25); color:#94a3b8; } |
| 38 | 38 | .ch-state-pill.archived { background:rgba(168,85,247,0.20); color:#c084fc; } |
| 39 | 39 | .ch-state-pill.waiting { background:rgba(245,158,11,0.20); color:#fbbf24; } |
| 40 | 40 | |
| 41 | 41 | /* Thread action buttons in head */ |
| 42 | 42 | .ch-thread-actions { display:flex; gap:6px; margin-left:auto; } |
| 43 | 43 | .ch-thread-actions form { display:inline; } |
| 44 | 44 | .ch-thread-actions button { background:transparent; border:1px solid var(--col-border); color:var(--col-text-2); padding:5px 10px; border-radius:6px; font-size:11px; font-weight:700; letter-spacing:0.4px; text-transform:uppercase; cursor:pointer; font-family:inherit; } |
| 45 | 45 | .ch-thread-actions button:hover { color:#fff; border-color:#3b82f6; } |
| 46 | 46 | .ch-thread-actions button.danger:hover { color:#fff; border-color:#ef4444; background:rgba(239,68,68,0.1); } |
| 47 | 47 | .ch-thread-actions button.reopen:hover { color:#fff; border-color:#22c55e; background:rgba(34,197,94,0.1); } |
| 48 | 48 | .ch-thread-state { font-size:10px; font-weight:700; letter-spacing:0.6px; text-transform:uppercase; padding:2px 8px; border-radius:5px; } |
| 49 | 49 | .ch-thread-state.closed { background:rgba(100,116,139,0.25); color:#cbd5e1; } |
| 50 | 50 | .ch-thread-state.archived { background:rgba(168,85,247,0.20); color:#c084fc; } |
| 51 | 51 | .ch-pill { font-size:9px; font-weight:700; padding:2px 6px; border-radius:5px; letter-spacing:0.8px; text-transform:uppercase; } |
| 52 | 52 | .ch-pill.guest { background: rgba(100,116,139,0.20); color:#cbd5e1; } |
| 53 | 53 | .ch-pill.lead { background: rgba(245,158,11,0.20); color:#fbbf24; } |
| 54 | 54 | .ch-pill.customer { background: rgba(34,197,94,0.20); color:#4ade80; } |
| 55 | 55 | .ch-item .preview { font-size:11px; color:var(--col-text-3); display:-webkit-box; -webkit-line-clamp:1; -webkit-box-orient:vertical; overflow:hidden; } |
| 56 | 56 | .ch-item .meta { font-size:10px; color:var(--col-text-3); margin-top:2px; } |
| 57 | 57 | .ch-item .meta.from-admin { color:#60a5fa; } |
| 58 | 58 | |
| 59 | 59 | /* Thread */ |
| 60 | 60 | .ch-thread { display:flex; flex-direction:column; } |
| 61 | 61 | .ch-thread-head { padding:12px 16px; border-bottom:1px solid var(--col-border); display:flex; align-items:center; gap:10px; } |
| 62 | 62 | .ch-thread-body { flex:1; overflow-y:auto; padding:18px; display:flex; flex-direction:column; gap:10px; } |
| 63 | 63 | .ch-msg { padding:10px 14px; border-radius:14px; line-height:1.5; font-size:13px; align-self:flex-start; box-sizing:border-box; } |
| 64 | 64 | .ch-msg.visitor, .ch-msg.admin { width:100%; } |
| 65 | 65 | .ch-msg.visitor { background:#1e293b; color:#e2e8f0; border-bottom-left-radius:4px; } |
| 66 | 66 | .ch-msg.admin { background:#1e3a8a; color:#fff; border-bottom-left-radius:4px; } |
| 67 | 67 | .ch-msg.system { background:rgba(124,58,237,0.18); color:#c4b5fd; align-self:center; font-size:11px; padding:4px 10px; } |
| 68 | 68 | .ch-msg-head { display:flex; justify-content:space-between; align-items:center; gap:10px; margin-bottom:4px; } |
| 69 | 69 | .ch-msg-author { display:flex; align-items:center; gap:6px; font-size:10px; font-weight:700; letter-spacing:0.6px; text-transform:uppercase; opacity:0.85; } |
| 70 | 70 | .ch-msg-author svg { width:13px; height:13px; flex-shrink:0; } |
| 71 | 71 | .ch-msg time { font-size:10px; opacity:0.75; white-space:nowrap; font-weight:700; } |
| 72 | 72 | .ch-thread-foot { border-top:1px solid var(--col-border); padding:10px 12px; display:flex; flex-direction:column; gap:8px; } |
| 73 | 73 | .ch-thread-foot textarea { width:100%; min-height:64px; max-height:200px; resize:vertical; background: var(--col-surface-2); border:1px solid var(--col-border); color:var(--col-text); padding:10px 12px; border-radius:8px; font-size:13px; font-family:inherit; outline:none; box-sizing:border-box; } |
| 74 | 74 | .ch-thread-foot textarea:focus { border-color:#3b82f6; } |
| 75 | 75 | .ch-thread-foot .ch-actions { display:flex; gap:8px; justify-content:flex-end; } |
| 76 | 76 | .ch-thread-foot button { background:#2563eb; border:0; color:#fff; padding:8px 18px; border-radius:8px; cursor:pointer; font-weight:700; font-size:13px; font-family:inherit; } |
| 77 | 77 | .ch-thread-foot button:hover { background:#3b82f6; } |
| 78 | 78 | .ch-thread-foot button.push-btn { background: transparent; border:1px solid var(--col-border); color:var(--col-text-2); } |
| 79 | 79 | .ch-thread-foot button.push-btn:hover { color:#fff; border-color:#3b82f6; } |
| 80 | 80 | .ch-msg { white-space:pre-wrap; } |
| 81 | 81 | |
| 82 | 82 | /* Side info */ |
| 83 | 83 | .ch-side { padding:14px; } |
| 84 | 84 | .ch-side h3 { font-size:11px; letter-spacing:1.5px; text-transform:uppercase; color:var(--col-text-3); font-weight:700; margin:0 0 10px; } |
| 85 | 85 | .ch-side .row { display:flex; justify-content:space-between; gap:8px; padding:6px 0; border-bottom:1px dashed var(--col-border); font-size:12px; } |
| 86 | 86 | .ch-side .row:last-child { border-bottom:none; } |
| 87 | 87 | .ch-side .k { color:var(--col-text-3); } |
| 88 | 88 | .ch-side .v { color:var(--col-text); font-weight:600; text-align:right; word-break:break-all; } |
| 89 | 89 | |
| 90 | 90 | /* Online toggle */ |
| 91 | 91 | .ch-toggle { display:inline-flex; align-items:center; gap:8px; cursor:pointer; font-size:12px; color:var(--col-text-2); } |
| 92 | 92 | .ch-toggle .ind { width:10px; height:10px; border-radius:50%; background:#475569; } |
| 93 | 93 | .ch-toggle.is-on .ind { background:#22c55e; box-shadow:0 0 6px #22c55e; } |
| 94 | 94 | </style> |
| 95 | 95 | |
| 96 | 96 | <div class="page-head"> |
| 97 | 97 | <div> |
| 98 | 98 | <span class="page-eyebrow"><span class="dot"></span> Admin · Live chat</span> |
| 99 | 99 | <h1 class="page-title">Visitor chat console</h1> |
| 100 | 100 | <p class="page-subtitle">Chat with visitors in real time. Toggle yourself offline when you don't want to take messages — the chat bubble hides on every dashboard automatically.</p> |
| 101 | 101 | </div> |
| 102 | 102 | <div class="page-actions"> |
| 103 | 103 | <form method="POST" action="/admin_tracking_action.cgi" style="display:inline"> |
| 104 | 104 | <input type="hidden" name="act" value="toggle_online"> |
| 105 | 105 | <input type="hidden" name="value" value="[if:$admin_online]0[/if][if:!$admin_online]1[/if]"> |
| 106 | 106 | <button type="submit" class="ch-toggle[if:$admin_online] is-on[/if]" style="background:var(--col-surface-2);border:1px solid var(--col-border);padding:8px 14px;border-radius:10px;font-family:inherit"> |
| 107 | 107 | <span class="ind"></span> |
| 108 | 108 | [if:$admin_online]You're online — click to go offline[/if][if:!$admin_online]You're offline — click to go online[/if] |
| 109 | 109 | </button> |
| 110 | 110 | </form> |
| 111 | 111 | [if:$not_tutorial_mode]<a href="/admin_chat.cgi?tutorial=1" class="btn btn-secondary"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Sample Data View</a>[/if] |
| 112 | 112 | [if:$tutorial_mode]<a href="/admin_chat.cgi" class="btn btn-secondary"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Exit sample view</a>[/if] |
| 113 | 113 | </div> |
| 114 | 114 | </div> |
| 115 | 115 | |
| 116 | 116 | [if:$schema_missing] |
| 117 | 117 | <div class="an-banner" style="background:rgba(245,158,11,0.10);border-color:rgba(245,158,11,0.30)"> |
| 118 | 118 | <strong>Chat schema not yet installed.</strong> Run the latest <code>db_schema.sql</code>. |
| 119 | 119 | </div> |
| 120 | 120 | [/if] |
| 121 | 121 | |
| 122 | 122 | [if:$tutorial_mode] |
| 123 | 123 | <div class="tutorial-banner"> |
| 124 | 124 | <div class="tutorial-banner-icon"><svg viewBox="0 0 24 24" 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> |
| 125 | 125 | <div class="tutorial-banner-body"> |
| 126 | 126 | <div class="tutorial-banner-title">Tutorial · Sample data only</div> |
| 127 | 127 | <div class="tutorial-banner-text">3 example conversations with realistic visitor messages. None of this is real chat traffic.</div> |
| 128 | 128 | </div> |
| 129 | 129 | <a href="/admin_chat.cgi" class="btn btn-secondary btn-sm">Exit sample view</a> |
| 130 | 130 | </div> |
| 131 | 131 | [/if] |
| 132 | 132 | |
| 133 | 133 | [if:$schema_ready] |
| 134 | 134 | |
| 135 | 135 | <!-- Concluded chats per day (last 30 days) --> |
| 136 | 136 | [if:$has_resolved_months] |
| 137 | 137 | <style> |
| 138 | 138 | .ccd-graph { display:grid;grid-template-columns:repeat(30,1fr);gap:3px;align-items:end;height:110px;position:relative; } |
| 139 | 139 | .ccd-bar-wrap { display:flex;flex-direction:column;align-items:stretch;height:100%;justify-content:flex-end;cursor:pointer;position:relative;border-radius:4px;transition:background 0.12s; } |
| 140 | 140 | .ccd-bar-wrap:hover { background:rgba(59,130,246,0.10); } |
| 141 | 141 | .ccd-bar { width:100%;background:var(--col-accent-deep,#1d4ed8);border-radius:3px 3px 0 0;min-height:2px;transition:filter 0.12s,transform 0.12s;transform-origin:bottom; } |
| 142 | 142 | .ccd-bar-wrap:hover .ccd-bar { filter:brightness(1.30);transform:scaleY(1.04); } |
| 143 | 143 | .ccd-bar-wrap.is-current .ccd-bar { box-shadow:0 0 0 2px rgba(59,130,246,0.55); } |
| 144 | 144 | .ccd-axis { display:grid;grid-template-columns:repeat(30,1fr);gap:3px;margin-top:6px;font-size:9px;color:var(--col-text-3);font-family:var(--font-mono,monospace); } |
| 145 | 145 | .ccd-axis-cell { text-align:center;letter-spacing:0;line-height:1; } |
| 146 | 146 | .ccd-axis-cell.is-week { color:var(--col-text-2);font-weight:700; } |
| 147 | 147 | .ccd-tip { position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:rgba(8,12,26,0.96);border:1px solid var(--col-border);color:#fff;font-size:11px;padding:6px 10px;border-radius:7px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity 0.12s;z-index:30;box-shadow:0 6px 18px rgba(0,0,0,0.4); } |
| 148 | 148 | .ccd-tip strong { color:var(--col-accent-bright,#60a5fa);font-weight:700; } |
| 149 | 149 | .ccd-bar-wrap:hover .ccd-tip { opacity:1; } |
| 150 | 150 | |
| 151 | 151 | /* Drilldown overlay: dimmed full-page backdrop with a centered card |
| 152 | 152 | listing every chat closed on the clicked day. Linked rows jump |
| 153 | 153 | straight back into the conversation thread in the console. */ |
| 154 | 154 | .ccd-overlay { position:fixed;inset:0;background:rgba(2,6,18,0.72);backdrop-filter:blur(6px);z-index:9999;display:none;align-items:flex-start;justify-content:center;padding:60px 20px;overflow:auto; } |
| 155 | 155 | .ccd-overlay.is-open { display:flex; } |
| 156 | 156 | .ccd-card { background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:14px;width:100%;max-width:560px;padding:22px 24px;box-shadow:0 24px 60px rgba(0,0,0,0.55); } |
| 157 | 157 | .ccd-card-head { display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px; } |
| 158 | 158 | .ccd-card-head h3 { margin:0;font-family:var(--font-display);font-size:18px;font-weight:700;color:#fff; } |
| 159 | 159 | .ccd-card-close { background:transparent;border:none;color:var(--col-text-3);font-size:22px;cursor:pointer;line-height:1;padding:0 4px; } |
| 160 | 160 | .ccd-card-close:hover { color:#fff; } |
| 161 | 161 | .ccd-card-empty { font-size:13px;color:var(--col-text-3);padding:18px 0; } |
| 162 | 162 | .ccd-card-list { display:flex;flex-direction:column;gap:6px;max-height:60vh;overflow:auto; } |
| 163 | 163 | .ccd-card-row { display:flex;align-items:center;justify-content:space-between;gap:10px;background:var(--col-surface-2);border:1px solid var(--col-border);border-radius:8px;padding:10px 12px;color:var(--col-text);text-decoration:none;transition:border-color 0.12s,background 0.12s; } |
| 164 | 164 | .ccd-card-row:hover { border-color:var(--col-accent);background:var(--col-surface-3); } |
| 165 | 165 | .ccd-card-time { font-family:var(--font-mono,monospace);font-size:11px;color:var(--col-text-3); } |
| 166 | 166 | </style> |
| 167 | 167 | <div style="display:grid;grid-template-columns:240px 1fr;gap:14px;margin:0 0 18px;align-items:stretch"> |
| 168 | 168 | <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px"> |
| 169 | 169 | <div class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase;font-weight:700">Resolved today</div> |
| 170 | 170 | <div style="font-family:var(--font-display);font-size:30px;font-weight:700;color:#fff;margin:6px 0 2px">$resolved_mtd</div> |
| 171 | 171 | <div class="text-xs text-dim">$resolved_total_6mo closed + archived in the last 30 days</div> |
| 172 | 172 | </div> |
| 173 | 173 | <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px"> |
| 174 | 174 | <div class="text-xs text-dim" style="letter-spacing:1.5px;text-transform:uppercase;font-weight:700;margin-bottom:10px">Concluded chats · last 30 days</div> |
| 175 | 175 | <div class="ccd-graph" id="ccdGraph"> |
| 176 | 176 | [loop:@resolved_months] |
| 177 | 177 | <div class="ccd-bar-wrap[if:$loop1.is_current] is-current[/if]" |
| 178 | 178 | data-d-iso="$loop1.d_iso" |
| 179 | 179 | data-d-label="$loop1.d_label" |
| 180 | 180 | data-n="$loop1.n" |
| 181 | 181 | data-chats='$loop1.chats_json'> |
| 182 | 182 | <div class="ccd-tip"><strong>$loop1.n</strong> on $loop1.d_label</div> |
| 183 | 183 | <div class="ccd-bar" style="height:$loop1.bar_pct%"></div> |
| 184 | 184 | </div> |
| 185 | 185 | [/loop] |
| 186 | 186 | </div> |
| 187 | 187 | <div class="ccd-axis"> |
| 188 | 188 | [loop:@resolved_months] |
| 189 | 189 | <div class="ccd-axis-cell[if:$loop1.is_current] is-week[/if]">$loop1.d_short</div> |
| 190 | 190 | [/loop] |
| 191 | 191 | </div> |
| 192 | 192 | </div> |
| 193 | 193 | </div> |
| 194 | 194 | |
| 195 | 195 | <!-- Drilldown overlay (one shared modal; populated on bar click) --> |
| 196 | 196 | <div class="ccd-overlay" id="ccdOverlay" role="dialog" aria-modal="true" aria-labelledby="ccdOverlayTitle"> |
| 197 | 197 | <div class="ccd-card"> |
| 198 | 198 | <div class="ccd-card-head"> |
| 199 | 199 | <h3 id="ccdOverlayTitle">—</h3> |
| 200 | 200 | <button type="button" class="ccd-card-close" aria-label="Close" data-ccd-close>×</button> |
| 201 | 201 | </div> |
| 202 | 202 | <div id="ccdOverlayBody"></div> |
| 203 | 203 | </div> |
| 204 | 204 | </div> |
| 205 | 205 | |
| 206 | 206 | <script> |
| 207 | 207 | (function(){ |
| 208 | 208 | var graph = document.getElementById('ccdGraph'); |
| 209 | 209 | var ov = document.getElementById('ccdOverlay'); |
| 210 | 210 | if (!graph || !ov) return; |
| 211 | 211 | var title = document.getElementById('ccdOverlayTitle'); |
| 212 | 212 | var body = document.getElementById('ccdOverlayBody'); |
| 213 | 213 | |
| 214 | 214 | function openOverlay(wrap) { |
| 215 | 215 | var n = parseInt(wrap.getAttribute('data-n') || '0', 10); |
| 216 | 216 | var lbl = wrap.getAttribute('data-d-label') || ''; |
| 217 | 217 | var chats = []; |
| 218 | 218 | try { chats = JSON.parse(wrap.getAttribute('data-chats') || '[]'); } catch(e) { chats = []; } |
| 219 | 219 | title.textContent = lbl + ' - ' + n + (n === 1 ? ' chat' : ' chats') + ' resolved'; |
| 220 | 220 | if (!chats.length) { |
| 221 | 221 | body.innerHTML = '<div class="ccd-card-empty">No closures on this day. The bar shows 0 because the data is up to date, not because we missed anything.</div>'; |
| 222 | 222 | } else { |
| 223 | 223 | var rows = chats.map(function(c){ |
| 224 | 224 | var label = (c.label == null ? '' : ('' + c.label)); |
| 225 | 225 | var hm = c.hm || ''; |
| 226 | 226 | var href = '/admin_chat.cgi?chat=' + encodeURIComponent(c.id); |
| 227 | 227 | var safeLabel = label.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); |
| 228 | 228 | return '<a class="ccd-card-row" href="' + href + '">' |
| 229 | 229 | + '<span>' + safeLabel + '</span>' |
| 230 | 230 | + '<span class="ccd-card-time">' + hm + '</span>' |
| 231 | 231 | + '</a>'; |
| 232 | 232 | }).join(''); |
| 233 | 233 | body.innerHTML = '<div class="ccd-card-list">' + rows + '</div>'; |
| 234 | 234 | } |
| 235 | 235 | ov.classList.add('is-open'); |
| 236 | 236 | } |
| 237 | 237 | function closeOverlay() { ov.classList.remove('is-open'); } |
| 238 | 238 | |
| 239 | 239 | graph.addEventListener('click', function(e){ |
| 240 | 240 | var wrap = e.target.closest('.ccd-bar-wrap'); |
| 241 | 241 | if (!wrap || !graph.contains(wrap)) return; |
| 242 | 242 | openOverlay(wrap); |
| 243 | 243 | }); |
| 244 | 244 | ov.addEventListener('click', function(e){ |
| 245 | 245 | if (e.target === ov || e.target.hasAttribute('data-ccd-close')) closeOverlay(); |
| 246 | 246 | }); |
| 247 | 247 | document.addEventListener('keydown', function(e){ |
| 248 | 248 | if (e.key === 'Escape' && ov.classList.contains('is-open')) closeOverlay(); |
| 249 | 249 | }); |
| 250 | 250 | })(); |
| 251 | 251 | </script> |
| 252 | 252 | [/if] |
| 253 | 253 | |
| 254 | 254 | <div class="ch-grid"> |
| 255 | 255 | <!-- Conversation list --> |
| 256 | 256 | <div class="ch-panel"> |
| 257 | 257 | <div class="ch-list-head"> |
| 258 | 258 | <span class="ch-list-title">Conversations · $chat_count[if:$has_search] match[/if]</span> |
| 259 | 259 | <form method="GET" action="/admin_chat.cgi" class="ch-search"> |
| 260 | 260 | [if:$tutorial_mode]<input type="hidden" name="tutorial" value="1">[/if] |
| 261 | 261 | <input type="hidden" name="view" value="$view"> |
| 262 | 262 | <input type="text" name="q" value="$search_q" placeholder="Search by name, email, or message..." autocomplete="off"> |
| 263 | 263 | [if:$has_search]<a href="/admin_chat.cgi?view=$view[if:$tutorial_mode]&tutorial=1[/if]" class="clear" title="Clear search">×</a>[/if] |
| 264 | 264 | </form> |
| 265 | 265 | <div class="ch-tabs"> |
| 266 | 266 | <a href="/admin_chat.cgi?view=active[if:$tutorial_mode]&tutorial=1[/if]" class="ch-tab[if:$is_view_active] is-current[/if]">Active <span class="cnt">$count_active</span></a> |
| 267 | 267 | <a href="/admin_chat.cgi?view=closed[if:$tutorial_mode]&tutorial=1[/if]" class="ch-tab[if:$is_view_closed] is-current[/if]">Closed <span class="cnt">$count_closed</span></a> |
| 268 | 268 | <a href="/admin_chat.cgi?view=archived[if:$tutorial_mode]&tutorial=1[/if]" class="ch-tab[if:$is_view_archived] is-current[/if]">Archived <span class="cnt">$count_archived</span></a> |
| 269 | 269 | <a href="/admin_chat.cgi?view=all[if:$tutorial_mode]&tutorial=1[/if]" class="ch-tab[if:$is_view_all] is-current[/if]">All <span class="cnt">$count_all</span></a> |
| 270 | 270 | </div> |
| 271 | 271 | </div> |
| 272 | 272 | <div class="ch-list"> |
| 273 | 273 | [if:$has_chats] |
| 274 | 274 | [loop:@chats] |
| 275 | 275 | <a href="$loop1.href" class="ch-item[if:$loop1.is_selected] is-selected[/if][if:$loop1.is_closed] is-closed[/if][if:$loop1.is_archived] is-archived[/if]"> |
| 276 | 276 | <div class="top"> |
| 277 | 277 | <span class="name">[if:$loop1.visitor_online]<span class="live-dot" title="Visitor on site now"></span>[/if]$loop1.label</span> |
| 278 | 278 | [if:$loop1.unread]<span class="badge">$loop1.unread</span>[/if] |
| 279 | 279 | </div> |
| 280 | 280 | <div style="display:flex;gap:6px;align-items:center;margin:2px 0 4px;flex-wrap:wrap"> |
| 281 | 281 | [if:$loop1.is_customer]<span class="ch-pill customer">Customer</span><span class="meta">$loop1.total_orders orders · $loop1.ltv_display</span>[/if] |
| 282 | 282 | [if:$loop1.is_lead]<span class="ch-pill lead">Lead</span>[/if] |
| 283 | 283 | [if:$loop1.is_guest]<span class="ch-pill guest">Guest</span>[/if] |
| 284 | 284 | [if:$loop1.is_closed]<span class="ch-state-pill closed">Closed</span>[/if] |
| 285 | 285 | [if:$loop1.is_archived]<span class="ch-state-pill archived">Archived</span>[/if] |
| 286 | 286 | [if:$loop1.awaiting_reply]<span class="ch-state-pill waiting">Awaiting</span>[/if] |
| 287 | 287 | </div> |
| 288 | 288 | [if:$loop1.has_email]<div class="meta">$loop1.email</div>[/if] |
| 289 | 289 | <div class="preview"><strong>[if:$loop1.last_from][if:$loop1.last_from eq 'admin']You: [/if][/if]</strong>$loop1.last_body</div> |
| 290 | 290 | <div class="meta">$loop1.platform_meta · $loop1.last_message_at</div> |
| 291 | 291 | </a> |
| 292 | 292 | [/loop] |
| 293 | 293 | [/if] |
| 294 | 294 | [if:!$has_chats] |
| 295 | 295 | <div style="padding:24px;text-align:center;color:var(--col-text-3);font-size:12px">[if:$has_search]No conversations match "$search_q".[/if][if:!$has_search]No conversations in this view yet.[/if]</div> |
| 296 | 296 | [/if] |
| 297 | 297 | </div> |
| 298 | 298 | </div> |
| 299 | 299 | |
| 300 | 300 | <!-- Thread + reply --> |
| 301 | 301 | <div class="ch-panel ch-thread"> |
| 302 | 302 | [if:$has_selected] |
| 303 | 303 | <div class="ch-thread-head"> |
| 304 | 304 | <span style="font-weight:700;color:var(--col-text);font-size:14px">$thread.label</span> |
| 305 | 305 | [if:$thread.is_closed]<span class="ch-thread-state closed">Closed</span>[/if] |
| 306 | 306 | [if:$thread.is_archived]<span class="ch-thread-state archived">Archived</span>[/if] |
| 307 | 307 | <span style="font-size:11px;color:var(--col-text-3);margin-left:12px">$thread.os · $thread.browser · $thread.device</span> |
| 308 | 308 | <div class="ch-thread-actions"> |
| 309 | 309 | [if:$thread.is_open] |
| 310 | 310 | <form method="POST" action="/admin_tracking_action.cgi" |
| 311 | 311 | data-confirm-title="Close this conversation?" |
| 312 | 312 | data-confirm-message="The visitor will see the closure in their chat widget." |
| 313 | 313 | data-confirm-label="Close conversation" |
| 314 | 314 | data-confirm-style="warning"> |
| 315 | 315 | <input type="hidden" name="act" value="close_chat"> |
| 316 | 316 | <input type="hidden" name="chat_id" value="$selected_chat_id"> |
| 317 | 317 | <button type="submit" class="danger" title="Mark closed -- still visible in Closed view">Close</button> |
| 318 | 318 | </form> |
| 319 | 319 | <form method="POST" action="/admin_tracking_action.cgi"> |
| 320 | 320 | <input type="hidden" name="act" value="archive_chat"> |
| 321 | 321 | <input type="hidden" name="chat_id" value="$selected_chat_id"> |
| 322 | 322 | <button type="submit" title="Move to Archived view -- comes back if visitor messages again">Archive</button> |
| 323 | 323 | </form> |
| 324 | 324 | [/if] |
| 325 | 325 | [if:$thread.is_closed] |
| 326 | 326 | <form method="POST" action="/admin_tracking_action.cgi"> |
| 327 | 327 | <input type="hidden" name="act" value="reopen_chat"> |
| 328 | 328 | <input type="hidden" name="chat_id" value="$selected_chat_id"> |
| 329 | 329 | <button type="submit" class="reopen">Reopen</button> |
| 330 | 330 | </form> |
| 331 | 331 | <form method="POST" action="/admin_tracking_action.cgi"> |
| 332 | 332 | <input type="hidden" name="act" value="archive_chat"> |
| 333 | 333 | <input type="hidden" name="chat_id" value="$selected_chat_id"> |
| 334 | 334 | <button type="submit">Archive</button> |
| 335 | 335 | </form> |
| 336 | 336 | [/if] |
| 337 | 337 | [if:$thread.is_archived] |
| 338 | 338 | <form method="POST" action="/admin_tracking_action.cgi"> |
| 339 | 339 | <input type="hidden" name="act" value="reopen_chat"> |
| 340 | 340 | <input type="hidden" name="chat_id" value="$selected_chat_id"> |
| 341 | 341 | <button type="submit" class="reopen">Reopen</button> |
| 342 | 342 | </form> |
| 343 | 343 | [/if] |
| 344 | 344 | </div> |
| 345 | 345 | </div> |
| 346 | 346 | <div class="ch-thread-body" id="ch-thread-body"> |
| 347 | 347 | [loop:@thread_rows] |
| 348 | 348 | [if:$loop1.is_system]<div class="ch-msg system">$loop1.body</div>[/if] |
| 349 | 349 | [if:$loop1.is_visitor]<div class="ch-msg visitor"><div class="ch-msg-head"><div class="ch-msg-author"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/></svg>Visitor</div><time>$loop1.sent_at</time></div>$loop1.body</div>[/if] |
| 350 | 350 | [if:$loop1.is_admin]<div class="ch-msg admin"><div class="ch-msg-head"><div class="ch-msg-author"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="4" cy="9" r="2"/><path d="M1 19a3 3 0 0 1 3-3"/><circle cx="12" cy="7" r="3"/><path d="M7 19a5 5 0 0 1 10 0"/><circle cx="20" cy="9" r="2"/><path d="M23 19a3 3 0 0 0-3-3"/></svg>Admin</div><time>$loop1.sent_at</time></div>$loop1.body</div>[/if] |
| 351 | 351 | [/loop] |
| 352 | 352 | </div> |
| 353 | 353 | <form method="POST" action="/admin_tracking_action.cgi" class="ch-thread-foot"> |
| 354 | 354 | <input type="hidden" name="act" value="send_message"> |
| 355 | 355 | <input type="hidden" name="chat_id" value="$selected_chat_id"> |
| 356 | 356 | <input type="hidden" name="back_to" value="chat"> |
| 357 | 357 | <textarea name="body" placeholder="Type your reply... (Enter to send, Shift+Enter for new line)" maxlength="2000" required autofocus rows="3"></textarea> |
| 358 | 358 | <div class="ch-actions"> |
| 359 | 359 | <button type="button" class="push-btn js-push-link" title="Push a link/window to this visitor">+ Push link</button> |
| 360 | 360 | <button type="submit">Send</button> |
| 361 | 361 | </div> |
| 362 | 362 | </form> |
| 363 | 363 | <form id="ch-push-form" method="POST" action="/admin_tracking_action.cgi" style="display:none"> |
| 364 | 364 | <input type="hidden" name="act" value="push_link"> |
| 365 | 365 | <input type="hidden" name="session_id" value="$thread.session_id"> |
| 366 | 366 | <input type="hidden" name="back_to" value="chat"> |
| 367 | 367 | <input type="hidden" name="chat_id" value="$selected_chat_id"> |
| 368 | 368 | <input type="hidden" name="url" value=""> |
| 369 | 369 | <input type="hidden" name="label" value=""> |
| 370 | 370 | </form> |
| 371 | 371 | <script> |
| 372 | 372 | // Push-link flow: URL prompt -> optional label prompt -> submit. |
| 373 | 373 | document.addEventListener('click', function (e) { |
| 374 | 374 | var btn = e.target.closest('.js-push-link'); |
| 375 | 375 | if (!btn) return; |
| 376 | 376 | appPrompt({ |
| 377 | 377 | title: 'Push a link to this visitor', |
| 378 | 378 | message: 'They will see a notification with this URL in their chat widget.', |
| 379 | 379 | placeholder: 'https://', |
| 380 | 380 | default_value: 'https://', |
| 381 | 381 | input_type: 'url', |
| 382 | 382 | label: 'Next', |
| 383 | 383 | required: true, |
| 384 | 384 | validate: function (v) { |
| 385 | 385 | if (!/^https?:\/\//i.test(v)) return 'Must start with http:// or https://'; |
| 386 | 386 | return ''; |
| 387 | 387 | } |
| 388 | 388 | }).then(function (url) { |
| 389 | 389 | if (url === null) return; |
| 390 | 390 | appPrompt({ |
| 391 | 391 | title: 'Label for the link (optional)', |
| 392 | 392 | message: 'Short text shown alongside the URL. Leave blank to use the URL itself.', |
| 393 | 393 | placeholder: 'Try this', |
| 394 | 394 | label: 'Push link', |
| 395 | 395 | required: false |
| 396 | 396 | }).then(function (label) { |
| 397 | 397 | if (label === null) return; |
| 398 | 398 | var f = document.getElementById('ch-push-form'); |
| 399 | 399 | f.url.value = url; |
| 400 | 400 | f.label.value = label || ''; |
| 401 | 401 | f.submit(); |
| 402 | 402 | }); |
| 403 | 403 | }); |
| 404 | 404 | }); |
| 405 | 405 | </script> |
| 406 | 406 | [/if] |
| 407 | 407 | [if:!$has_selected] |
| 408 | 408 | <div style="flex:1;display:grid;place-items:center;text-align:center;padding:32px;color:var(--col-text-3)"> |
| 409 | 409 | <div> |
| 410 | 410 | <svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" style="margin:0 auto 12px;display:block;color:var(--col-text-3)"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg> |
| 411 | 411 | <div style="font-size:14px;color:var(--col-text-2);font-weight:600;margin-bottom:4px">Pick a conversation</div> |
| 412 | 412 | <div style="font-size:12px;color:var(--col-text-3)">Or wait for a visitor to open the chat widget.</div> |
| 413 | 413 | </div> |
| 414 | 414 | </div> |
| 415 | 415 | [/if] |
| 416 | 416 | </div> |
| 417 | 417 | |
| 418 | 418 | <!-- Visitor side panel --> |
| 419 | 419 | <div class="ch-panel ch-side"> |
| 420 | 420 | [if:$has_thread] |
| 421 | 421 | <h3>Contact status</h3> |
| 422 | 422 | <div style="margin-bottom:12px;text-align:center"> |
| 423 | 423 | [if:$thread.is_customer]<span class="ch-pill customer" style="font-size:11px;padding:4px 10px">Customer</span> |
| 424 | 424 | <div style="margin-top:6px;font-size:12px;color:var(--col-text-2)"><strong style="color:#fff">$thread.total_orders</strong> orders · <strong style="color:#4ade80">$thread.ltv_display</strong> lifetime</div>[/if] |
| 425 | 425 | [if:$thread.is_lead]<span class="ch-pill lead" style="font-size:11px;padding:4px 10px">Lead</span> |
| 426 | 426 | <div style="margin-top:6px;font-size:11px;color:var(--col-text-3)">Signed up, no orders yet</div>[/if] |
| 427 | 427 | [if:$thread.is_guest]<span class="ch-pill guest" style="font-size:11px;padding:4px 10px">Guest</span> |
| 428 | 428 | <div style="margin-top:6px;font-size:11px;color:var(--col-text-3)">No account — could become a lead</div>[/if] |
| 429 | 429 | </div> |
| 430 | 430 | [if:$thread.has_email] |
| 431 | 431 | <div class="row"><span class="k">Email</span><span class="v" style="font-family:var(--font-mono,monospace);font-size:11px">$thread.email</span></div> |
| 432 | 432 | [/if] |
| 433 | 433 | <h3 style="margin-top:14px">Client info</h3> |
| 434 | 434 | <div class="row"><span class="k">OS</span><span class="v">$thread.os</span></div> |
| 435 | 435 | <div class="row"><span class="k">Browser</span><span class="v">$thread.browser</span></div> |
| 436 | 436 | <div class="row"><span class="k">Device</span><span class="v">$thread.device</span></div> |
| 437 | 437 | <div class="row"><span class="k">Screen</span><span class="v">$thread.screen</span></div> |
| 438 | 438 | <div class="row"><span class="k">Language</span><span class="v">$thread.language</span></div> |
| 439 | 439 | <div class="row"><span class="k">Time zone</span><span class="v">$thread.timezone</span></div> |
| 440 | 440 | <div class="row"><span class="k">Current page</span><span class="v" style="font-family:var(--font-mono,monospace);font-size:10px">$thread.current_page</span></div> |
| 441 | 441 | |
| 442 | 442 | <div style="margin-top:16px;display:flex;gap:8px"> |
| 443 | 443 | <a href="/admin_visitors.cgi?id=$thread.session_id" class="btn btn-secondary btn-sm" style="flex:1;text-align:center">All sessions</a> |
| 444 | 444 | </div> |
| 445 | 445 | [/if] |
| 446 | 446 | </div> |
| 447 | 447 | </div> |
| 448 | 448 | |
| 449 | 449 | <script> |
| 450 | 450 | // Auto-scroll the thread body to bottom on load |
| 451 | 451 | (function () { |
| 452 | 452 | var el = document.getElementById('ch-thread-body'); |
| 453 | 453 | if (el) el.scrollTop = el.scrollHeight; |
| 454 | 454 | })(); |
| 455 | 455 | |
| 456 | 456 | // Preserve any in-progress reply across the auto-refresh. We stash |
| 457 | 457 | // the textbox value in sessionStorage keyed by chat_id, restore it |
| 458 | 458 | // on load, and clear it on submit. Enter sends, Shift+Enter inserts |
| 459 | 459 | // a newline -- standard chat ergonomics. |
| 460 | 460 | (function () { |
| 461 | 461 | var input = document.querySelector('.ch-thread-foot [name="body"]'); |
| 462 | 462 | var chatId = document.querySelector('.ch-thread-foot input[name="chat_id"]'); |
| 463 | 463 | if (!input || !chatId) return; |
| 464 | 464 | var key = 'ws_chat_draft_' + chatId.value; |
| 465 | 465 | var saved = sessionStorage.getItem(key); |
| 466 | 466 | if (saved) { |
| 467 | 467 | input.value = saved; |
| 468 | 468 | try { input.setSelectionRange(saved.length, saved.length); } catch (e) {} |
| 469 | 469 | } |
| 470 | 470 | input.addEventListener('input', function () { |
| 471 | 471 | sessionStorage.setItem(key, input.value); |
| 472 | 472 | }); |
| 473 | 473 | input.addEventListener('keydown', function (e) { |
| 474 | 474 | if (e.key === 'Enter' && !e.shiftKey) { |
| 475 | 475 | e.preventDefault(); |
| 476 | 476 | // Programmatic form.submit() does NOT fire the 'submit' event, |
| 477 | 477 | // so the form-level cleanup listener below never runs on Enter. |
| 478 | 478 | // Clear the draft here too, otherwise the next page load |
| 479 | 479 | // restores the text we just sent. |
| 480 | 480 | sessionStorage.removeItem(key); |
| 481 | 481 | if (input.form) input.form.submit(); |
| 482 | 482 | } |
| 483 | 483 | }); |
| 484 | 484 | if (input.form) { |
| 485 | 485 | input.form.addEventListener('submit', function () { |
| 486 | 486 | sessionStorage.removeItem(key); |
| 487 | 487 | }); |
| 488 | 488 | } |
| 489 | 489 | })(); |
| 490 | 490 | |
| 491 | 491 | // Auto-refresh every 6s to pick up new visitor messages (skipped in |
| 492 | 492 | // sample view). If the admin is actively focused on the reply input |
| 493 | 493 | // we defer the reload 3s at a time so we never yank the cursor mid- |
| 494 | 494 | // sentence -- their draft is saved either way, but reloading while |
| 495 | 495 | // typing is jarring. Same deferral when the daily-trend drilldown |
| 496 | 496 | // overlay is open: yanking that mid-read is just as bad. |
| 497 | 497 | if (location.search.indexOf('tutorial=1') < 0) { |
| 498 | 498 | function chRefreshTick() { |
| 499 | 499 | var input = document.querySelector('.ch-thread-foot [name="body"]'); |
| 500 | 500 | if (input && document.activeElement === input) { |
| 501 | 501 | setTimeout(chRefreshTick, 3000); |
| 502 | 502 | return; |
| 503 | 503 | } |
| 504 | 504 | var drill = document.getElementById('ccdOverlay'); |
| 505 | 505 | if (drill && drill.classList.contains('is-open')) { |
| 506 | 506 | setTimeout(chRefreshTick, 3000); |
| 507 | 507 | return; |
| 508 | 508 | } |
| 509 | 509 | location.reload(); |
| 510 | 510 | } |
| 511 | 511 | setTimeout(chRefreshTick, 6000); |
| 512 | 512 | } |
| 513 | 513 | </script> |
| 514 | 514 | [/if] |
| 515 | 515 | |
| 516 | 516 | <div class="adm-foot-spacer" data-end="admin-chat"> </div> |