Diff -- /var/www/vhosts/3dshawn.com/admin.3dshawn.com/support.cgi

O Operator
Diff

/var/www/vhosts/3dshawn.com/admin.3dshawn.com/support.cgi

added on local at 2026-07-11 08:07:44

Added
+440
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to cdbe845dc26e
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# Meta-Admin -- /support.cgi
4#
5# Cross-site customer support inbox. Reads real support_threads +
6# support_messages across the 6 unified sister sites (WebSTLs, AffSoft,
7# ShopCart, RePricer, ABForge, ContactForge). Replying POSTs to that
8# site's /support_meta_send.cgi with an HMAC-signed body -- the site's
9# own Support.pm handles the DB write, so unread flags, status flips
10# and last_message_at bookkeeping all flow through the same code path
11# as a native (per-site) admin reply.
12#
13# Modes (query params):
14# /support.cgi -> thread list (default view)
15# /support.cgi?thread=SLUG:ID -> single-thread detail + reply form
16# POST /support.cgi with `thread` + `body` -> send reply, redirect back
17#======================================================================
18use strict;
19use warnings;
20use lib '/var/www/vhosts/3dshawn.com/admin.3dshawn.com';
21use CGI ();
22use MODS::Login;
23use MODS::MetaAdmin::Config;
24use MODS::MetaAdmin::Wrapper;
25use MODS::MetaAdmin::SupportInbox;
26use MODS::MetaAdmin::CannedReplies;
27use JSON::PP ();
28
29my $auth = MODS::Login->new;
30my $u = $auth->login_verify;
31unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
32
33my $cgi = CGI->new;
34my $method = $ENV{REQUEST_METHOD} || 'GET';
35my $inbox = MODS::MetaAdmin::SupportInbox->new;
36
37sub _esc { my $s=shift; $s//=''; $s=~s/&/&amp;/g; $s=~s/</&lt;/g; $s=~s/>/&gt;/g; $s=~s/"/&quot;/g; return $s; }
38
39# Format a MySQL DATETIME "YYYY-MM-DD HH:MM:SS" as a relative age.
40sub _rel_age {
41 my $ts = shift;
42 return '-' unless $ts && $ts =~ /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})$/;
43 require Time::Local;
44 my $epoch = eval { Time::Local::timelocal($6,$5,$4,$3,$2-1,$1-1900) } || 0;
45 return '-' unless $epoch;
46 my $delta = time - $epoch;
47 return 'just now' if $delta < 60;
48 return int($delta/60).' m ago' if $delta < 3600;
49 return int($delta/3600).' h ago' if $delta < 86400;
50 return int($delta/86400).' d ago';
51}
52
53#---------------------------------------------------------------------
54# POST reply
55#---------------------------------------------------------------------
56if ($method eq 'POST') {
57 my $thread = $cgi->param('thread') // '';
58 my $body = $cgi->param('body') // '';
59 $body =~ s/\r\n/\n/g;
60 $body =~ s/\s+$//;
61
62 my $err = '';
63 my ($slug, $tid);
64 if ($thread =~ /^([a-z0-9_-]+):(\d+)$/i) {
65 ($slug, $tid) = (lc($1), $2 + 0);
66 } else {
67 $err = 'bad_thread_param';
68 }
69 unless ($err) {
70 unless (length $body) { $err = 'empty_body'; }
71 }
72 unless ($err) {
73 my ($ok, $ret) = $inbox->post_reply($slug, $tid, $body);
74 $err = $ret unless $ok;
75 }
76
77 my $back = '/support.cgi?thread=' . ($slug // '') . ':' . ($tid // '');
78 $back .= '&err=' . _esc($err) if $err;
79 $back .= '&sent=1' unless $err;
80 print "Status: 302 Found\nLocation: $back\n\n";
81 exit;
82}
83
84#---------------------------------------------------------------------
85# Detail mode
86#---------------------------------------------------------------------
87my $thread_param = $cgi->param('thread') || '';
88if ($thread_param =~ /^([a-z0-9_-]+):(\d+)$/i) {
89 my ($slug, $tid) = (lc($1), $2 + 0);
90 my $res = $inbox->get_thread($slug, $tid);
91
92 my $body_html = '';
93 unless ($res) {
94 $body_html .= q~
95 <div class="page-head"><div class="left">
96 <a href="/support.cgi" class="page-eyebrow" style="text-decoration:none"><span class="dot"></span> &larr; Back to inbox</a>
97 <h1 class="page-title">Thread not found</h1>
98 <p class="page-subtitle">That thread ID isn't valid for the selected site, or the site DB is unreachable.</p>
99 </div></div>
100 ~;
101 } else {
102 my $t = $res->{thread};
103 my $msgs = $res->{messages};
104 my $col = _esc($t->{brand_color});
105 my $site = _esc($t->{display_name});
106 my $subj = _esc($t->{subject} || '(no subject)');
107 my $cust = _esc($t->{customer_name} || $t->{customer_email} || '(unknown customer)');
108 my $cust_e= _esc($t->{customer_email}|| '');
109 my $stat = _esc($t->{status});
110 my $pri = _esc($t->{priority});
111 my $created_raw = _esc($t->{created_at} || '');
112 my $created_ep = int($t->{created_epoch} || 0);
113 my $created = qq~<span class="ts" data-ts="$created_ep" data-fmt="datetime">$created_raw</span>~;
114 my $back = "/support.cgi?site=" . _esc($slug);
115
116 my $sent = $cgi->param('sent') ? 1 : 0;
117 my $err_p = _esc($cgi->param('err') || '');
118
119 $body_html .= qq~
120 <div class="page-head"><div class="left">
121 <a href="/support.cgi" class="page-eyebrow" style="text-decoration:none"><span class="dot" style="background:$col"></span> &larr; Back to inbox</a>
122 <h1 class="page-title">$subj</h1>
123 <p class="page-subtitle"><span style="color:$col;font-weight:600">$site</span> &middot; from <strong>$cust</strong>~;
124 $body_html .= " &lt;$cust_e&gt;" if $cust_e;
125 $body_html .= qq~ &middot; opened $created &middot; status <code>$stat</code> &middot; priority <code>$pri</code></p>
126 </div></div>
127 ~;
128
129 if ($sent) {
130 $body_html .= q~<div class="banner banner-ok" style="margin:12px 0;padding:10px 14px;border-radius:10px;background:rgba(34,197,94,.10);border:1px solid rgba(34,197,94,.35);color:#a7f3d0;font-size:13px">Reply sent. The customer's unread flag has been bumped on that site.</div>~;
131 }
132 if ($err_p) {
133 $body_html .= qq~<div class="banner banner-err" style="margin:12px 0;padding:10px 14px;border-radius:10px;background:rgba(239,68,68,.10);border:1px solid rgba(239,68,68,.35);color:#fca5a5;font-size:13px">Reply failed: <code>$err_p</code></div>~;
134 }
135
136 $body_html .= q~
137 <style>
138 .msg-list { display:flex; flex-direction:column; gap:14px; }
139 .msg-row { display:flex; gap:14px; align-items:flex-start; }
140 .msg-avatar { flex:0 0 auto; width:36px; height:36px; border-radius:50%;
141 display:flex; align-items:center; justify-content:center; font-weight:700;
142 font-size:12px; letter-spacing:.5px; color:#fff; user-select:none;
143 box-shadow:0 0 12px rgba(0,0,0,.2); }
144 .msg-body { flex:1 1 auto; min-width:0; background:var(--col-surface);
145 border:1px solid var(--col-border); border-radius:12px;
146 padding:14px 16px; }
147 .msg-head { display:flex; align-items:center; gap:10px; margin-bottom:8px; font-size:12px; }
148 .msg-sender { font-weight:700; color:var(--col-text); }
149 .msg-role { font-size:10.5px; letter-spacing:.6px; text-transform:uppercase;
150 padding:2px 8px; border-radius:999px; font-weight:700; }
151 .msg-role-admin { background:rgba(181,48,72,.15); color:#e5a1ac; border:1px solid rgba(181,48,72,.35); }
152 .msg-role-customer { background:rgba(6,182,212,.12); color:#7dd3fc; border:1px solid rgba(6,182,212,.35); }
153 .msg-role-system { background:rgba(255,255,255,.05); color:#a8a29e; border:1px solid rgba(255,255,255,.10); }
154 .msg-time { color:var(--col-text-dim); font-size:11px; margin-left:auto; }
155 .msg-txt { color:var(--col-text-2); font-size:13px; line-height:1.6; white-space:pre-wrap; word-wrap:break-word; }
156 .reply-form { margin-top:24px; }
157 .reply-form textarea { width:100%; box-sizing:border-box; background:var(--col-surface);
158 color:var(--col-text); border:1px solid var(--col-border);
159 border-radius:12px; padding:14px 16px; font-family:inherit; font-size:13px;
160 line-height:1.6; resize:vertical; min-height:160px; outline:none;
161 transition:border-color .15s ease; }
162 .reply-form textarea:focus { border-color:var(--col-brand, var(--col-primary, #b53048)); }
163 .reply-form .row { display:flex; align-items:center; gap:12px; margin-top:12px; }
164 .reply-form button { background:var(--col-brand, var(--col-primary, #b53048)); color:#fff;
165 border:none; padding:9px 18px; border-radius:999px; font-weight:600;
166 font-size:13px; cursor:pointer; transition:transform .1s ease, box-shadow .1s ease; }
167 .reply-form button:hover { box-shadow:0 4px 14px rgba(181,48,72,.35); transform:translateY(-1px); }
168 .reply-form .hint { color:var(--col-text-dim); font-size:11.5px; }
169 </style>
170 ~;
171
172 my $msg_list = '<div class="msg-list">';
173 foreach my $m (@$msgs) {
174 my $role = $m->{sender_role} || 'system';
175 my $init = uc(substr(($role eq 'admin') ? 'AD' : ($role eq 'customer') ? 'CU' : 'SY', 0, 2));
176 my $bg = ($role eq 'admin') ? $t->{brand_color}
177 : ($role eq 'customer') ? '#0891b2' : '#4b5563';
178 my $sender = _esc($m->{sender_name} || $m->{sender_email} || ($role eq 'admin' ? 'Portfolio admin' : ucfirst($role)));
179 my $when = _esc($m->{created_at} || '');
180 my $age = _rel_age($m->{created_at});
181 my $ep = int($m->{created_epoch} || 0);
182 my $mbody = _esc($m->{body});
183 $msg_list .= qq~
184 <div class="msg-row">
185 <div class="msg-avatar" style="background:$bg;box-shadow:0 0 12px $bg">$init</div>
186 <div class="msg-body">
187 <div class="msg-head">
188 <span class="msg-sender">$sender</span>
189 <span class="msg-role msg-role-$role">$role</span>
190 <span class="msg-time"><span class="ts" data-ts="$ep" data-fmt="relative">$age</span> &middot; <span class="ts" data-ts="$ep" data-fmt="datetime">$when</span></span>
191 </div>
192 <div class="msg-txt">$mbody</div>
193 </div>
194 </div>~;
195 }
196 $msg_list .= '</div>';
197
198 my $thread_key = _esc($slug) . ':' . ($tid + 0);
199
200 # Canned reply library — server-side token expansion so the dropdown
201 # can drop pre-expanded text straight into the textarea (no
202 # round-trip needed).
203 my $canned = MODS::MetaAdmin::CannedReplies->new;
204 my $tpl_rows = $canned->list(active_only => 1);
205 my $ctx = $canned->thread_context($t);
206 my %tpl_expanded;
207 my $tpl_dropdown_opts = '<option value="">-- pick a canned reply --</option>';
208 foreach my $tpl (@$tpl_rows) {
209 $tpl_expanded{$tpl->{id} + 0} = $canned->expand($tpl->{body}, $ctx);
210 my $n = _esc($tpl->{name});
211 my $i = _esc($tpl->{id});
212 $tpl_dropdown_opts .= qq~<option value="$i">$n</option>~;
213 }
214 my $tpl_json = JSON::PP::encode_json(\%tpl_expanded);
215 my $tpl_count = scalar @$tpl_rows;
216 my $tpl_hint = $tpl_count
217 ? qq~$tpl_count canned reply@{[ $tpl_count == 1 ? '' : 's' ]} available &middot; <a href="/canned.cgi" style="color:var(--col-text-muted)">manage</a>~
218 : q~No canned replies yet &mdash; <a href="/canned.cgi?edit=NEW" style="color:var(--col-text-muted)">create one</a>~;
219
220 $body_html .= qq~
221 <div class="module glow-magenta">
222 <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" 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></div><div class="module-title">Conversation</div><span class="module-subtitle">Most recent at the bottom. Your reply below is delivered through the site's own send API so the customer sees it exactly the same way.</span></div></div>
223 <div class="module-body">
224 $msg_list
225
226 <form method="post" action="/support.cgi" class="reply-form">
227 <input type="hidden" name="thread" value="$thread_key">
228 <label style="display:block;margin-bottom:8px;font-size:11.5px;letter-spacing:1.4px;text-transform:uppercase;color:var(--col-text-dim);font-weight:700">Reply as portfolio admin</label>
229 <div class="canned-picker">
230 <select id="cannedPicker">$tpl_dropdown_opts</select>
231 <span class="canned-hint">$tpl_hint</span>
232 </div>
233 <textarea name="body" id="replyBody" placeholder="Type your reply, or pick a canned reply above..." required></textarea>
234 <div class="row">
235 <span class="hint">Delivered via <code>/support_meta_send.cgi</code> on $site. Customer's unread flag will be bumped.</span>
236 <button type="submit" style="margin-left:auto">Send reply</button>
237 </div>
238 </form>
239 </div>
240 </div>
241 <style>
242 .canned-picker { display:flex; align-items:center; gap:12px; margin-bottom:10px; }
243 .canned-picker select { background:var(--col-surface); color:var(--col-text); border:1px solid var(--col-border); border-radius:8px; padding:6px 10px; font-family:inherit; font-size:12.5px; min-width:280px; cursor:pointer; }
244 .canned-picker select:focus { outline:none; border-color:var(--col-brand, var(--col-primary, #b53048)); }
245 .canned-hint { color:var(--col-text-dim); font-size:11.5px; }
246 .canned-hint a { color:inherit; text-decoration:underline; }
247 </style>
248 <script>
249 (function(){
250 var expanded = $tpl_json;
251 var sel = document.getElementById('cannedPicker');
252 var ta = document.getElementById('replyBody');
253 if (!sel || !ta) return;
254 sel.addEventListener('change', function(){
255 var id = sel.value;
256 if (!id) return;
257 var txt = expanded[id];
258 if (typeof txt !== 'string') return;
259 // Insert (or replace if textarea empty). If user has typed
260 // something, append with a blank line separator.
261 if (ta.value.trim() === '') { ta.value = txt; }
262 else { ta.value = ta.value.replace(/\\s+\$/, '') + '\\n\\n' + txt; }
263 ta.focus();
264 // Reset the picker so the same template can be re-picked.
265 sel.value = '';
266 });
267 })();
268 </script>
269 ~;
270 }
271
272 MODS::MetaAdmin::Wrapper->new->render(
273 title => 'Support thread',
274 page_key => 'support',
275 body => $body_html,
276 userinfo => $u,
277 );
278 exit;
279}
280
281#---------------------------------------------------------------------
282# List mode -- default
283#---------------------------------------------------------------------
284my $q_status = $cgi->param('status') // '';
285my $q_site = $cgi->param('site') // '';
286my $q_days = $cgi->param('days');
287$q_days = 90 unless $q_days && $q_days =~ /^\d+$/;
288$q_days = 730 if $q_days > 730;
289
290my $threads = $inbox->list_threads(
291 status => $q_status,
292 site => $q_site,
293 days => $q_days,
294 limit => 100,
295);
296
297my $total = scalar @$threads;
298my $unresolved = grep { ($_->{status} || '') =~ /^(open|waiting_admin)$/ } @$threads;
299my $admin_unread_total = 0;
300$admin_unread_total += ($_->{admin_unread} || 0) for @$threads;
301
302my $body = '';
303$body .= q~
304<div class="page-head"><div class="left">
305 <span class="page-eyebrow"><span class="dot"></span> Inbox</span>
306 <h1 class="page-title">Support</h1>
307 <p class="page-subtitle">Every open customer-support thread across the portfolio, in one list. Click a thread to read the full conversation and reply -- your reply goes out through the site's own send pipeline so unread flags and status flips fire exactly the same way as if you'd replied from the site itself.</p>
308</div></div>
309~;
310
311# KPI row
312$body .= qq~
313<div class="dash-grid" style="grid-template-columns: repeat(3, 1fr)">
314 <div class="module glow-magenta">
315 <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" 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></div><div class="module-title">Threads</div><span class="module-subtitle">Total support conversations touched in the selected window.</span></div></div>
316 <div class="module-body kpi kpi-magenta"><div class="num">$total</div><div class="lbl">in the last $q_days days</div><div class="sub">across the portfolio</div></div>
317 </div>
318 <div class="module glow-lime">
319 <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></div><div class="module-title">Waiting on you</div><span class="module-subtitle">Threads whose last message came from the customer or that never got an admin reply.</span></div></div>
320 <div class="module-body kpi kpi-lime"><div class="num">$unresolved</div><div class="lbl">open + waiting_admin</div><div class="sub">worth answering</div></div>
321 </div>
322 <div class="module glow-cyan">
323 <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8a6 6 0 0 0-9.33-5"/><path d="M6 8a6 6 0 0 0 9.33 5"/><circle cx="12" cy="20" r="1"/></svg></div><div class="module-title">Unread on admin side</div><span class="module-subtitle">Sum of the <code>admin_unread</code> counter across every listed thread. Bumped by any customer message.</span></div></div>
324 <div class="module-body kpi kpi-cyan"><div class="num">$admin_unread_total</div><div class="lbl">unread messages</div><div class="sub">across all threads</div></div>
325 </div>
326</div>
327~;
328
329# Filter chips
330my %sel_status = (
331 '' => $q_status eq '' ? ' is-active' : '',
332 'unresolved' => $q_status eq 'unresolved' ? ' is-active' : '',
333 'waiting_admin' => $q_status eq 'waiting_admin' ? ' is-active' : '',
334 'waiting_customer' => $q_status eq 'waiting_customer' ? ' is-active' : '',
335 'closed' => $q_status eq 'closed' ? ' is-active' : '',
336);
337
338my $q_site_e = _esc($q_site);
339my $site_options = '<option value="">All sites</option>';
340foreach my $slug (@{ $inbox->supported_slugs }) {
341 my $sel = ($slug eq $q_site) ? ' selected' : '';
342 my $slug_e = _esc($slug);
343 $site_options .= qq~<option value="$slug_e"$sel>$slug_e</option>~;
344}
345
346$body .= qq~
347<form method="get" action="/support.cgi"
348 style="display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:8px 0 14px;padding:10px 14px;background:var(--col-surface);border:1px solid var(--col-border);border-radius:12px">
349 <span style="font-size:11.5px;letter-spacing:1px;text-transform:uppercase;color:var(--col-text-muted);font-weight:700">Filter</span>
350 <a class="tr-chip$sel_status{''}" href="?days=$q_days">All</a>
351 <a class="tr-chip$sel_status{'unresolved'}" href="?days=$q_days&status=unresolved">Unresolved</a>
352 <a class="tr-chip$sel_status{'waiting_admin'}" href="?days=$q_days&status=waiting_admin">Waiting on admin</a>
353 <a class="tr-chip$sel_status{'waiting_customer'}" href="?days=$q_days&status=waiting_customer">Waiting on customer</a>
354 <a class="tr-chip$sel_status{'closed'}" href="?days=$q_days&status=closed">Closed</a>
355 <span style="margin-left:16px;font-size:11.5px;letter-spacing:1px;text-transform:uppercase;color:var(--col-text-muted);font-weight:700">Site</span>
356 <select name="site" onchange="this.form.submit()" style="background:var(--col-surface);color:var(--col-text);border:1px solid var(--col-border);border-radius:8px;padding:4px 8px;font-size:12px;font-family:inherit">
357 $site_options
358 </select>
359 <input type="hidden" name="days" value="$q_days">
360 <input type="hidden" name="status" value="~ . _esc($q_status) . qq~">
361 <span style="margin-left:auto;font-size:11.5px;color:var(--col-text-muted)">Window: <strong style="color:var(--col-text)">Last $q_days days</strong></span>
362</form>
363<style>
364.tr-chip{display:inline-flex;align-items:center;padding:5px 11px;border-radius:999px;font-size:12px;font-weight:600;color:var(--col-text-muted);text-decoration:none;background:transparent;border:1px solid var(--col-border);cursor:pointer;transition:all .15s ease;font-family:inherit}
365.tr-chip:hover{color:var(--col-text);border-color:var(--col-brand, var(--col-primary, #b53048))}
366.tr-chip.is-active{background:var(--col-brand, var(--col-primary, #b53048));color:#fff;border-color:var(--col-brand, var(--col-primary, #b53048))}
367
368.thr-table { display:grid; row-gap:2px; font-size:13px; }
369.thr-row { display:grid; grid-template-columns: 40px 1.6fr 2.6fr 100px 90px; gap:14px; align-items:center;
370 padding:12px 14px; border-bottom:1px solid rgba(255,255,255,.03); cursor:pointer;
371 transition:background .12s ease; text-decoration:none; color:inherit; }
372.thr-row:hover { background:rgba(255,255,255,.03); }
373.thr-brand { width:26px; height:26px; border-radius:7px; display:flex; align-items:center; justify-content:center;
374 font-size:10.5px; font-weight:700; color:#fff; letter-spacing:.5px; }
375.thr-main { display:flex; flex-direction:column; gap:3px; min-width:0; }
376.thr-subj { font-weight:600; color:var(--col-text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
377.thr-meta { font-size:11px; color:var(--col-text-dim); }
378.thr-prev { color:var(--col-text-dim); font-size:12.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
379.thr-status { font-size:10.5px; letter-spacing:.6px; text-transform:uppercase; padding:3px 8px; border-radius:999px; font-weight:700; text-align:center; }
380.st-open { background:rgba(6,182,212,.12); color:#7dd3fc; border:1px solid rgba(6,182,212,.35); }
381.st-waiting_admin { background:rgba(239,68,68,.10); color:#fca5a5; border:1px solid rgba(239,68,68,.35); }
382.st-waiting_customer { background:rgba(180,150,50,.12);color:#facc15; border:1px solid rgba(180,150,50,.35); }
383.st-resolved,.st-closed { background:rgba(34,197,94,.10);color:#a7f3d0; border:1px solid rgba(34,197,94,.35); }
384.thr-when { text-align:right; font-size:11.5px; color:var(--col-text-dim); }
385.thr-unread { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px;
386 padding:0 6px; border-radius:999px; background:#b53048; color:#fff; font-size:10.5px;
387 font-weight:700; margin-left:8px; box-shadow:0 0 8px rgba(181,48,72,.5); }
388</style>
389~;
390
391my $inbox_html = '';
392if (!@$threads) {
393 $inbox_html = q~<div style="padding:40px 14px;text-align:center;color:var(--col-text-dim);font-size:13px">
394 No support threads in this window. Try a wider date range or clearing filters.
395 </div>~;
396} else {
397 my %letters = (webstls=>'WS',ptmatrix=>'PT',affsoft=>'AS',shopcart=>'SC',
398 repricer=>'RP',abforge=>'AB',contactforge=>'CF');
399 foreach my $t (@$threads) {
400 my $slug = $t->{slug};
401 my $tid = $t->{id} + 0;
402 my $col = _esc($t->{brand_color} || '#807aa8');
403 my $ltr = $letters{$slug} || uc(substr($t->{display_name}||'?',0,2));
404 my $subj = _esc($t->{subject} || '(no subject)');
405 my $site = _esc($t->{display_name} || $slug);
406 my $cust = _esc($t->{customer_name} || $t->{customer_email} || 'unknown');
407 my $prev = _esc($t->{preview} || '');
408 my $stat = _esc($t->{status} || 'open');
409 my $age = _rel_age($t->{last_message_at} || $t->{created_at});
410 my $when_ep = int($t->{last_message_epoch} || $t->{created_epoch} || 0);
411 my $unread = ($t->{admin_unread} || 0) + 0;
412 my $ur_html = $unread > 0 ? qq~<span class="thr-unread">$unread</span>~ : '';
413 my $href = "/support.cgi?thread=" . _esc($slug) . ":" . $tid;
414 $inbox_html .= qq~
415 <a class="thr-row" href="$href">
416 <div class="thr-brand" style="background:$col;box-shadow:0 0 10px $col">$ltr</div>
417 <div class="thr-main">
418 <div class="thr-subj">$subj$ur_html</div>
419 <div class="thr-meta">$site &middot; from $cust</div>
420 </div>
421 <div class="thr-prev">$prev</div>
422 <div class="thr-status st-$stat">$stat</div>
423 <div class="thr-when"><span class="ts" data-ts="$when_ep" data-fmt="relative">$age</span></div>
424 </a>~;
425 }
426}
427
428$body .= qq~
429<div class="module glow-magenta">
430 <div class="module-head"><div class="left"><div class="module-icon"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></div><div class="module-title">All threads</div><span class="module-subtitle">Newest activity first. Unread badge is the site's <code>admin_unread</code> counter -- resets on the site when a super-admin opens the thread there.</span></div></div>
431 <div class="module-body" style="padding:0"><div class="thr-table">$inbox_html</div></div>
432</div>
433~;
434
435MODS::MetaAdmin::Wrapper->new->render(
436 title => 'Support inbox',
437 page_key => 'support',
438 body => $body,
439 userinfo => $u,
440);