Diff -- /var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/TEMPLATES/webstls_admin_messages.html

O Operator
Diff

/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/TEMPLATES/webstls_admin_messages.html

added on local at 2026-07-11 18:38:18

Added
+652
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 2290bc58bb3c
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1<!-- =====================================================================
2 ADMIN SUPPORT INBOX
3 /admin_messages.cgi -- platform admin's inbox of every customer
4 support thread. Same shape as the customer's /support.cgi with
5 extra controls in the right pane: change status, see customer
6 email + priority. Async messaging only.
7===================================================================== -->
8<style>
9 .ai-page { display:grid;grid-template-columns:380px 1fr;gap:20px;height:calc(100vh - 100px);min-height:560px;padding:0;margin:0 -8px; }
10 .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; }
11
12 .ai-list-head { padding:14px 18px;border-bottom:1px solid var(--col-border);flex-shrink:0; }
13 .ai-list-title { font-family:var(--font-display);font-size:18px;color:var(--col-text);margin:0; }
14 .ai-list-sub { font-size:11px;color:var(--col-text-3);letter-spacing:1px;text-transform:uppercase; }
15 .ai-filters { display:flex;gap:4px;margin-top:10px;flex-wrap:wrap; }
16 .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; }
17 .ai-filter:hover { background:var(--col-accent-glow);color:var(--col-text); }
18 .ai-filter.is-active { background:var(--col-accent);color:#fff;border-color:var(--col-accent); }
19
20 .ai-list { flex:1;overflow-y:auto;padding:8px; }
21 .ai-list-empty { padding:50px 22px;text-align:center;color:var(--col-text-3);font-size:13px; }
22 /* Compact one-line thread rows -- everything on a single horizontal
23 flex line so the inbox fits 3-4x more threads per viewport. */
24 .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; }
25 .ai-thread:hover { background:var(--col-surface-2);border-color:var(--col-border); }
26 .ai-thread.is-active { background:var(--col-accent-glow);border-color:var(--col-accent); }
27 .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; }
28 .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; }
29 .ai-thread-time { font-size:11px;color:var(--col-text-3);flex-shrink:0; }
30 .ai-pill { padding:1px 7px;border-radius:10px;font-size:10px;font-weight:700;letter-spacing:0.4px;flex-shrink:0; }
31 .ai-pill.st-needs { background:rgba(239,68,68,0.15);color:#f87171;border:1px solid rgba(239,68,68,0.32); }
32 .ai-pill.st-open { background:rgba(59,130,246,0.18);color:#60a5fa;border:1px solid rgba(59,130,246,0.36); }
33 .ai-pill.st-waiting { background:rgba(245,158,11,0.15);color:#fbbf24;border:1px solid rgba(245,158,11,0.32); }
34 .ai-pill.st-resolved { background:rgba(34,197,94,0.15);color:#4ade80;border:1px solid rgba(34,197,94,0.32); }
35 .ai-pill.st-closed { background:rgba(148,163,184,0.15);color:#cbd5e1;border:1px solid rgba(148,163,184,0.32); }
36 .ai-pill.chat { background:rgba(124,58,237,0.18);color:#c4b5fd;border:1px solid rgba(124,58,237,0.36); }
37 .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; }
38
39 /* Right pane: conversation */
40 .ai-conv { display:flex;flex-direction:column;flex:1;min-height:0; }
41 .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; }
42 .ai-conv-title { font-family:var(--font-display);font-size:18px;color:var(--col-text);margin:0; }
43 .ai-conv-cust { font-size:12px;color:var(--col-text-3);margin-top:3px; }
44 .ai-conv-actions { display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end; }
45 .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; }
46 .ai-btn:hover { background:var(--col-accent-glow);border-color:var(--col-accent); }
47 .ai-btn.is-primary { background:linear-gradient(135deg,var(--col-accent),var(--col-accent-deep));border:none;color:#fff; }
48 .ai-btn.is-primary:hover { filter:brightness(1.08); }
49 .ai-btn.is-success { background:rgba(34,197,94,0.15);border-color:rgba(34,197,94,0.4);color:#4ade80; }
50 .ai-btn.is-success:hover { background:rgba(34,197,94,0.25); }
51 .ai-btn.is-danger { background:rgba(239,68,68,0.12);border-color:rgba(239,68,68,0.4);color:#f87171; }
52 .ai-btn.is-danger:hover { background:rgba(239,68,68,0.22); }
53
54 .ai-stream { flex:1;overflow-y:auto;padding:18px 22px;display:flex;flex-direction:column;gap:14px; }
55 /* Admin sees roles inverted: ADMIN replies render on the right
56 (their own side), CUSTOMER messages render on the left. */
57 .ai-msg { display:flex;gap:10px;max-width:78%; }
58 .ai-msg.is-admin { margin-left:auto;flex-direction:row-reverse; }
59 .ai-msg.is-system { margin:0 auto;max-width:90%; }
60 .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; }
61 .ai-msg.is-admin .ai-avatar { background:linear-gradient(135deg,var(--col-accent),var(--col-accent-deep)); }
62 .ai-msg.is-system .ai-avatar { background:var(--col-surface-2);color:var(--col-text-3);border:1px solid var(--col-border); }
63 .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); }
64 .ai-msg.is-admin .ai-bubble { background:var(--col-accent-glow);border-color:var(--col-accent); }
65 .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; }
66 .ai-meta-line { font-size:10.5px;color:var(--col-text-3);margin-top:4px; }
67 .ai-msg.is-admin .ai-meta-line { text-align:right; }
68
69 .ai-compose { border-top:1px solid var(--col-border);padding:14px 18px;flex-shrink:0;background:var(--col-surface-2); }
70 .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; }
71 .ai-compose textarea:focus { outline:none;border-color:var(--col-accent); }
72 .ai-compose-row { display:flex;justify-content:space-between;align-items:center;margin-top:8px;gap:10px; }
73 .ai-compose-hint { font-size:11px;color:var(--col-text-3); }
74
75 .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; }
76 .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; }
77 .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; }
78 @keyframes aiPulse { 0%, 100% { opacity:1 } 50% { opacity:0.35 } }
79
80 .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; }
81 .ai-empty h2 { font-family:var(--font-display);font-size:22px;color:var(--col-text);margin:0; }
82
83 @media (max-width:1100px) {
84 .ai-page { grid-template-columns:1fr;height:auto; }
85 .ai-pane.ai-left { max-height:340px; }
86 }
87</style>
88
89
90<div class="page-head">
91 <div>
92 <span class="page-eyebrow"><span class="dot"></span> Admin &middot; Support</span>
93 <h1 class="page-title">Customer messages</h1>
94 <p class="page-subtitle">Every support thread on the platform. Reply or mark resolved when you&#39;re done.</p>
95 </div>
96</div>
97
98<!-- Tab nav between Inbox and Reports. Bigger pills with a clear
99 active state (filled gradient + glow), so the page section is
100 obvious at a glance. -->
101<style>
102 .ai-tabs { display:flex; gap:8px; margin-bottom:20px; }
103 .ai-tab {
104 display:inline-flex; align-items:center; gap:8px;
105 padding:11px 22px; height:42px;
106 border-radius:11px;
107 font-size:13px; font-weight:700;
108 letter-spacing:0.5px; text-transform:uppercase;
109 text-decoration:none;
110 border:1px solid var(--col-border);
111 background:var(--col-surface-2);
112 color:var(--col-text-2);
113 transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.12s, box-shadow 0.15s;
114 }
115 .ai-tab:hover {
116 background:var(--col-surface-3); color:var(--col-text);
117 border-color:rgba(59,130,246,0.40);
118 transform:translateY(-1px);
119 }
120 .ai-tab.active {
121 background:linear-gradient(135deg, rgba(59,130,246,0.30) 0%, rgba(99,102,241,0.22) 100%);
122 border-color:rgba(59,130,246,0.75);
123 color:#fff;
124 box-shadow:0 0 18px rgba(59,130,246,0.45);
125 }
126 .ai-tab svg { width:16px; height:16px; flex-shrink:0; opacity:0.85; }
127 .ai-tab.active svg { opacity:1; }
128
129 /* Attachment widget -- mirrors the customer-side support template
130 so admins get the same drag/drop/paste/click upload experience. */
131 .sup-att-area { margin-top: 10px; }
132 .sup-att-strip { display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px; }
133 .sup-att-strip:empty { display:none; }
134 .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); }
135 .sup-att-thumb img { width:100%;height:100%;object-fit:cover;display:block; }
136 .sup-att-thumb.is-pending { opacity:0.55; }
137 .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; }
138 .sup-att-thumb.failed { border-color:#ef4444;opacity:0.7; }
139 @keyframes sup-att-shimmer { 0% { transform:translateX(-100%); } 100% { transform:translateX(100%); } }
140 .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; }
141 .sup-att-rm:hover { background:#ef4444; }
142 .sup-att-controls { display:flex;align-items:center;gap:10px;flex-wrap:wrap; }
143 .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; }
144 .sup-att-pick:hover { background:rgba(59,130,246,0.18);color:#fff;border-color:rgba(59,130,246,0.55); }
145 .sup-att-pick svg { width:14px;height:14px; }
146 .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; }
147 .sup-att-hint { font-size:11px;color:var(--col-text-3); }
148 .sup-att-form.att-drag { outline:2px dashed var(--col-accent);outline-offset:4px;border-radius:8px; }
149 .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; }
150 .sup-att-count.is-working { background:rgba(245,158,11,0.18);border:1px solid rgba(245,158,11,0.45);color:#fde68a; }
151 .sup-att-count.is-ready { background:rgba(16,185,129,0.18);border:1px solid rgba(16,185,129,0.55);color:#6ee7b7; }
152 .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; }
153 .sup-att-count.is-ready::before { content:'\2713';font-weight:800;color:#10b981; }
154 @keyframes sup-att-blink { 0%,100% { opacity:1; } 50% { opacity:0.35; } }
155 .sup-msg-atts { display:flex;flex-wrap:wrap;gap:6px;margin-top:8px; }
156 .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; }
157 .sup-msg-att:hover { border-color:var(--col-accent);transform:translateY(-1px); }
158 .sup-msg-att img { display:block;max-width:240px;max-height:180px;width:auto;height:auto; }
159</style>
160<div class="ai-tabs">
161 <a href="/admin_messages.cgi" class="ai-tab[if:$tab_inbox] active[/if]">
162 <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>
163 Inbox
164 </a>
165 <a href="/admin_messages.cgi?tab=reports" class="ai-tab[if:$tab_reports] active[/if]">
166 <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>
167 Reports
168 </a>
169</div>
170
171[if:$tab_reports]
172<!-- ===== SUPPORT REPORTS ===== -->
173<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:18px">
174 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
175 <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>
176 <div style="font-family:var(--font-display,sans-serif);font-size:28px;color:var(--col-text);line-height:1">$rep_total</div>
177 </div>
178 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
179 <div style="font-size:11px;font-weight:700;color:#f87171;letter-spacing:1.2px;text-transform:uppercase;margin-bottom:6px">Needs reply</div>
180 <div style="font-family:var(--font-display,sans-serif);font-size:28px;color:#f87171;line-height:1">$rep_open</div>
181 </div>
182 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
183 <div style="font-size:11px;font-weight:700;color:#fbbf24;letter-spacing:1.2px;text-transform:uppercase;margin-bottom:6px">Waiting on buyer</div>
184 <div style="font-family:var(--font-display,sans-serif);font-size:28px;color:#fbbf24;line-height:1">$rep_waiting</div>
185 </div>
186 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
187 <div style="font-size:11px;font-weight:700;color:#4ade80;letter-spacing:1.2px;text-transform:uppercase;margin-bottom:6px">Resolved (7d)</div>
188 <div style="font-family:var(--font-display,sans-serif);font-size:28px;color:#4ade80;line-height:1">$rep_resolved_7d</div>
189 <div style="font-size:11px;color:var(--col-text-3);margin-top:6px">$rep_resolved_30d in last 30d</div>
190 </div>
191 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
192 <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>
193 <div style="font-family:var(--font-display,sans-serif);font-size:28px;color:var(--col-text);line-height:1">$rep_new_7d</div>
194 <div style="font-size:11px;color:var(--col-text-3);margin-top:6px">$rep_new_30d in last 30d</div>
195 </div>
196 <div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:12px;padding:16px 18px">
197 <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>
198 [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]
199 [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]
200 [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]
201 <div style="font-size:11px;color:var(--col-text-3);margin-top:6px">From open to first admin reply (30d avg)</div>
202 </div>
203</div>
204<!-- Area charts: daily new tickets + daily resolved over 30 days.
205 Hover any day for a tooltip; click a day filters the inbox to
206 threads opened on that date. -->
207[if:$has_charts]
208<style>
209 .ac-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(420px,1fr)); gap:14px; margin-bottom:18px; }
210 .ac-card { position:relative; background:var(--col-surface-1); border:1px solid var(--col-border); border-radius:14px; padding:18px 20px; }
211 .ac-head { display:flex; align-items:baseline; justify-content:space-between; margin-bottom:10px; }
212 .ac-title { font-size:12px; letter-spacing:1.5px; text-transform:uppercase; font-weight:700; color:var(--col-text-3); }
213 .ac-sub { font-size:11px; color:var(--col-text-3); }
214 .ac-hot { cursor:pointer; }
215 .ac-hot:hover ~ .ac-guide { display:none; }
216 .ac-tip {
217 position:absolute; pointer-events:none; opacity:0; transition:opacity 0.1s;
218 background:rgba(8,12,26,0.96); border:1px solid var(--col-border);
219 color:#fff; font-size:12px; padding:7px 11px; border-radius:7px;
220 white-space:nowrap; box-shadow:0 6px 18px rgba(0,0,0,0.45);
221 transform:translate(-50%, -100%); margin-top:-8px;
222 z-index:50;
223 }
224 .ac-tip strong { color:var(--col-accent-bright); }
225</style>
226<div class="ac-grid">
227 <div class="ac-card" data-chart-id="new">
228 <div class="ac-head">
229 <span class="ac-title">New tickets &middot; last 30 days</span>
230 <span class="ac-sub" style="color:#93c5fd">$rep_new_30d total</span>
231 </div>
232 $chart_new_html
233 <div class="ac-tip"></div>
234 </div>
235 <div class="ac-card" data-chart-id="res">
236 <div class="ac-head">
237 <span class="ac-title">Resolved &middot; last 30 days</span>
238 <span class="ac-sub" style="color:#6ee7b7">$rep_resolved_30d total</span>
239 </div>
240 $chart_resolved_html
241 <div class="ac-tip"></div>
242 </div>
243</div>
244<script>
245(function () {
246 document.querySelectorAll('.ac-card').forEach(function (card) {
247 var svg = card.querySelector('.ac-svg');
248 var tip = card.querySelector('.ac-tip');
249 var guide= card.querySelector('.ac-guide');
250 var dot = card.querySelector('.ac-dot');
251 if (!svg || !tip) return;
252 var hots = card.querySelectorAll('.ac-hot');
253 var vb = svg.viewBox.baseVal;
254 function showAt(hot) {
255 var x = parseFloat(hot.getAttribute('data-x'));
256 var y = parseFloat(hot.getAttribute('data-y'));
257 var lbl = hot.getAttribute('data-label');
258 var val = hot.getAttribute('data-value');
259 // Move guide line + dot inside SVG coords.
260 if (guide) {
261 guide.setAttribute('x1', x); guide.setAttribute('x2', x);
262 guide.setAttribute('opacity', 0.55);
263 }
264 if (dot) {
265 dot.setAttribute('cx', x); dot.setAttribute('cy', y);
266 dot.setAttribute('opacity', 1);
267 }
268 // Convert SVG x to DOM x for tooltip placement.
269 var rect = svg.getBoundingClientRect();
270 var domX = (x / vb.width) * rect.width;
271 var domY = (y / vb.height) * rect.height;
272 tip.style.left = domX + 'px';
273 tip.style.top = domY + 'px';
274 tip.innerHTML = '<strong>' + val + '</strong> on ' + lbl;
275 tip.style.opacity = '1';
276 }
277 function hide() {
278 tip.style.opacity = '0';
279 if (guide) guide.setAttribute('opacity', 0);
280 if (dot) dot.setAttribute('opacity', 0);
281 }
282 hots.forEach(function (hot) {
283 hot.addEventListener('mouseenter', function () { showAt(hot); });
284 hot.addEventListener('mouseleave', hide);
285 hot.addEventListener('click', function () {
286 var iso = hot.getAttribute('data-iso');
287 if (iso) location.href = '/admin_messages.cgi?day=' + encodeURIComponent(iso);
288 });
289 });
290 });
291})();
292</script>
293[/if]
294
295<div style="background:var(--col-surface-1);border:1px solid var(--col-border);border-radius:14px;padding:20px">
296 <p style="margin:0;color:var(--col-text-2);font-size:13.5px;line-height:1.7">
297 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
298 <a href="/admin_messages.cgi" style="color:var(--col-accent-bright)">Inbox</a> to read and reply to threads.
299 </p>
300</div>
301[/if]
302
303[if:$tab_inbox]
304<div class="ai-page">
305
306 <!-- LEFT: thread list -->
307 <div class="ai-pane ai-left">
308 <div class="ai-list-head">
309 <div class="ai-list-sub">All threads</div>
310 <h2 class="ai-list-title">$thread_count showing</h2>
311 <div class="ai-filters">
312 <a class="ai-filter[if:!$filter] is-active[/if]" href="/admin_messages.cgi">All</a>
313 <a class="ai-filter" href="/admin_messages.cgi?status=waiting_admin">Needs reply</a>
314 <a class="ai-filter" href="/admin_messages.cgi?status=waiting_customer">Waiting on buyer</a>
315 <a class="ai-filter" href="/admin_messages.cgi?status=resolved">Resolved</a>
316 <a class="ai-filter" href="/admin_messages.cgi?status=closed">Closed</a>
317 </div>
318 </div>
319 <div class="ai-list">
320 [if:!$has_threads]
321 <div class="ai-list-empty">No threads in this view.</div>
322 [/if]
323 [if:$has_threads]
324 [loop:@threads]
325 <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">
326 <span class="ai-thread-cust">$loop1.customer_name</span>
327 <span class="ai-thread-subj">$loop1.subject</span>
328 <span class="ai-pill $loop1.status_class">$loop1.status_label</span>
329 [if:$loop1.has_unread]<span class="ai-unread">$loop1.unread</span>[/if]
330 <span class="ai-thread-time"><span class="ts" data-ts="$loop1.last_at_epoch" data-fmt="relative">$loop1.last_at</span></span>
331 </a>
332 [/loop]
333 [/if]
334 </div>
335 </div>
336
337 <!-- RIGHT: conversation -->
338 <div class="ai-pane ai-right">
339 [if:!$has_active]
340 <div class="ai-empty">
341 <div style="font-size:48px;opacity:0.5">&#9993;</div>
342 <h2>Pick a thread</h2>
343 <p>Select a customer thread on the left to see their messages and reply.</p>
344 </div>
345 [/if]
346
347 [if:$has_active]
348 <div class="ai-conv">
349 <div class="ai-conv-head">
350 <div style="min-width:0;flex:1">
351 <div class="ai-conv-title">$active_subject</div>
352 <div class="ai-conv-cust">From <strong style="color:var(--col-text-2)">$active_cust_name</strong> &middot; $active_cust_email</div>
353 </div>
354 <div class="ai-conv-actions">
355 <form method="post" action="/admin_messages.cgi" style="display:inline;margin:0">
356 <input type="hidden" name="action" value="set_status">
357 <input type="hidden" name="thread_id" value="$active_id">
358 <input type="hidden" name="status" value="resolved">
359 <button type="submit" class="ai-btn is-success">&#10003; Mark resolved</button>
360 </form>
361 <form method="post" action="/admin_messages.cgi" style="display:inline;margin:0">
362 <input type="hidden" name="action" value="set_status">
363 <input type="hidden" name="thread_id" value="$active_id">
364 <input type="hidden" name="status" value="closed">
365 <button type="submit" class="ai-btn">Close</button>
366 </form>
367 </div>
368 </div>
369
370 <div class="ai-stream" id="ai-stream" data-thread-id="$active_id" data-last-id="$last_message_id" data-side="admin">
371 [loop:@messages]
372 <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">
373 [if:!$loop1.is_system]<div class="ai-avatar">$loop1.sender_initial</div>[/if]
374 [if:$loop1.is_system]<div class="ai-avatar">&#9432;</div>[/if]
375 <div>
376 <div class="ai-bubble">$loop1.body_html</div>
377 [if:$loop1.has_attach]
378 <div class="sup-msg-atts">
379 [loop:@attachments]
380 <a href="$loop2.url" target="_blank" rel="noopener" class="sup-msg-att" title="$loop2.name"><img src="$loop2.url" alt=""></a>
381 [/loop]
382 </div>
383 [/if]
384 <div class="ai-meta-line">$loop1.sender_name &middot; <span class="ts" data-ts="$loop1.ts_epoch" data-fmt="relative">$loop1.ts</span></div>
385 </div>
386 </div>
387 [/loop]
388 </div>
389
390 <form class="ai-compose sup-att-form" id="ai-compose" data-thread-id="$active_id">
391 <textarea name="body" id="ai-body" placeholder="Write a reply..." required></textarea>
392 <div class="sup-att-area" data-att-area>
393 <div class="sup-att-strip" data-att-strip></div>
394 <div class="sup-att-controls">
395 <label class="sup-att-pick" data-att-pick>
396 <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>
397 <span>Attach image</span>
398 <input type="file" data-att-input accept="image/jpeg,image/png,image/gif,image/webp,image/bmp,.jpg,.jpeg,.png,.gif,.webp,.bmp" multiple>
399 </label>
400 <span class="sup-att-hint">Drag &amp; drop or paste also works</span>
401 </div>
402 </div>
403 <div class="ai-compose-row">
404 <span class="ai-compose-hint">Enter to send &middot; Shift+Enter for a new line</span>
405 <button type="submit" class="ai-btn is-primary">Send</button>
406 </div>
407 </form>
408 </div>
409 [/if]
410 </div>
411</div>
412
413
414<script>
415(function(){
416 var stream = document.getElementById('ai-stream');
417 if (!stream) return;
418 var threadId = stream.getAttribute('data-thread-id');
419 var lastId = parseInt(stream.getAttribute('data-last-id') || '0', 10);
420 stream.scrollTop = stream.scrollHeight;
421
422 var compose = document.getElementById('ai-compose');
423 var body = document.getElementById('ai-body');
424 function sendMessage() {
425 var text = body.value.trim();
426 if (!text) return;
427 // Capture FormData FIRST -- FormData() skips disabled fields,
428 // so disabling the textarea before this would silently drop the
429 // body and the server returns "empty message".
430 var data = new FormData(compose);
431 data.append('thread_id', threadId);
432 data.append('async', '1');
433 body.disabled = true;
434 var strip = compose.querySelector('[data-att-strip]');
435 var attUrls = [];
436 if (strip) {
437 strip.querySelectorAll('.sup-att-thumb img').forEach(function(im){ attUrls.push(im.src); });
438 }
439 var sentText = text;
440 fetch('/support_send.cgi', { method:'POST', body:data })
441 .then(function(r){ return r.json(); })
442 .then(function(j){
443 body.disabled = false;
444 if (!j || !j.ok) { appToast({ message: 'Send failed: ' + (j && j.error ? j.error : 'unknown'), type: 'error' }); return; }
445 body.value = '';
446 if (strip) strip.innerHTML = '';
447 body.focus();
448 // Optimistic render so the admin sees their own reply land
449 // immediately even if support_poll lags.
450 if (j.message_id) {
451 lastId = Math.max(lastId, parseInt(j.message_id, 10) || 0);
452 var attHtml = '';
453 if (attUrls.length) {
454 attHtml = '<div class="sup-msg-atts">' + attUrls.map(function(u){
455 return '<a href="' + u + '" target="_blank" rel="noopener" class="sup-msg-att"><img src="' + u + '" alt=""></a>';
456 }).join('') + '</div>';
457 }
458 var safe = sentText.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
459 appendMessage({
460 id: j.message_id, sender_role: 'admin', sender_name: 'You',
461 body_html: safe + attHtml, ts: 'just now'
462 });
463 }
464 pollNow();
465 })
466 .catch(function(err){ body.disabled = false; appToast({ message: 'Send failed: ' + err.message, type: 'error' }); });
467 }
468 if (compose) {
469 compose.addEventListener('submit', function(e){ e.preventDefault(); sendMessage(); });
470 body.addEventListener('keydown', function(e){
471 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
472 });
473 }
474
475 function appendMessage(m) {
476 var wrap = document.createElement('div');
477 var roleClass = m.sender_role === 'customer' ? 'is-customer'
478 : m.sender_role === 'admin' ? 'is-admin'
479 : 'is-system';
480 wrap.className = 'ai-msg ' + roleClass;
481 wrap.setAttribute('data-msg-id', m.id);
482 var initial = (m.sender_name || '?').charAt(0).toUpperCase();
483 var avatar = '<div class="ai-avatar">' + (m.sender_role === 'system' ? '&#9432;' : initial) + '</div>';
484 wrap.innerHTML = avatar +
485 '<div>' +
486 ' <div class="ai-bubble">' + m.body_html + '</div>' +
487 ' <div class="ai-meta-line">' + (m.sender_name || '') + ' &middot; just now</div>' +
488 '</div>';
489 stream.appendChild(wrap);
490 stream.scrollTop = stream.scrollHeight;
491 }
492 function pollNow() {
493 fetch('/support_poll.cgi?thread_id=' + encodeURIComponent(threadId) + '&since=' + lastId, { cache:'no-store' })
494 .then(function(r){ return r.json(); })
495 .then(function(j){
496 if (!j || !j.ok) return;
497 (j.messages || []).forEach(function(m){
498 if (m.id > lastId) { lastId = m.id; appendMessage(m); }
499 });
500 })
501 .catch(function(){});
502 }
503 // Async-only: poll every 12s for new customer replies.
504 setInterval(pollNow, 12000);
505})();
506
507// ===== Attachment widget (shared customer-side identical implementation)
508(function(){
509 function setupAttachments(form) {
510 var area = form.querySelector('[data-att-area]');
511 if (!area) return;
512 var input = area.querySelector('[data-att-input]');
513 var pick = area.querySelector('[data-att-pick]');
514 var strip = area.querySelector('[data-att-strip]');
515 var ta = form.querySelector('textarea');
516
517 var counter = document.createElement('span');
518 counter.className = 'sup-att-count';
519 counter.style.display = 'none';
520 if (pick && pick.parentNode) pick.parentNode.insertBefore(counter, pick.nextSibling);
521 function updateCounter() {
522 var n = strip.querySelectorAll('.sup-att-thumb').length;
523 var ok = strip.querySelectorAll('input[name="attach_id"]').length;
524 var pending = n - ok;
525 if (n === 0) { counter.style.display = 'none'; return; }
526 counter.style.display = 'inline-flex';
527 if (pending > 0) {
528 counter.textContent = n + ' selected (' + ok + ' ready, ' + pending + ' uploading...)';
529 counter.className = 'sup-att-count is-working';
530 } else {
531 counter.textContent = n + (n === 1 ? ' image attached' : ' images attached');
532 counter.className = 'sup-att-count is-ready';
533 }
534 }
535
536 function makeThumb(src, id, pending, name) {
537 var t = document.createElement('div');
538 t.className = 'sup-att-thumb' + (pending ? ' is-pending' : '');
539 if (name) t.title = name;
540 var img = document.createElement('img'); img.src = src; t.appendChild(img);
541 if (!pending && id) {
542 var hidden = document.createElement('input');
543 hidden.type = 'hidden'; hidden.name = 'attach_id'; hidden.value = id;
544 t.appendChild(hidden);
545 var rm = document.createElement('button');
546 rm.type = 'button'; rm.className = 'sup-att-rm';
547 rm.innerHTML = '&times;'; rm.setAttribute('aria-label', 'Remove attachment');
548 rm.addEventListener('click', function(){ t.remove(); updateCounter(); });
549 t.appendChild(rm);
550 }
551 return t;
552 }
553 function upload(file) {
554 if (!file) return;
555 var t = (file.type || '').toLowerCase();
556 var nm = (file.name || '').toLowerCase();
557 var okType = t.indexOf('image/') === 0;
558 var okExt = false;
559 var exts = ['.jpg','.jpeg','.png','.gif','.webp','.bmp'];
560 for (var ei = 0; ei < exts.length; ei++) {
561 if (nm.length >= exts[ei].length && nm.lastIndexOf(exts[ei]) === nm.length - exts[ei].length) { okExt = true; break; }
562 }
563 if (!okType && !okExt) {
564 appToast({ message: file.name + ': not an image -- only JPG / PNG / GIF / WebP / BMP are accepted.', type: 'warning' });
565 return;
566 }
567 var placeholder = makeThumb(URL.createObjectURL(file), null, true, file.name);
568 strip.appendChild(placeholder);
569 updateCounter();
570 var fd = new FormData();
571 fd.append('image', file);
572 fetch('/support_upload.cgi', { method:'POST', body:fd, credentials:'same-origin' })
573 .then(function(r){ return r.json(); })
574 .then(function(j){
575 if (j && j.success) {
576 placeholder.replaceWith(makeThumb(j.url, j.id, false, j.filename));
577 } else {
578 placeholder.classList.add('failed'); placeholder.classList.remove('is-pending');
579 placeholder.title = (j && j.error) ? j.error : 'upload failed';
580 }
581 updateCounter();
582 })
583 .catch(function(){
584 placeholder.classList.add('failed'); placeholder.classList.remove('is-pending');
585 updateCounter();
586 });
587 }
588 // Native <label> wrapping opens the picker -- no JS .click().
589 if (input) {
590 input.addEventListener('change', function(){
591 Array.prototype.slice.call(input.files).forEach(upload);
592 input.value = '';
593 });
594 }
595 ['dragenter','dragover'].forEach(function(ev){
596 form.addEventListener(ev, function(e){
597 if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types, 'Files') !== -1) {
598 e.preventDefault(); form.classList.add('att-drag');
599 }
600 });
601 });
602 ['dragleave','dragend','drop'].forEach(function(ev){
603 form.addEventListener(ev, function(){ form.classList.remove('att-drag'); });
604 });
605 form.addEventListener('drop', function(e){
606 if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
607 e.preventDefault();
608 Array.prototype.slice.call(e.dataTransfer.files).forEach(upload);
609 });
610 if (ta) {
611 ta.addEventListener('paste', function(e){
612 var items = e.clipboardData && e.clipboardData.items;
613 if (!items) return;
614 for (var i = 0; i < items.length; i++) {
615 if (items[i].kind === 'file') {
616 var f = items[i].getAsFile();
617 if (f) upload(f);
618 }
619 }
620 });
621 }
622 }
623 document.querySelectorAll('form.sup-att-form').forEach(setupAttachments);
624
625 // Same global drag-default killer as the customer side, so files
626 // dropped anywhere on /admin_messages.cgi don't navigate.
627 window.addEventListener('dragover', function(e){
628 if (e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types, 'Files') !== -1) {
629 e.preventDefault();
630 }
631 });
632 window.addEventListener('drop', function(e){
633 if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
634 var inForm = e.target && e.target.closest && e.target.closest('form.sup-att-form');
635 if (inForm) return;
636 e.preventDefault();
637 var firstForm = document.querySelector('form.sup-att-form');
638 if (!firstForm) return;
639 var area = firstForm.querySelector('[data-att-area]');
640 if (!area || area.offsetParent === null) return;
641 var dropEvt = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: e.dataTransfer });
642 firstForm.dispatchEvent(dropEvt);
643 });
644})();
645</script>
646[/if]
647<!-- Non-conditional trailing content. MODS::Template returns the
648 value of the last evaluated expression; a template that ends on
649 an [if] block (false branch) returns undef and the whole body
650 renders empty. This div forces a final non-conditional .= so the
651 accumulated $print_contents is what gets returned. -->
652<div class="adm-foot-spacer" data-end="admin-messages">&nbsp;</div>