Diff -- /var/www/vhosts/3dshawn.com/admin.3dshawn.com/support.cgi
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
to cdbe845dc26e
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 | #====================================================================== | |
| 18 | use strict; | |
| 19 | use warnings; | |
| 20 | use lib '/var/www/vhosts/3dshawn.com/admin.3dshawn.com'; | |
| 21 | use CGI (); | |
| 22 | use MODS::Login; | |
| 23 | use MODS::MetaAdmin::Config; | |
| 24 | use MODS::MetaAdmin::Wrapper; | |
| 25 | use MODS::MetaAdmin::SupportInbox; | |
| 26 | use MODS::MetaAdmin::CannedReplies; | |
| 27 | use JSON::PP (); | |
| 28 | ||
| 29 | my $auth = MODS::Login->new; | |
| 30 | my $u = $auth->login_verify; | |
| 31 | unless ($u) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 32 | ||
| 33 | my $cgi = CGI->new; | |
| 34 | my $method = $ENV{REQUEST_METHOD} || 'GET'; | |
| 35 | my $inbox = MODS::MetaAdmin::SupportInbox->new; | |
| 36 | ||
| 37 | sub _esc { my $s=shift; $s//=''; $s=~s/&/&/g; $s=~s/</</g; $s=~s/>/>/g; $s=~s/"/"/g; return $s; } | |
| 38 | ||
| 39 | # Format a MySQL DATETIME "YYYY-MM-DD HH:MM:SS" as a relative age. | |
| 40 | sub _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 | #--------------------------------------------------------------------- | |
| 56 | if ($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 | #--------------------------------------------------------------------- | |
| 87 | my $thread_param = $cgi->param('thread') || ''; | |
| 88 | if ($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> ← 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> ← 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> · from <strong>$cust</strong>~; | |
| 124 | $body_html .= " <$cust_e>" if $cust_e; | |
| 125 | $body_html .= qq~ · opened $created · status <code>$stat</code> · 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> · <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 · <a href="/canned.cgi" style="color:var(--col-text-muted)">manage</a>~ | |
| 218 | : q~No canned replies yet — <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 | #--------------------------------------------------------------------- | |
| 284 | my $q_status = $cgi->param('status') // ''; | |
| 285 | my $q_site = $cgi->param('site') // ''; | |
| 286 | my $q_days = $cgi->param('days'); | |
| 287 | $q_days = 90 unless $q_days && $q_days =~ /^\d+$/; | |
| 288 | $q_days = 730 if $q_days > 730; | |
| 289 | ||
| 290 | my $threads = $inbox->list_threads( | |
| 291 | status => $q_status, | |
| 292 | site => $q_site, | |
| 293 | days => $q_days, | |
| 294 | limit => 100, | |
| 295 | ); | |
| 296 | ||
| 297 | my $total = scalar @$threads; | |
| 298 | my $unresolved = grep { ($_->{status} || '') =~ /^(open|waiting_admin)$/ } @$threads; | |
| 299 | my $admin_unread_total = 0; | |
| 300 | $admin_unread_total += ($_->{admin_unread} || 0) for @$threads; | |
| 301 | ||
| 302 | my $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 | |
| 330 | my %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 | ||
| 338 | my $q_site_e = _esc($q_site); | |
| 339 | my $site_options = '<option value="">All sites</option>'; | |
| 340 | foreach 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 | ||
| 391 | my $inbox_html = ''; | |
| 392 | if (!@$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 · 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 | ||
| 435 | MODS::MetaAdmin::Wrapper->new->render( | |
| 436 | title => 'Support inbox', | |
| 437 | page_key => 'support', | |
| 438 | body => $body, | |
| 439 | userinfo => $u, | |
| 440 | ); |