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