Diff -- /var/www/vhosts/webstls.com/httpdocs/admin_chat.cgi
Diff
/var/www/vhosts/webstls.com/httpdocs/admin_chat.cgi
added on WebSTLs (webstls.com) at 2026-07-01 22:26:16
Added
+532
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to bd1684fdc8c6
to bd1684fdc8c6
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 | # WebSTLs - Admin Chat | |
| 4 | # | |
| 5 | # Dedicated chat console: list of conversations on the left, thread on | |
| 6 | # the right, reply box at the bottom. Online/offline toggle controls | |
| 7 | # whether the chat widget appears on visitor-facing storefronts. | |
| 8 | #====================================================================== | |
| 9 | use strict; | |
| 10 | use warnings; | |
| 11 | ||
| 12 | use lib '/var/www/vhosts/webstls.com/httpdocs'; | |
| 13 | use CGI; | |
| 14 | use MODS::Template; | |
| 15 | use MODS::DBConnect; | |
| 16 | use MODS::Login; | |
| 17 | use MODS::WebSTLs::Config; | |
| 18 | use MODS::WebSTLs::Wrapper; | |
| 19 | use MODS::WebSTLs::Admin; | |
| 20 | use MODS::WebSTLs::Tracking; | |
| 21 | ||
| 22 | my $q = CGI->new; | |
| 23 | my $form = $q->Vars; | |
| 24 | my $auth = MODS::Login->new; | |
| 25 | my $wrap = MODS::WebSTLs::Wrapper->new; | |
| 26 | my $tfile = MODS::Template->new; | |
| 27 | my $db = MODS::DBConnect->new; | |
| 28 | my $cfg = MODS::WebSTLs::Config->new; | |
| 29 | my $admin = MODS::WebSTLs::Admin->new; | |
| 30 | my $tr = MODS::WebSTLs::Tracking->new; | |
| 31 | my $DB = $cfg->settings('database_name'); | |
| 32 | ||
| 33 | $|=1; | |
| 34 | my $userinfo = $auth->login_verify(); | |
| 35 | if (!$userinfo) { | |
| 36 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 37 | exit; | |
| 38 | } | |
| 39 | ||
| 40 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'admin_chat'; | |
| 41 | ||
| 42 | if ($entry eq 'admin_tracking_action') { | |
| 43 | $admin->require_admin($userinfo); | |
| 44 | require MODS::WebSTLs::Permissions; | |
| 45 | MODS::WebSTLs::Permissions->new->require_feature($userinfo, 'admin_view_traffic'); | |
| 46 | _handle_action($q, $form, $userinfo); | |
| 47 | exit; | |
| 48 | } | |
| 49 | ||
| 50 | $admin->require_admin($userinfo); | |
| 51 | require MODS::WebSTLs::Permissions; | |
| 52 | MODS::WebSTLs::Permissions->new->require_feature($userinfo, 'admin_chat'); | |
| 53 | ||
| 54 | my $tutorial_mode = ($form->{tutorial} && $form->{tutorial} eq '1') ? 1 : 0; | |
| 55 | ||
| 56 | my $dbh = $db->db_connect(); | |
| 57 | my $schema_ready = $tr->schema_ready($db, $dbh, $DB); | |
| 58 | ||
| 59 | my $admin_online = $schema_ready ? $tr->admin_online($db, $dbh, $DB) : 0; | |
| 60 | ||
| 61 | # View filter + search | |
| 62 | my %ok_view = (active=>1, closed=>1, archived=>1, all=>1); | |
| 63 | my $view = $form->{view} || 'active'; | |
| 64 | $view = 'active' unless $ok_view{$view}; | |
| 65 | my $search = defined $form->{q} ? $form->{q} : ''; | |
| 66 | $search =~ s/^\s+|\s+$//g; | |
| 67 | my $search_trim = substr($search, 0, 100); | |
| 68 | ||
| 69 | # Conversation list | |
| 70 | my @chats_raw = $schema_ready ? $tr->admin_chat_list($db, $dbh, $DB, | |
| 71 | filter => $view, search => $search_trim, limit => 100) : (); | |
| 72 | my $counts = $schema_ready ? $tr->chat_counts_by_status($db, $dbh, $DB) | |
| 73 | : { active=>0, closed=>0, archived=>0, all=>0 }; | |
| 74 | ||
| 75 | sub _h { my $s = shift; $s //= ''; $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; return $s; } | |
| 76 | sub _qs { my $s = shift; $s //= ''; $s =~ s/([^A-Za-z0-9\-_.~])/sprintf('%%%02X', ord($1))/eg; return $s; } | |
| 77 | ||
| 78 | # Build the ?view=X&q=... fragment so list links keep filter state. | |
| 79 | my $view_qs = '&view=' . $view; | |
| 80 | $view_qs .= '&q=' . _qs($search_trim) if length $search_trim; | |
| 81 | ||
| 82 | my @chats; | |
| 83 | my $selected_chat_id = $form->{chat} || 0; | |
| 84 | $selected_chat_id =~ s/[^0-9]//g; | |
| 85 | ||
| 86 | foreach my $c (@chats_raw) { | |
| 87 | my $is_selected = ($selected_chat_id && $c->{chat_id} eq $selected_chat_id) ? 1 : 0; | |
| 88 | $selected_chat_id ||= $c->{chat_id} if !$selected_chat_id; | |
| 89 | ||
| 90 | # Pull session fingerprint for classification. | |
| 91 | my $full = $db->db_readwrite($dbh, qq~ | |
| 92 | SELECT id, buyer_account_id FROM `${DB}`.tracking_sessions WHERE id='$c->{session_id}' LIMIT 1 | |
| 93 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 94 | my $cls = $tr->classify_session($db, $dbh, $DB, $full); | |
| 95 | ||
| 96 | my $chat_status = $c->{status} || 'open'; | |
| 97 | my $idle = $c->{idle_seconds} || 0; | |
| 98 | push @chats, { | |
| 99 | chat_id => $c->{chat_id}, | |
| 100 | session_id => $c->{session_id}, | |
| 101 | label => _h($c->{visitor_label} || ('Visitor #' . $c->{session_id})), | |
| 102 | platform_meta => _h(($c->{os_name} || '') . ' / ' . ($c->{browser_name} || '')), | |
| 103 | device => _h($c->{device_type} || ''), | |
| 104 | current_page => _h($c->{current_page_path} || ''), | |
| 105 | last_message_at => $c->{last_message_at} || '', | |
| 106 | last_body => _h(substr($c->{last_body} || '', 0, 60)), | |
| 107 | last_from => $c->{last_from} || '', | |
| 108 | unread => $c->{unread_for_admin} || 0, | |
| 109 | href => '/admin_chat.cgi?chat=' . $c->{chat_id} . $view_qs, | |
| 110 | is_selected => $is_selected, | |
| 111 | # Buyer classification (guest/lead/customer) | |
| 112 | status => $cls->{status}, | |
| 113 | status_label => $cls->{status_label}, | |
| 114 | is_customer => $cls->{status} eq 'customer' ? 1 : 0, | |
| 115 | is_lead => $cls->{status} eq 'lead' ? 1 : 0, | |
| 116 | is_guest => $cls->{status} eq 'guest' ? 1 : 0, | |
| 117 | email => _h($cls->{email} || ''), | |
| 118 | has_email => $cls->{email} ? 1 : 0, | |
| 119 | total_orders => $cls->{total_orders}, | |
| 120 | ltv_display => '$' . sprintf('%.2f', ($cls->{ltv_cents} || 0) / 100), | |
| 121 | # Conversation status (open/closed/archived) for visual treatment | |
| 122 | chat_status => $chat_status, | |
| 123 | is_open => $chat_status eq 'open' ? 1 : 0, | |
| 124 | is_closed => $chat_status eq 'closed' ? 1 : 0, | |
| 125 | is_archived => $chat_status eq 'archived' ? 1 : 0, | |
| 126 | visitor_online => ($c->{is_online} && $idle < 60) ? 1 : 0, | |
| 127 | awaiting_reply => (($c->{last_from} || '') eq 'visitor' && $c->{unread_for_admin}) ? 1 : 0, | |
| 128 | }; | |
| 129 | } | |
| 130 | ||
| 131 | # Selected thread + visitor details | |
| 132 | my @thread_rows; | |
| 133 | my $thread = {}; | |
| 134 | if ($schema_ready && $selected_chat_id) { | |
| 135 | # Mark read on view | |
| 136 | $tr->mark_chat_read($db, $dbh, $DB, $selected_chat_id, 'admin'); | |
| 137 | ||
| 138 | my @msgs = $tr->chat_messages($db, $dbh, $DB, $selected_chat_id, 200); | |
| 139 | foreach my $m (@msgs) { | |
| 140 | # HTML-escape first, then linkify URLs. Order matters: escaping | |
| 141 | # an already-built <a> tag would mangle it; URLs themselves | |
| 142 | # don't contain HTML-special chars normally, and any that do | |
| 143 | # (& in querystrings) are correctly &-escaped inside the | |
| 144 | # href attribute, which is what HTML attribute parsing expects. | |
| 145 | my $safe = _h($m->{body}); | |
| 146 | $safe =~ s{(https?://[^\s<]+)}{<a href="$1" target="_blank" rel="noopener" style="color:#60a5fa;text-decoration:underline">$1</a>}g; | |
| 147 | push @thread_rows, { | |
| 148 | id => $m->{id}, | |
| 149 | from_role => $m->{from_role}, | |
| 150 | body => $safe, | |
| 151 | sent_at => $m->{sent_at} || '', | |
| 152 | is_admin => ($m->{from_role} eq 'admin') ? 1 : 0, | |
| 153 | is_visitor => ($m->{from_role} eq 'visitor') ? 1 : 0, | |
| 154 | is_system => ($m->{from_role} eq 'system') ? 1 : 0, | |
| 155 | }; | |
| 156 | } | |
| 157 | ||
| 158 | # Visitor info for the right rail + chat row for status-driven actions | |
| 159 | my $sess = $db->db_readwrite($dbh, qq~ | |
| 160 | SELECT s.*, c.status AS chat_status FROM `${DB}`.support_chats c | |
| 161 | JOIN `${DB}`.tracking_sessions s ON s.id = c.session_id | |
| 162 | WHERE c.id='$selected_chat_id' LIMIT 1 | |
| 163 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 164 | if ($sess && $sess->{id}) { | |
| 165 | my $cls = $tr->classify_session($db, $dbh, $DB, $sess); | |
| 166 | my $chat_status = $sess->{chat_status} || 'open'; | |
| 167 | $thread = { | |
| 168 | session_id => $sess->{id}, | |
| 169 | label => _h($sess->{visitor_label} || ''), | |
| 170 | os => _h(($sess->{os_name} || '') . ' ' . ($sess->{os_version} || '')), | |
| 171 | browser => _h(($sess->{browser_name} || '') . ' ' . ($sess->{browser_version} || '')), | |
| 172 | device => _h($sess->{device_type} || ''), | |
| 173 | screen => ($sess->{screen_width} && $sess->{screen_height}) ? ($sess->{screen_width} . 'x' . $sess->{screen_height}) : '', | |
| 174 | language => _h($sess->{language} || ''), | |
| 175 | timezone => _h($sess->{timezone} || ''), | |
| 176 | current_page => _h($sess->{current_page_path} || ''), | |
| 177 | status => $cls->{status}, | |
| 178 | status_label => $cls->{status_label}, | |
| 179 | is_customer => $cls->{status} eq 'customer' ? 1 : 0, | |
| 180 | is_lead => $cls->{status} eq 'lead' ? 1 : 0, | |
| 181 | is_guest => $cls->{status} eq 'guest' ? 1 : 0, | |
| 182 | email => _h($cls->{email} || ''), | |
| 183 | has_email => $cls->{email} ? 1 : 0, | |
| 184 | total_orders => $cls->{total_orders}, | |
| 185 | ltv_display => '$' . sprintf('%.2f', ($cls->{ltv_cents} || 0) / 100), | |
| 186 | # Conversation status drives which header buttons render. | |
| 187 | chat_status => $chat_status, | |
| 188 | is_open => $chat_status eq 'open' ? 1 : 0, | |
| 189 | is_closed => $chat_status eq 'closed' ? 1 : 0, | |
| 190 | is_archived => $chat_status eq 'archived' ? 1 : 0, | |
| 191 | }; | |
| 192 | } | |
| 193 | } | |
| 194 | ||
| 195 | # ---- Concluded-chats summary (last 30 days + today) ----------------- | |
| 196 | # Counts every chat that hit status='closed' OR 'archived' (both count | |
| 197 | # as "resolved" -- archive is just an old close). Today's bar always | |
| 198 | # renders even when zero closures so the trend reads honestly. Also | |
| 199 | # pulls the individual chats so the bar drilldown overlay can list | |
| 200 | # them without a follow-up request. | |
| 201 | my @resolved_raw = $schema_ready ? $tr->chats_closed_by_day($db, $dbh, $DB, 30) : (); | |
| 202 | my @resolved_list = $schema_ready ? $tr->chats_closed_list($db, $dbh, $DB, 30) : (); | |
| 203 | my %resolved_by_d = map { $_->{d} => $_ } @resolved_raw; | |
| 204 | my %chats_by_d; | |
| 205 | foreach my $cc (@resolved_list) { | |
| 206 | my $d = $cc->{d} || ''; | |
| 207 | push @{ $chats_by_d{$d} }, { | |
| 208 | id => $cc->{id} + 0, | |
| 209 | hm => $cc->{hm} || '', | |
| 210 | label => $cc->{label} || ('Visitor #' . $cc->{id}), | |
| 211 | }; | |
| 212 | } | |
| 213 | ||
| 214 | # Build the trailing 30 days from oldest -> newest so the chart reads | |
| 215 | # left-to-right as time-moves-forward. | |
| 216 | my @resolved_days; | |
| 217 | my $resolved_max = 0; | |
| 218 | my $today_count = 0; | |
| 219 | my @short_months = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec); | |
| 220 | # Today's date as a reference point. We walk back 29 days and stop at today. | |
| 221 | my ($t_sec,$t_min,$t_hour,$t_mday,$t_mon,$t_year) = localtime(time); | |
| 222 | require POSIX; | |
| 223 | my $today_epoch = POSIX::mktime(0,0,12,$t_mday,$t_mon,$t_year); | |
| 224 | for (my $i = 29; $i >= 0; $i--) { | |
| 225 | my $epoch = $today_epoch - ($i * 86400); | |
| 226 | my @t = localtime($epoch); | |
| 227 | my $d_iso = sprintf('%04d-%02d-%02d', $t[5]+1900, $t[4]+1, $t[3]); | |
| 228 | my $d_short = sprintf('%s %d', $short_months[$t[4]], $t[3]); # "May 17" | |
| 229 | my $n = ($resolved_by_d{$d_iso} && $resolved_by_d{$d_iso}->{n}) || 0; | |
| 230 | $resolved_max = $n if $n > $resolved_max; | |
| 231 | $today_count = $n if $i == 0; | |
| 232 | # Serialise the per-day chat list so the drilldown can read it | |
| 233 | # from a data-* attribute. Embeds visitor label + close time + | |
| 234 | # link to the conversation. Strict JSON (double-quoted keys/values | |
| 235 | # with JSON-escaping) so the JS can JSON.parse it cleanly. | |
| 236 | my $chats_for_day = $chats_by_d{$d_iso} || []; | |
| 237 | my @items_json; | |
| 238 | for my $cc (@$chats_for_day) { | |
| 239 | my $lbl = $cc->{label}; | |
| 240 | # JSON-escape: backslash, double-quote, control chars | |
| 241 | $lbl =~ s/\\/\\\\/g; | |
| 242 | $lbl =~ s/"/\\"/g; | |
| 243 | $lbl =~ s/\r/\\r/g; | |
| 244 | $lbl =~ s/\n/\\n/g; | |
| 245 | $lbl =~ s/\t/\\t/g; | |
| 246 | my $hm = $cc->{hm} || ''; | |
| 247 | $hm =~ s/[^0-9:]//g; | |
| 248 | my $cid = $cc->{id} + 0; | |
| 249 | # Build with sprintf instead of qq~ so a literal '~' in the | |
| 250 | # visitor label cannot accidentally terminate the heredoc. | |
| 251 | push @items_json, sprintf('{"id":%d,"hm":"%s","label":"%s"}', $cid, $hm, $lbl); | |
| 252 | } | |
| 253 | my $chats_json = '[' . join(',', @items_json) . ']'; | |
| 254 | # HTML-attribute escape the JSON so it's safe inside data-chats='...'. | |
| 255 | # Single-quote attribute means we only need to escape the apostrophe. | |
| 256 | $chats_json =~ s/&/&/g; | |
| 257 | $chats_json =~ s/'/'/g; | |
| 258 | push @resolved_days, { | |
| 259 | d_iso => $d_iso, | |
| 260 | d_short => $d_short, | |
| 261 | d_label => $d_short, | |
| 262 | n => $n, | |
| 263 | is_current => $i == 0 ? 1 : 0, | |
| 264 | chats_json => $chats_json, | |
| 265 | }; | |
| 266 | } | |
| 267 | # Add a relative-height percent for the bar visual. | |
| 268 | foreach my $r (@resolved_days) { | |
| 269 | $r->{bar_pct} = $resolved_max ? sprintf('%.1f', ($r->{n} / $resolved_max) * 100) : '0.0'; | |
| 270 | } | |
| 271 | my $resolved_total_30d = 0; | |
| 272 | $resolved_total_30d += $_->{n} for @resolved_days; | |
| 273 | # Preserve the old var names so unrelated template branches keep working. | |
| 274 | my $mtd_resolved = $today_count; | |
| 275 | my @resolved_months = @resolved_days; | |
| 276 | my $resolved_total_6mo = $resolved_total_30d; | |
| 277 | ||
| 278 | $db->db_disconnect($dbh); | |
| 279 | ||
| 280 | my $tvars = { | |
| 281 | user_id => $userinfo->{user_id}, | |
| 282 | schema_ready => $schema_ready ? 1 : 0, | |
| 283 | schema_missing => $schema_ready ? 0 : 1, | |
| 284 | admin_online => $admin_online, | |
| 285 | ||
| 286 | # Monthly resolved-chats summary | |
| 287 | resolved_months => \@resolved_months, | |
| 288 | has_resolved_months => scalar(@resolved_months) ? 1 : 0, | |
| 289 | resolved_total_6mo => $resolved_total_6mo, | |
| 290 | resolved_mtd => $mtd_resolved, | |
| 291 | chats => \@chats, | |
| 292 | has_chats => scalar(@chats) ? 1 : 0, | |
| 293 | chat_count => scalar(@chats), | |
| 294 | selected_chat_id => $selected_chat_id || 0, | |
| 295 | has_selected => $selected_chat_id ? 1 : 0, | |
| 296 | thread => $thread, | |
| 297 | has_thread => %$thread ? 1 : 0, | |
| 298 | thread_rows => \@thread_rows, | |
| 299 | has_thread_rows => scalar(@thread_rows) ? 1 : 0, | |
| 300 | tutorial_mode => $tutorial_mode, | |
| 301 | not_tutorial_mode=> $tutorial_mode ? 0 : 1, | |
| 302 | # Filter + search state | |
| 303 | view => $view, | |
| 304 | is_view_active => $view eq 'active' ? 1 : 0, | |
| 305 | is_view_closed => $view eq 'closed' ? 1 : 0, | |
| 306 | is_view_archived => $view eq 'archived' ? 1 : 0, | |
| 307 | is_view_all => $view eq 'all' ? 1 : 0, | |
| 308 | search_q => _h($search_trim), | |
| 309 | has_search => length $search_trim ? 1 : 0, | |
| 310 | count_active => $counts->{active}, | |
| 311 | count_closed => $counts->{closed}, | |
| 312 | count_archived => $counts->{archived}, | |
| 313 | count_all => $counts->{all}, | |
| 314 | }; | |
| 315 | ||
| 316 | # Tutorial sample | |
| 317 | if ($tutorial_mode) { | |
| 318 | $tvars->{admin_online} = 1; | |
| 319 | $tvars->{count_active} = 3; | |
| 320 | $tvars->{count_closed} = 12; | |
| 321 | $tvars->{count_archived} = 48; | |
| 322 | $tvars->{count_all} = 63; | |
| 323 | # Sample daily resolved-chats trend (30 trailing days). Numbers | |
| 324 | # picked to look organic (busier weekdays, quieter weekends) without | |
| 325 | # spiking off-chart so the bar visual reads naturally in tutorial. | |
| 326 | my @sample_pattern = (4,6,5,7,3,2,1, 5,8,9,7,6,2,3, 6,7,10,8,9,4,2, 5,9,11,8,7,3,1, 6,12); | |
| 327 | my @sample_days; | |
| 328 | my @short_months_tut = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec); | |
| 329 | my ($s_sec,$s_min,$s_hour,$s_mday,$s_mon,$s_year) = localtime(time); | |
| 330 | require POSIX; | |
| 331 | my $s_today_epoch = POSIX::mktime(0,0,12,$s_mday,$s_mon,$s_year); | |
| 332 | my $sample_total = 0; | |
| 333 | my $sample_today = 0; | |
| 334 | my $sample_max = 0; | |
| 335 | for (my $i = 29; $i >= 0; $i--) { | |
| 336 | my $n = $sample_pattern[29 - $i] || 0; | |
| 337 | my @t = localtime($s_today_epoch - ($i * 86400)); | |
| 338 | my $d_iso = sprintf('%04d-%02d-%02d', $t[5]+1900, $t[4]+1, $t[3]); | |
| 339 | my $d_short = sprintf('%s %d', $short_months_tut[$t[4]], $t[3]); | |
| 340 | $sample_total += $n; | |
| 341 | $sample_today = $n if $i == 0; | |
| 342 | $sample_max = $n if $n > $sample_max; | |
| 343 | push @sample_days, { | |
| 344 | d_iso => $d_iso, | |
| 345 | d_short => $d_short, | |
| 346 | d_label => $d_short, | |
| 347 | n => $n, | |
| 348 | is_current => $i == 0 ? 1 : 0, | |
| 349 | chats_json => '[]', | |
| 350 | }; | |
| 351 | } | |
| 352 | foreach my $m (@sample_days) { | |
| 353 | $m->{bar_pct} = $sample_max ? sprintf('%.1f', ($m->{n} / $sample_max) * 100) : '0.0'; | |
| 354 | } | |
| 355 | $tvars->{resolved_months} = \@sample_days; | |
| 356 | $tvars->{has_resolved_months} = 1; | |
| 357 | $tvars->{resolved_total_6mo} = $sample_total; | |
| 358 | $tvars->{resolved_mtd} = $sample_today; | |
| 359 | $tvars->{chats} = [ | |
| 360 | { chat_id=>1, session_id=>842, label=>'Visitor #842', platform_meta=>'macOS / Safari', device=>'desktop', | |
| 361 | current_page=>'/store.cgi?id=1', last_message_at=>'2 min ago', | |
| 362 | last_body=>'Do these prints come with supports?', last_from=>'visitor', | |
| 363 | unread=>2, href=>'/admin_chat.cgi?tutorial=1&chat=1', is_selected=>1, | |
| 364 | status=>'customer', status_label=>'Customer', is_customer=>1, is_lead=>0, is_guest=>0, | |
| 365 | email=>'rune.caster@example.com', has_email=>1, total_orders=>4, ltv_display=>'$96.00', | |
| 366 | chat_status=>'open', is_open=>1, is_closed=>0, is_archived=>0, visitor_online=>1, awaiting_reply=>1 }, | |
| 367 | { chat_id=>2, session_id=>841, label=>'Visitor #841', platform_meta=>'Windows / Chrome', device=>'desktop', | |
| 368 | current_page=>'/store.cgi?id=1&page=catalog', last_message_at=>'18 min ago', | |
| 369 | last_body=>'Thanks, that worked!', last_from=>'visitor', | |
| 370 | unread=>0, href=>'/admin_chat.cgi?tutorial=1&chat=2', is_selected=>0, | |
| 371 | status=>'guest', status_label=>'Guest', is_customer=>0, is_lead=>0, is_guest=>1, | |
| 372 | email=>'', has_email=>0, total_orders=>0, ltv_display=>'$0.00', | |
| 373 | chat_status=>'open', is_open=>1, is_closed=>0, is_archived=>0, visitor_online=>0, awaiting_reply=>0 }, | |
| 374 | { chat_id=>3, session_id=>840, label=>'Visitor #840', platform_meta=>'iOS / Safari', device=>'mobile', | |
| 375 | current_page=>'/store.cgi?id=1&page=about', last_message_at=>'1 h ago', | |
| 376 | last_body=>'Glad you could help!', last_from=>'admin', | |
| 377 | unread=>0, href=>'/admin_chat.cgi?tutorial=1&chat=3', is_selected=>0, | |
| 378 | status=>'lead', status_label=>'Lead', is_customer=>0, is_lead=>1, is_guest=>0, | |
| 379 | email=>'bench.hammer@example.com', has_email=>1, total_orders=>0, ltv_display=>'$0.00', | |
| 380 | chat_status=>'open', is_open=>1, is_closed=>0, is_archived=>0, visitor_online=>0, awaiting_reply=>0 }, | |
| 381 | ]; | |
| 382 | $tvars->{has_chats} = 1; | |
| 383 | $tvars->{chat_count} = 3; | |
| 384 | $tvars->{selected_chat_id} = 1; | |
| 385 | $tvars->{has_selected} = 1; | |
| 386 | $tvars->{thread} = { | |
| 387 | session_id=>842, label=>'Visitor #842', | |
| 388 | os=>'macOS 14.4', browser=>'Safari 17.4', device=>'desktop', | |
| 389 | screen=>'2560x1440', language=>'en-US', timezone=>'America/Denver', | |
| 390 | current_page=>'/store.cgi?id=1', | |
| 391 | status=>'customer', status_label=>'Customer', is_customer=>1, is_lead=>0, is_guest=>0, | |
| 392 | email=>'rune.caster@example.com', has_email=>1, total_orders=>4, ltv_display=>'$96.00', | |
| 393 | chat_status=>'open', is_open=>1, is_closed=>0, is_archived=>0, | |
| 394 | }; | |
| 395 | $tvars->{has_thread} = 1; | |
| 396 | $tvars->{thread_rows} = [ | |
| 397 | { id=>1, from_role=>'visitor', body=>'Hi! Quick question about the Dragon Lord MK-II.', sent_at=>'14:18', is_admin=>0, is_visitor=>1, is_system=>0 }, | |
| 398 | { id=>2, from_role=>'admin', body=>'Hey! Happy to help -- what is the question?', sent_at=>'14:19', is_admin=>1, is_visitor=>0, is_system=>0 }, | |
| 399 | { id=>3, from_role=>'visitor', body=>'Do these prints come with supports?', sent_at=>'14:20', is_admin=>0, is_visitor=>1, is_system=>0 }, | |
| 400 | { id=>4, from_role=>'visitor', body=>'And what layer height did you test at?', sent_at=>'14:20', is_admin=>0, is_visitor=>1, is_system=>0 }, | |
| 401 | ]; | |
| 402 | $tvars->{has_thread_rows} = 1; | |
| 403 | $tvars->{schema_ready} = 1; | |
| 404 | $tvars->{schema_missing} = 0; | |
| 405 | } | |
| 406 | ||
| 407 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 408 | ||
| 409 | my $body = join('', $tfile->template('webstls_admin_chat.html', $tvars, $userinfo)); | |
| 410 | ||
| 411 | $wrap->render({ | |
| 412 | userinfo => $userinfo, | |
| 413 | page_key => 'admin_chat', | |
| 414 | title => 'Admin · Chat', | |
| 415 | body => $body, | |
| 416 | }); | |
| 417 | ||
| 418 | #====================================================================== | |
| 419 | # admin_tracking_action entry: POST handlers. | |
| 420 | # send_message -- write an admin reply into a chat | |
| 421 | # push_link -- push a URL to a live visitor session | |
| 422 | # toggle_online -- flip admin_online for chat widget visibility | |
| 423 | # close_chat / archive_chat / reopen_chat -- lifecycle transitions | |
| 424 | #====================================================================== | |
| 425 | sub _handle_action { | |
| 426 | my ($q, $form, $userinfo) = @_; | |
| 427 | ||
| 428 | my $admin_id = $userinfo->{user_id}; | |
| 429 | $admin_id =~ s/[^0-9]//g; | |
| 430 | ||
| 431 | my $act = $form->{act} || ''; | |
| 432 | my $back_to = $form->{back_to} || ''; | |
| 433 | ||
| 434 | my $dbh = $db->db_connect(); | |
| 435 | unless ($tr->schema_ready($db, $dbh, $DB)) { | |
| 436 | $db->db_disconnect($dbh); | |
| 437 | _act_back($back_to, '?err=schema'); | |
| 438 | return; | |
| 439 | } | |
| 440 | ||
| 441 | if ($act eq 'send_message') { | |
| 442 | my $chat_id = $form->{chat_id}; $chat_id =~ s/[^0-9]//g; | |
| 443 | my $body = $form->{body} || ''; | |
| 444 | if ($chat_id && length $body) { | |
| 445 | $tr->post_chat_message($db, $dbh, $DB, | |
| 446 | chat_id => $chat_id, | |
| 447 | from_role => 'admin', | |
| 448 | admin_user_id => $admin_id, | |
| 449 | body => $body, | |
| 450 | ); | |
| 451 | } | |
| 452 | $db->db_disconnect($dbh); | |
| 453 | _act_back($back_to, '?chat=' . ($chat_id || '')); | |
| 454 | return; | |
| 455 | } | |
| 456 | ||
| 457 | if ($act eq 'push_link') { | |
| 458 | my $sid = $form->{session_id}; $sid =~ s/[^0-9]//g; | |
| 459 | my $url = $form->{url} || ''; | |
| 460 | my $label = $form->{label} || ''; | |
| 461 | if ($sid && length $url) { | |
| 462 | $tr->push_link($db, $dbh, $DB, | |
| 463 | session_id => $sid, | |
| 464 | admin_user_id => $admin_id, | |
| 465 | url => $url, | |
| 466 | label => $label, | |
| 467 | open_mode => $form->{open_mode} || 'new_tab', | |
| 468 | ); | |
| 469 | } | |
| 470 | $db->db_disconnect($dbh); | |
| 471 | if ($back_to eq 'chat') { | |
| 472 | my $chat_id = $form->{chat_id} || ''; | |
| 473 | $chat_id =~ s/[^0-9]//g; | |
| 474 | _act_back('chat', '?chat=' . $chat_id . '&ok=pushed'); | |
| 475 | } else { | |
| 476 | _act_back('visitors', '?id=' . $sid . '&ok=pushed'); | |
| 477 | } | |
| 478 | return; | |
| 479 | } | |
| 480 | ||
| 481 | if ($act eq 'toggle_online') { | |
| 482 | my $value = $form->{value}; | |
| 483 | $value = ($value && $value eq '1') ? 1 : 0; | |
| 484 | $tr->set_admin_online($db, $dbh, $DB, $value, $admin_id); | |
| 485 | $db->db_disconnect($dbh); | |
| 486 | _act_back('chat', ''); | |
| 487 | return; | |
| 488 | } | |
| 489 | ||
| 490 | if ($act eq 'close_chat') { | |
| 491 | my $chat_id = $form->{chat_id}; $chat_id =~ s/[^0-9]//g; | |
| 492 | if ($chat_id) { | |
| 493 | $tr->close_chat_by_admin($db, $dbh, $DB, $chat_id, $admin_id); | |
| 494 | } | |
| 495 | $db->db_disconnect($dbh); | |
| 496 | # Land on the Closed view with this chat selected -- otherwise it | |
| 497 | # vanishes from the default Active list and looks broken. | |
| 498 | _act_back('chat', '?view=closed&chat=' . ($chat_id || '')); | |
| 499 | return; | |
| 500 | } | |
| 501 | ||
| 502 | if ($act eq 'archive_chat') { | |
| 503 | my $chat_id = $form->{chat_id}; $chat_id =~ s/[^0-9]//g; | |
| 504 | if ($chat_id) { | |
| 505 | $tr->archive_chat_by_admin($db, $dbh, $DB, $chat_id, $admin_id); | |
| 506 | } | |
| 507 | $db->db_disconnect($dbh); | |
| 508 | _act_back('chat', '?view=archived&chat=' . ($chat_id || '')); | |
| 509 | return; | |
| 510 | } | |
| 511 | ||
| 512 | if ($act eq 'reopen_chat') { | |
| 513 | my $chat_id = $form->{chat_id}; $chat_id =~ s/[^0-9]//g; | |
| 514 | if ($chat_id) { | |
| 515 | $tr->reopen_chat_by_admin($db, $dbh, $DB, $chat_id, $admin_id); | |
| 516 | } | |
| 517 | $db->db_disconnect($dbh); | |
| 518 | _act_back('chat', '?view=active&chat=' . ($chat_id || '')); | |
| 519 | return; | |
| 520 | } | |
| 521 | ||
| 522 | $db->db_disconnect($dbh); | |
| 523 | _act_back($back_to, '?err=unknown_act'); | |
| 524 | } | |
| 525 | ||
| 526 | sub _act_back { | |
| 527 | my ($where, $qs) = @_; | |
| 528 | my $loc = ($where eq 'visitors') ? '/admin_visitors.cgi' | |
| 529 | : ($where eq 'chat') ? '/admin_chat.cgi' | |
| 530 | : '/admin_chat.cgi'; | |
| 531 | print "Status: 302 Found\nLocation: $loc$qs\n\n"; | |
| 532 | } |