Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_admin_messages.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/crm.3dshawn.com/TEMPLATES/cf_admin_messages.html

added on local at 2026-07-11 18:31:55

Added
+0
lines
Removed
-0
lines
Context
652
unchanged
Blobs
from 16f9a52e923b
to 16f9a52e923b
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11<!-- =====================================================================
22 ADMIN SUPPORT INBOX
33 /admin_messages.cgi -- platform admin's inbox of every customer
44 support thread. Same shape as the customer's /support.cgi with
55 extra controls in the right pane: change status, see customer
66 email + priority. Async messaging only.
77===================================================================== -->
88<style>
99 .ai-page { display:grid;grid-template-columns:380px 1fr;gap:20px;height:calc(100vh - 100px);min-height:560px;padding:0;margin:0 -8px; }
1010 .ai-pane { background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:14px;overflow:hidden;display:flex;flex-direction:column;min-height:0; }
1111
1212 .ai-list-head { padding:14px 18px;border-bottom:1px solid var(--col-border);flex-shrink:0; }
1313 .ai-list-title { font-family:var(--font-display);font-size:18px;color:var(--col-text);margin:0; }
1414 .ai-list-sub { font-size:11px;color:var(--col-text-3);letter-spacing:1px;text-transform:uppercase; }
1515 .ai-filters { display:flex;gap:4px;margin-top:10px;flex-wrap:wrap; }
1616 .ai-filter { padding:5px 10px;background:var(--col-surface-2);border:1px solid var(--col-border);border-radius:14px;color:var(--col-text-2);font-size:11px;font-weight:600;text-decoration:none;cursor:pointer; }
1717 .ai-filter:hover { background:var(--col-accent-glow);color:var(--col-text); }
1818 .ai-filter.is-active { background:var(--col-accent);color:#fff;border-color:var(--col-accent); }
1919
2020 .ai-list { flex:1;overflow-y:auto;padding:8px; }
2121 .ai-list-empty { padding:50px 22px;text-align:center;color:var(--col-text-3);font-size:13px; }
2222 /* Compact one-line thread rows -- everything on a single horizontal
2323 flex line so the inbox fits 3-4x more threads per viewport. */
2424 .ai-thread { display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:8px;text-decoration:none;color:inherit;margin-bottom:2px;border:1px solid transparent;position:relative;min-height:32px; }
2525 .ai-thread:hover { background:var(--col-surface-2);border-color:var(--col-border); }
2626 .ai-thread.is-active { background:var(--col-accent-glow);border-color:var(--col-accent); }
2727 .ai-thread-cust { font-size:12px;color:var(--col-accent-bright);font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex-shrink:0;max-width:110px; }
2828 .ai-thread-subj { font-size:13px;font-weight:600;color:var(--col-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.3;flex:1;min-width:0; }
2929 .ai-thread-time { font-size:11px;color:var(--col-text-3);flex-shrink:0; }
3030 .ai-pill { padding:1px 7px;border-radius:10px;font-size:10px;font-weight:700;letter-spacing:0.4px;flex-shrink:0; }
3131 .ai-pill.st-needs { background:rgba(239,68,68,0.15);color:#f87171;border:1px solid rgba(239,68,68,0.32); }
3232 .ai-pill.st-open { background:rgba(59,130,246,0.18);color:#60a5fa;border:1px solid rgba(59,130,246,0.36); }
3333 .ai-pill.st-waiting { background:rgba(245,158,11,0.15);color:#fbbf24;border:1px solid rgba(245,158,11,0.32); }
3434 .ai-pill.st-resolved { background:rgba(34,197,94,0.15);color:#4ade80;border:1px solid rgba(34,197,94,0.32); }
3535 .ai-pill.st-closed { background:rgba(148,163,184,0.15);color:#cbd5e1;border:1px solid rgba(148,163,184,0.32); }
3636 .ai-pill.chat { background:rgba(124,58,237,0.18);color:#c4b5fd;border:1px solid rgba(124,58,237,0.36); }
3737 .ai-unread { min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:#ef4444;color:#fff;font-size:10px;font-weight:800;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0; }
3838
3939 /* Right pane: conversation */
4040 .ai-conv { display:flex;flex-direction:column;flex:1;min-height:0; }
4141 .ai-conv-head { padding:12px 20px;border-bottom:1px solid var(--col-border);display:flex;align-items:flex-start;justify-content:space-between;gap:14px;flex-shrink:0; }
4242 .ai-conv-title { font-family:var(--font-display);font-size:18px;color:var(--col-text);margin:0; }
4343 .ai-conv-cust { font-size:12px;color:var(--col-text-3);margin-top:3px; }
4444 .ai-conv-actions { display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end; }
4545 .ai-btn { padding:7px 12px;background:var(--col-surface-2);border:1px solid var(--col-border-2);color:var(--col-text);border-radius:7px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit; }
4646 .ai-btn:hover { background:var(--col-accent-glow);border-color:var(--col-accent); }
4747 .ai-btn.is-primary { background:linear-gradient(135deg,var(--col-accent),var(--col-accent-deep));border:none;color:#fff; }
4848 .ai-btn.is-primary:hover { filter:brightness(1.08); }
4949 .ai-btn.is-success { background:rgba(34,197,94,0.15);border-color:rgba(34,197,94,0.4);color:#4ade80; }
5050 .ai-btn.is-success:hover { background:rgba(34,197,94,0.25); }
5151 .ai-btn.is-danger { background:rgba(239,68,68,0.12);border-color:rgba(239,68,68,0.4);color:#f87171; }
5252 .ai-btn.is-danger:hover { background:rgba(239,68,68,0.22); }
5353
5454 .ai-stream { flex:1;overflow-y:auto;padding:18px 22px;display:flex;flex-direction:column;gap:14px; }
5555 /* Admin sees roles inverted: ADMIN replies render on the right
5656 (their own side), CUSTOMER messages render on the left. */
5757 .ai-msg { display:flex;gap:10px;max-width:78%; }
5858 .ai-msg.is-admin { margin-left:auto;flex-direction:row-reverse; }
5959 .ai-msg.is-system { margin:0 auto;max-width:90%; }
6060 .ai-avatar { width:34px;height:34px;border-radius:50%;background:linear-gradient(135deg,#10b981,#059669);color:#fff;display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-size:14px;flex-shrink:0; }
6161 .ai-msg.is-admin .ai-avatar { background:linear-gradient(135deg,var(--col-accent),var(--col-accent-deep)); }
6262 .ai-msg.is-system .ai-avatar { background:var(--col-surface-2);color:var(--col-text-3);border:1px solid var(--col-border); }
6363 .ai-bubble { background:var(--col-surface-2);border:1px solid var(--col-border);border-radius:14px;padding:10px 14px;font-size:13.5px;line-height:1.55;color:var(--col-text); }
6464 .ai-msg.is-admin .ai-bubble { background:var(--col-accent-glow);border-color:var(--col-accent); }
6565 .ai-msg.is-system .ai-bubble { background:transparent;border:1px dashed var(--col-border-2);color:var(--col-text-3);font-style:italic;font-size:12.5px;text-align:center; }
6666 .ai-meta-line { font-size:10.5px;color:var(--col-text-3);margin-top:4px; }
6767 .ai-msg.is-admin .ai-meta-line { text-align:right; }
6868
6969 .ai-compose { border-top:1px solid var(--col-border);padding:14px 18px;flex-shrink:0;background:var(--col-surface-2); }
7070 .ai-compose textarea { width:100%;box-sizing:border-box;min-height:60px;max-height:200px;padding:10px 12px;background:var(--col-bg);border:1px solid var(--col-border-2);border-radius:8px;color:var(--col-text);font-family:inherit;font-size:13.5px;resize:vertical; }
7171 .ai-compose textarea:focus { outline:none;border-color:var(--col-accent); }
7272 .ai-compose-row { display:flex;justify-content:space-between;align-items:center;margin-top:8px;gap:10px; }
7373 .ai-compose-hint { font-size:11px;color:var(--col-text-3); }
7474
7575 .ai-chat-ribbon { padding:10px 16px;margin:12px 22px 0;background:rgba(34,197,94,0.10);border:1px solid rgba(34,197,94,0.4);border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:14px;font-size:12.5px;color:#4ade80; }
7676 .ai-chat-ribbon .dot { width:8px;height:8px;border-radius:50%;background:#4ade80;animation:aiPulse 1.4s infinite ease-in-out;display:inline-block;margin-right:8px;box-shadow:0 0 8px #4ade80; }
7777 .ai-invite-ribbon { padding:10px 16px;margin:12px 22px 0;background:rgba(124,58,237,0.10);border:1px solid rgba(124,58,237,0.4);border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:14px;font-size:12.5px;color:#c4b5fd; }
7878 @keyframes aiPulse { 0%, 100% { opacity:1 } 50% { opacity:0.35 } }
7979
8080 .ai-empty { flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:40px;color:var(--col-text-3);text-align:center;gap:14px; }
8181 .ai-empty h2 { font-family:var(--font-display);font-size:22px;color:var(--col-text);margin:0; }
8282
8383 @media (max-width:1100px) {
8484 .ai-page { grid-template-columns:1fr;height:auto; }
8585 .ai-pane.ai-left { max-height:340px; }
8686 }
8787</style>
8888
8989
9090<div class="page-head">
9191 <div>
9292 <span class="page-eyebrow"><span class="dot"></span> Admin &middot; Support</span>
9393 <h1 class="page-title">Customer messages</h1>
9494 <p class="page-subtitle">Every support thread on the platform. Reply or mark resolved when you&#39;re done.</p>
9595 </div>
9696</div>
9797
9898<!-- Tab nav between Inbox and Reports. Bigger pills with a clear
9999 active state (filled gradient + glow), so the page section is
100100 obvious at a glance. -->
101101<style>
102102 .ai-tabs { display:flex; gap:8px; margin-bottom:20px; }
103103 .ai-tab {
104104 display:inline-flex; align-items:center; gap:8px;
105105 padding:11px 22px; height:42px;
106106 border-radius:11px;
107107 font-size:13px; font-weight:700;
108108 letter-spacing:0.5px; text-transform:uppercase;
109109 text-decoration:none;
110110 border:1px solid var(--col-border);
111111 background:var(--col-surface-2);
112112 color:var(--col-text-2);
113113 transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.12s, box-shadow 0.15s;
114114 }
115115 .ai-tab:hover {
116116 background:var(--col-surface-3); color:var(--col-text);
117117 border-color:rgba(59,130,246,0.40);
118118 transform:translateY(-1px);
119119 }
120120 .ai-tab.active {
121121 background:linear-gradient(135deg, rgba(59,130,246,0.30) 0%, rgba(99,102,241,0.22) 100%);
122122 border-color:rgba(59,130,246,0.75);
123123 color:#fff;
124124 box-shadow:0 0 18px rgba(59,130,246,0.45);
125125 }
126126 .ai-tab svg { width:16px; height:16px; flex-shrink:0; opacity:0.85; }
127127 .ai-tab.active svg { opacity:1; }
128128
129129 /* Attachment widget -- mirrors the customer-side support template
130130 so admins get the same drag/drop/paste/click upload experience. */
131131 .sup-att-area { margin-top: 10px; }
132132 .sup-att-strip { display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px; }
133133 .sup-att-strip:empty { display:none; }
134134 .sup-att-thumb { position:relative;width:72px;height:72px;border-radius:8px;overflow:hidden;border:1px solid rgba(255,255,255,0.18);background:rgba(0,0,0,0.30); }
135135 .sup-att-thumb img { width:100%;height:100%;object-fit:cover;display:block; }
136136 .sup-att-thumb.is-pending { opacity:0.55; }
137137 .sup-att-thumb.is-pending::after { content:'';position:absolute;inset:0;background:linear-gradient(110deg, transparent 30%, rgba(255,255,255,0.18) 50%, transparent 70%);animation:sup-att-shimmer 1.1s linear infinite; }
138138 .sup-att-thumb.failed { border-color:#ef4444;opacity:0.7; }
139139 @keyframes sup-att-shimmer { 0% { transform:translateX(-100%); } 100% { transform:translateX(100%); } }
140140 .sup-att-rm { position:absolute;top:3px;right:3px;width:20px;height:20px;border-radius:50%;background:rgba(0,0,0,0.78);color:#fff;border:none;cursor:pointer;line-height:18px;font-size:16px;padding:0;display:grid;place-items:center; }
141141 .sup-att-rm:hover { background:#ef4444; }
142142 .sup-att-controls { display:flex;align-items:center;gap:10px;flex-wrap:wrap; }
143143 .sup-att-pick { display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.18);color:var(--col-text-2);font-size:12px;font-weight:600;border-radius:7px;cursor:pointer;transition:background 0.15s, color 0.15s, border-color 0.15s; }
144144 .sup-att-pick:hover { background:rgba(59,130,246,0.18);color:#fff;border-color:rgba(59,130,246,0.55); }
145145 .sup-att-pick svg { width:14px;height:14px; }
146146 .sup-att-pick input[type="file"] { position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0; }
147147 .sup-att-hint { font-size:11px;color:var(--col-text-3); }
148148 .sup-att-form.att-drag { outline:2px dashed var(--col-accent);outline-offset:4px;border-radius:8px; }
149149 .sup-att-count { display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:11px;font-weight:700;letter-spacing:0.3px;text-transform:uppercase; }
150150 .sup-att-count.is-working { background:rgba(245,158,11,0.18);border:1px solid rgba(245,158,11,0.45);color:#fde68a; }
151151 .sup-att-count.is-ready { background:rgba(16,185,129,0.18);border:1px solid rgba(16,185,129,0.55);color:#6ee7b7; }
152152 .sup-att-count.is-working::before { content:'';width:8px;height:8px;border-radius:50%;background:#f59e0b;animation:sup-att-blink 1s ease-in-out infinite; }
153153 .sup-att-count.is-ready::before { content:'\2713';font-weight:800;color:#10b981; }
154154 @keyframes sup-att-blink { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
155155 .sup-msg-atts { display:flex;flex-wrap:wrap;gap:6px;margin-top:8px; }
156156 .sup-msg-att { display:block;border-radius:8px;overflow:hidden;border:1px solid rgba(255,255,255,0.15);transition:border-color 0.12s,transform 0.12s; }
157157 .sup-msg-att:hover { border-color:var(--col-accent);transform:translateY(-1px); }
158158 .sup-msg-att img { display:block;max-width:240px;max-height:180px;width:auto;height:auto; }
159159</style>
160160<div class="ai-tabs">
161161 <a href="/admin_messages.cgi" class="ai-tab[if:$tab_inbox] active[/if]">
162162 <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"/></svg>
163163 Inbox
164164 </a>
165165 <a href="/admin_messages.cgi?tab=reports" class="ai-tab[if:$tab_reports] active[/if]">
166166 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="M7 14l4-4 4 4 6-6"/></svg>
167167 Reports
168168 </a>
169169</div>
170170
171171[if:$tab_reports]
172172<!-- ===== SUPPORT REPORTS ===== -->
173173<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:18px">
174174 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
175175 <div style="font-size:11px;font-weight:700;color:var(--col-text-3);letter-spacing:1.2px;text-transform:uppercase;margin-bottom:6px">Total threads</div>
176176 <div style="font-family:var(--font-display,sans-serif);font-size:28px;color:var(--col-text);line-height:1">$rep_total</div>
177177 </div>
178178 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
179179 <div style="font-size:11px;font-weight:700;color:#f87171;letter-spacing:1.2px;text-transform:uppercase;margin-bottom:6px">Needs reply</div>
180180 <div style="font-family:var(--font-display,sans-serif);font-size:28px;color:#f87171;line-height:1">$rep_open</div>
181181 </div>
182182 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
183183 <div style="font-size:11px;font-weight:700;color:#fbbf24;letter-spacing:1.2px;text-transform:uppercase;margin-bottom:6px">Waiting on buyer</div>
184184 <div style="font-family:var(--font-display,sans-serif);font-size:28px;color:#fbbf24;line-height:1">$rep_waiting</div>
185185 </div>
186186 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
187187 <div style="font-size:11px;font-weight:700;color:#4ade80;letter-spacing:1.2px;text-transform:uppercase;margin-bottom:6px">Resolved (7d)</div>
188188 <div style="font-family:var(--font-display,sans-serif);font-size:28px;color:#4ade80;line-height:1">$rep_resolved_7d</div>
189189 <div style="font-size:11px;color:var(--col-text-3);margin-top:6px">$rep_resolved_30d in last 30d</div>
190190 </div>
191191 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
192192 <div style="font-size:11px;font-weight:700;color:var(--col-text-3);letter-spacing:1.2px;text-transform:uppercase;margin-bottom:6px">New tickets (7d)</div>
193193 <div style="font-family:var(--font-display,sans-serif);font-size:28px;color:var(--col-text);line-height:1">$rep_new_7d</div>
194194 <div style="font-size:11px;color:var(--col-text-3);margin-top:6px">$rep_new_30d in last 30d</div>
195195 </div>
196196 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
197197 <div style="font-size:11px;font-weight:700;color:var(--col-text-3);letter-spacing:1.2px;text-transform:uppercase;margin-bottom:6px">Avg first response</div>
198198 [if:$rep_avg_resp_is_minutes]<div style="font-family:var(--font-display,sans-serif);font-size:28px;color:var(--col-accent-bright);line-height:1">$rep_avg_resp_min<span style="font-size:14px;color:var(--col-text-3)"> min</span></div>[/if]
199199 [if:$rep_avg_resp_is_hours]<div style="font-family:var(--font-display,sans-serif);font-size:28px;color:var(--col-accent-bright);line-height:1">$rep_avg_resp_hours<span style="font-size:14px;color:var(--col-text-3)"> hrs</span></div>[/if]
200200 [if:$rep_avg_resp_unknown]<div style="font-family:var(--font-display,sans-serif);font-size:18px;color:var(--col-text-3);line-height:1.2">No replies yet</div>[/if]
201201 <div style="font-size:11px;color:var(--col-text-3);margin-top:6px">From open to first admin reply (30d avg)</div>
202202 </div>
203203</div>
204204<!-- Area charts: daily new tickets + daily resolved over 30 days.
205205 Hover any day for a tooltip; click a day filters the inbox to
206206 threads opened on that date. -->
207207[if:$has_charts]
208208<style>
209209 .ac-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(420px,1fr)); gap:14px; margin-bottom:18px; }
210210 .ac-card { position:relative; background:var(--col-surface-1); border:1px solid var(--col-border); border-radius:14px; padding:18px 20px; }
211211 .ac-head { display:flex; align-items:baseline; justify-content:space-between; margin-bottom:10px; }
212212 .ac-title { font-size:12px; letter-spacing:1.5px; text-transform:uppercase; font-weight:700; color:var(--col-text-3); }
213213 .ac-sub { font-size:11px; color:var(--col-text-3); }
214214 .ac-hot { cursor:pointer; }
215215 .ac-hot:hover ~ .ac-guide { display:none; }
216216 .ac-tip {
217217 position:absolute; pointer-events:none; opacity:0; transition:opacity 0.1s;
218218 background:rgba(8,12,26,0.96); border:1px solid var(--col-border);
219219 color:#fff; font-size:12px; padding:7px 11px; border-radius:7px;
220220 white-space:nowrap; box-shadow:0 6px 18px rgba(0,0,0,0.45);
221221 transform:translate(-50%, -100%); margin-top:-8px;
222222 z-index:50;
223223 }
224224 .ac-tip strong { color:var(--col-accent-bright); }
225225</style>
226226<div class="ac-grid">
227227 <div class="ac-card" data-chart-id="new">
228228 <div class="ac-head">
229229 <span class="ac-title">New tickets &middot; last 30 days</span>
230230 <span class="ac-sub" style="color:#93c5fd">$rep_new_30d total</span>
231231 </div>
232232 $chart_new_html
233233 <div class="ac-tip"></div>
234234 </div>
235235 <div class="ac-card" data-chart-id="res">
236236 <div class="ac-head">
237237 <span class="ac-title">Resolved &middot; last 30 days</span>
238238 <span class="ac-sub" style="color:#6ee7b7">$rep_resolved_30d total</span>
239239 </div>
240240 $chart_resolved_html
241241 <div class="ac-tip"></div>
242242 </div>
243243</div>
244244<script>
245245(function () {
246246 document.querySelectorAll('.ac-card').forEach(function (card) {
247247 var svg = card.querySelector('.ac-svg');
248248 var tip = card.querySelector('.ac-tip');
249249 var guide= card.querySelector('.ac-guide');
250250 var dot = card.querySelector('.ac-dot');
251251 if (!svg || !tip) return;
252252 var hots = card.querySelectorAll('.ac-hot');
253253 var vb = svg.viewBox.baseVal;
254254 function showAt(hot) {
255255 var x = parseFloat(hot.getAttribute('data-x'));
256256 var y = parseFloat(hot.getAttribute('data-y'));
257257 var lbl = hot.getAttribute('data-label');
258258 var val = hot.getAttribute('data-value');
259259 // Move guide line + dot inside SVG coords.
260260 if (guide) {
261261 guide.setAttribute('x1', x); guide.setAttribute('x2', x);
262262 guide.setAttribute('opacity', 0.55);
263263 }
264264 if (dot) {
265265 dot.setAttribute('cx', x); dot.setAttribute('cy', y);
266266 dot.setAttribute('opacity', 1);
267267 }
268268 // Convert SVG x to DOM x for tooltip placement.
269269 var rect = svg.getBoundingClientRect();
270270 var domX = (x / vb.width) * rect.width;
271271 var domY = (y / vb.height) * rect.height;
272272 tip.style.left = domX + 'px';
273273 tip.style.top = domY + 'px';
274274 tip.innerHTML = '<strong>' + val + '</strong> on ' + lbl;
275275 tip.style.opacity = '1';
276276 }
277277 function hide() {
278278 tip.style.opacity = '0';
279279 if (guide) guide.setAttribute('opacity', 0);
280280 if (dot) dot.setAttribute('opacity', 0);
281281 }
282282 hots.forEach(function (hot) {
283283 hot.addEventListener('mouseenter', function () { showAt(hot); });
284284 hot.addEventListener('mouseleave', hide);
285285 hot.addEventListener('click', function () {
286286 var iso = hot.getAttribute('data-iso');
287287 if (iso) location.href = '/admin_messages.cgi?day=' + encodeURIComponent(iso);
288288 });
289289 });
290290 });
291291})();
292292</script>
293293[/if]
294294
295295<div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:14px;padding:20px">
296296 <p style="margin:0;color:var(--col-text-2);font-size:13.5px;line-height:1.7">
297297 These metrics summarize your support load and responsiveness. Click any day on the charts above to filter the inbox to threads opened that day, or switch back to the
298298 <a href="/admin_messages.cgi" style="color:var(--col-accent-bright)">Inbox</a> to read and reply to threads.
299299 </p>
300300</div>
301301[/if]
302302
303303[if:$tab_inbox]
304304<div class="ai-page">
305305
306306 <!-- LEFT: thread list -->
307307 <div class="ai-pane ai-left">
308308 <div class="ai-list-head">
309309 <div class="ai-list-sub">All threads</div>
310310 <h2 class="ai-list-title">$thread_count showing</h2>
311311 <div class="ai-filters">
312312 <a class="ai-filter[if:!$filter] is-active[/if]" href="/admin_messages.cgi">All</a>
313313 <a class="ai-filter" href="/admin_messages.cgi?status=waiting_admin">Needs reply</a>
314314 <a class="ai-filter" href="/admin_messages.cgi?status=waiting_customer">Waiting on buyer</a>
315315 <a class="ai-filter" href="/admin_messages.cgi?status=resolved">Resolved</a>
316316 <a class="ai-filter" href="/admin_messages.cgi?status=closed">Closed</a>
317317 </div>
318318 </div>
319319 <div class="ai-list">
320320 [if:!$has_threads]
321321 <div class="ai-list-empty">No threads in this view.</div>
322322 [/if]
323323 [if:$has_threads]
324324 [loop:@threads]
325325 <a class="ai-thread[if:$loop1.is_active] is-active[/if]" href="/admin_messages.cgi?thread_id=$loop1.id" title="$loop1.customer_name -- $loop1.subject">
326326 <span class="ai-thread-cust">$loop1.customer_name</span>
327327 <span class="ai-thread-subj">$loop1.subject</span>
328328 <span class="ai-pill $loop1.status_class">$loop1.status_label</span>
329329 [if:$loop1.has_unread]<span class="ai-unread">$loop1.unread</span>[/if]
330330 <span class="ai-thread-time"><span class="ts" data-ts="$loop1.last_epoch" data-fmt="relative">$loop1.last_at</span></span>
331331 </a>
332332 [/loop]
333333 [/if]
334334 </div>
335335 </div>
336336
337337 <!-- RIGHT: conversation -->
338338 <div class="ai-pane ai-right">
339339 [if:!$has_active]
340340 <div class="ai-empty">
341341 <div style="font-size:48px;opacity:0.5">&#9993;</div>
342342 <h2>Pick a thread</h2>
343343 <p>Select a customer thread on the left to see their messages and reply.</p>
344344 </div>
345345 [/if]
346346
347347 [if:$has_active]
348348 <div class="ai-conv">
349349 <div class="ai-conv-head">
350350 <div style="min-width:0;flex:1">
351351 <div class="ai-conv-title">$active_subject</div>
352352 <div class="ai-conv-cust">From <strong style="color:var(--col-text-2)">$active_cust_name</strong> &middot; $active_cust_email</div>
353353 </div>
354354 <div class="ai-conv-actions">
355355 <form method="post" action="/admin_messages.cgi" style="display:inline;margin:0">
356356 <input type="hidden" name="action" value="set_status">
357357 <input type="hidden" name="thread_id" value="$active_id">
358358 <input type="hidden" name="status" value="resolved">
359359 <button type="submit" class="ai-btn is-success">&#10003; Mark resolved</button>
360360 </form>
361361 <form method="post" action="/admin_messages.cgi" style="display:inline;margin:0">
362362 <input type="hidden" name="action" value="set_status">
363363 <input type="hidden" name="thread_id" value="$active_id">
364364 <input type="hidden" name="status" value="closed">
365365 <button type="submit" class="ai-btn">Close</button>
366366 </form>
367367 </div>
368368 </div>
369369
370370 <div class="ai-stream" id="ai-stream" data-thread-id="$active_id" data-last-id="$last_message_id" data-side="admin">
371371 [loop:@messages]
372372 <div class="ai-msg[if:$loop1.is_customer] is-customer[/if][if:$loop1.is_admin] is-admin[/if][if:$loop1.is_system] is-system[/if]" data-msg-id="$loop1.id">
373373 [if:!$loop1.is_system]<div class="ai-avatar">$loop1.sender_initial</div>[/if]
374374 [if:$loop1.is_system]<div class="ai-avatar">&#9432;</div>[/if]
375375 <div>
376376 <div class="ai-bubble">$loop1.body_html</div>
377377 [if:$loop1.has_attach]
378378 <div class="sup-msg-atts">
379379 [loop:@attachments]
380380 <a href="$loop2.url" target="_blank" rel="noopener" class="sup-msg-att" title="$loop2.name"><img src="$loop2.url" alt=""></a>
381381 [/loop]
382382 </div>
383383 [/if]
384384 <div class="ai-meta-line">$loop1.sender_name &middot; <span class="ts" data-ts="$loop1.ts_epoch" data-fmt="relative">$loop1.ts</span></div>
385385 </div>
386386 </div>
387387 [/loop]
388388 </div>
389389
390390 <form class="ai-compose sup-att-form" id="ai-compose" data-thread-id="$active_id">
391391 <textarea name="body" id="ai-body" placeholder="Write a reply..." required></textarea>
392392 <div class="sup-att-area" data-att-area>
393393 <div class="sup-att-strip" data-att-strip></div>
394394 <div class="sup-att-controls">
395395 <label class="sup-att-pick" data-att-pick>
396396 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
397397 <span>Attach image</span>
398398 <input type="file" data-att-input accept="image/jpeg,image/png,image/gif,image/webp,image/bmp,.jpg,.jpeg,.png,.gif,.webp,.bmp" multiple>
399399 </label>
400400 <span class="sup-att-hint">Drag &amp; drop or paste also works</span>
401401 </div>
402402 </div>
403403 <div class="ai-compose-row">
404404 <span class="ai-compose-hint">Enter to send &middot; Shift+Enter for a new line</span>
405405 <button type="submit" class="ai-btn is-primary">Send</button>
406406 </div>
407407 </form>
408408 </div>
409409 [/if]
410410 </div>
411411</div>
412412
413413
414414<script>
415415(function(){
416416 var stream = document.getElementById('ai-stream');
417417 if (!stream) return;
418418 var threadId = stream.getAttribute('data-thread-id');
419419 var lastId = parseInt(stream.getAttribute('data-last-id') || '0', 10);
420420 stream.scrollTop = stream.scrollHeight;
421421
422422 var compose = document.getElementById('ai-compose');
423423 var body = document.getElementById('ai-body');
424424 function sendMessage() {
425425 var text = body.value.trim();
426426 if (!text) return;
427427 // Capture FormData FIRST -- FormData() skips disabled fields,
428428 // so disabling the textarea before this would silently drop the
429429 // body and the server returns "empty message".
430430 var data = new FormData(compose);
431431 data.append('thread_id', threadId);
432432 data.append('async', '1');
433433 body.disabled = true;
434434 var strip = compose.querySelector('[data-att-strip]');
435435 var attUrls = [];
436436 if (strip) {
437437 strip.querySelectorAll('.sup-att-thumb img').forEach(function(im){ attUrls.push(im.src); });
438438 }
439439 var sentText = text;
440440 fetch('/support_send.cgi', { method:'POST', body:data })
441441 .then(function(r){ return r.json(); })
442442 .then(function(j){
443443 body.disabled = false;
444444 if (!j || !j.ok) { appToast({ message: 'Send failed: ' + (j && j.error ? j.error : 'unknown'), type: 'error' }); return; }
445445 body.value = '';
446446 if (strip) strip.innerHTML = '';
447447 body.focus();
448448 // Optimistic render so the admin sees their own reply land
449449 // immediately even if support_poll lags.
450450 if (j.message_id) {
451451 lastId = Math.max(lastId, parseInt(j.message_id, 10) || 0);
452452 var attHtml = '';
453453 if (attUrls.length) {
454454 attHtml = '<div class="sup-msg-atts">' + attUrls.map(function(u){
455455 return '<a href="' + u + '" target="_blank" rel="noopener" class="sup-msg-att"><img src="' + u + '" alt=""></a>';
456456 }).join('') + '</div>';
457457 }
458458 var safe = sentText.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
459459 appendMessage({
460460 id: j.message_id, sender_role: 'admin', sender_name: 'You',
461461 body_html: safe + attHtml, ts: 'just now'
462462 });
463463 }
464464 pollNow();
465465 })
466466 .catch(function(err){ body.disabled = false; appToast({ message: 'Send failed: ' + err.message, type: 'error' }); });
467467 }
468468 if (compose) {
469469 compose.addEventListener('submit', function(e){ e.preventDefault(); sendMessage(); });
470470 body.addEventListener('keydown', function(e){
471471 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
472472 });
473473 }
474474
475475 function appendMessage(m) {
476476 var wrap = document.createElement('div');
477477 var roleClass = m.sender_role === 'customer' ? 'is-customer'
478478 : m.sender_role === 'admin' ? 'is-admin'
479479 : 'is-system';
480480 wrap.className = 'ai-msg ' + roleClass;
481481 wrap.setAttribute('data-msg-id', m.id);
482482 var initial = (m.sender_name || '?').charAt(0).toUpperCase();
483483 var avatar = '<div class="ai-avatar">' + (m.sender_role === 'system' ? '&#9432;' : initial) + '</div>';
484484 wrap.innerHTML = avatar +
485485 '<div>' +
486486 ' <div class="ai-bubble">' + m.body_html + '</div>' +
487487 ' <div class="ai-meta-line">' + (m.sender_name || '') + ' &middot; just now</div>' +
488488 '</div>';
489489 stream.appendChild(wrap);
490490 stream.scrollTop = stream.scrollHeight;
491491 }
492492 function pollNow() {
493493 fetch('/support_poll.cgi?thread_id=' + encodeURIComponent(threadId) + '&since=' + lastId, { cache:'no-store' })
494494 .then(function(r){ return r.json(); })
495495 .then(function(j){
496496 if (!j || !j.ok) return;
497497 (j.messages || []).forEach(function(m){
498498 if (m.id > lastId) { lastId = m.id; appendMessage(m); }
499499 });
500500 })
501501 .catch(function(){});
502502 }
503503 // Async-only: poll every 12s for new customer replies.
504504 setInterval(pollNow, 12000);
505505})();
506506
507507// ===== Attachment widget (shared customer-side identical implementation)
508508(function(){
509509 function setupAttachments(form) {
510510 var area = form.querySelector('[data-att-area]');
511511 if (!area) return;
512512 var input = area.querySelector('[data-att-input]');
513513 var pick = area.querySelector('[data-att-pick]');
514514 var strip = area.querySelector('[data-att-strip]');
515515 var ta = form.querySelector('textarea');
516516
517517 var counter = document.createElement('span');
518518 counter.className = 'sup-att-count';
519519 counter.style.display = 'none';
520520 if (pick && pick.parentNode) pick.parentNode.insertBefore(counter, pick.nextSibling);
521521 function updateCounter() {
522522 var n = strip.querySelectorAll('.sup-att-thumb').length;
523523 var ok = strip.querySelectorAll('input[name="attach_id"]').length;
524524 var pending = n - ok;
525525 if (n === 0) { counter.style.display = 'none'; return; }
526526 counter.style.display = 'inline-flex';
527527 if (pending > 0) {
528528 counter.textContent = n + ' selected (' + ok + ' ready, ' + pending + ' uploading...)';
529529 counter.className = 'sup-att-count is-working';
530530 } else {
531531 counter.textContent = n + (n === 1 ? ' image attached' : ' images attached');
532532 counter.className = 'sup-att-count is-ready';
533533 }
534534 }
535535
536536 function makeThumb(src, id, pending, name) {
537537 var t = document.createElement('div');
538538 t.className = 'sup-att-thumb' + (pending ? ' is-pending' : '');
539539 if (name) t.title = name;
540540 var img = document.createElement('img'); img.src = src; t.appendChild(img);
541541 if (!pending && id) {
542542 var hidden = document.createElement('input');
543543 hidden.type = 'hidden'; hidden.name = 'attach_id'; hidden.value = id;
544544 t.appendChild(hidden);
545545 var rm = document.createElement('button');
546546 rm.type = 'button'; rm.className = 'sup-att-rm';
547547 rm.innerHTML = '&times;'; rm.setAttribute('aria-label', 'Remove attachment');
548548 rm.addEventListener('click', function(){ t.remove(); updateCounter(); });
549549 t.appendChild(rm);
550550 }
551551 return t;
552552 }
553553 function upload(file) {
554554 if (!file) return;
555555 var t = (file.type || '').toLowerCase();
556556 var nm = (file.name || '').toLowerCase();
557557 var okType = t.indexOf('image/') === 0;
558558 var okExt = false;
559559 var exts = ['.jpg','.jpeg','.png','.gif','.webp','.bmp'];
560560 for (var ei = 0; ei < exts.length; ei++) {
561561 if (nm.length >= exts[ei].length && nm.lastIndexOf(exts[ei]) === nm.length - exts[ei].length) { okExt = true; break; }
562562 }
563563 if (!okType && !okExt) {
564564 appToast({ message: file.name + ': not an image -- only JPG / PNG / GIF / WebP / BMP are accepted.', type: 'warning' });
565565 return;
566566 }
567567 var placeholder = makeThumb(URL.createObjectURL(file), null, true, file.name);
568568 strip.appendChild(placeholder);
569569 updateCounter();
570570 var fd = new FormData();
571571 fd.append('image', file);
572572 fetch('/support_upload.cgi', { method:'POST', body:fd, credentials:'same-origin' })
573573 .then(function(r){ return r.json(); })
574574 .then(function(j){
575575 if (j && j.success) {
576576 placeholder.replaceWith(makeThumb(j.url, j.id, false, j.filename));
577577 } else {
578578 placeholder.classList.add('failed'); placeholder.classList.remove('is-pending');
579579 placeholder.title = (j && j.error) ? j.error : 'upload failed';
580580 }
581581 updateCounter();
582582 })
583583 .catch(function(){
584584 placeholder.classList.add('failed'); placeholder.classList.remove('is-pending');
585585 updateCounter();
586586 });
587587 }
588588 // Native <label> wrapping opens the picker -- no JS .click().
589589 if (input) {
590590 input.addEventListener('change', function(){
591591 Array.prototype.slice.call(input.files).forEach(upload);
592592 input.value = '';
593593 });
594594 }
595595 ['dragenter','dragover'].forEach(function(ev){
596596 form.addEventListener(ev, function(e){
597597 if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types, 'Files') !== -1) {
598598 e.preventDefault(); form.classList.add('att-drag');
599599 }
600600 });
601601 });
602602 ['dragleave','dragend','drop'].forEach(function(ev){
603603 form.addEventListener(ev, function(){ form.classList.remove('att-drag'); });
604604 });
605605 form.addEventListener('drop', function(e){
606606 if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
607607 e.preventDefault();
608608 Array.prototype.slice.call(e.dataTransfer.files).forEach(upload);
609609 });
610610 if (ta) {
611611 ta.addEventListener('paste', function(e){
612612 var items = e.clipboardData && e.clipboardData.items;
613613 if (!items) return;
614614 for (var i = 0; i < items.length; i++) {
615615 if (items[i].kind === 'file') {
616616 var f = items[i].getAsFile();
617617 if (f) upload(f);
618618 }
619619 }
620620 });
621621 }
622622 }
623623 document.querySelectorAll('form.sup-att-form').forEach(setupAttachments);
624624
625625 // Same global drag-default killer as the customer side, so files
626626 // dropped anywhere on /admin_messages.cgi don't navigate.
627627 window.addEventListener('dragover', function(e){
628628 if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types, 'Files') !== -1) {
629629 e.preventDefault();
630630 }
631631 });
632632 window.addEventListener('drop', function(e){
633633 if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
634634 var inForm = e.target && e.target.closest && e.target.closest('form.sup-att-form');
635635 if (inForm) return;
636636 e.preventDefault();
637637 var firstForm = document.querySelector('form.sup-att-form');
638638 if (!firstForm) return;
639639 var area = firstForm.querySelector('[data-att-area]');
640640 if (!area || area.offsetParent === null) return;
641641 var dropEvt = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: e.dataTransfer });
642642 firstForm.dispatchEvent(dropEvt);
643643 });
644644})();
645645</script>
646646[/if]
647647<!-- Non-conditional trailing content. MODS::Template returns the
648648 value of the last evaluated expression; a template that ends on
649649 an [if] block (false branch) returns undef and the whole body
650650 renders empty. This div forces a final non-conditional .= so the
651651 accumulated $print_contents is what gets returned. -->
652652<div class="adm-foot-spacer" data-end="admin-messages">&nbsp;</div>