Diff -- /var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/MODS/AffSoft/Wrapper.pm
Diff

/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com/MODS/AffSoft/Wrapper.pm

added on local at 2026-07-01 13:57:37

Added
+678
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to d4210a66f88a
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1package MODS::AffSoft::Wrapper;
2#======================================================================
3# AffSoft — page wrapper.
4#
5# Renders the sidebar + topbar shell around a page's main content.
6# Uses MODS::Template for the shell template (affsoft_wrapper.html
7# for logged-in pages, affsoft_marketing.html for logged-out).
8#
9# Caller pattern:
10#
11# my $body = qq~ ...page body html... ~;
12# $wrap->render({
13# userinfo => $userinfo, # required for logged-in shell
14# page_key => 'dashboard', # marks the active sidebar item
15# title => 'Dashboard',
16# body => $body,
17# extra_js => ['/assets/javascript/graphs.js'], # optional
18# });
19#======================================================================
20
21use strict;
22use warnings;
23
24use MODS::Template;
25use MODS::DBConnect;
26use MODS::AffSoft::Config;
27
28my $tfile = MODS::Template->new;
29my $config = MODS::AffSoft::Config->new;
30my $db = MODS::DBConnect->new;
31
32# Sidebar nav: section headers + entries. Each entry has a key
33# matched against $page_key to set "active", an icon name (resolved
34# to inline SVG below), label, href, and optional badge.
35my @SIDEBAR = (
36 # Sidebar mirrors the profile dropdown in affsoft_wrapper.html --
37 # same sections, same wording, same hrefs, same ordering so the
38 # two menus read as one family. Order: Studio > Insights > Account
39 # > Admin (admin-only). Admin section contains every platform-wide
40 # control surface.
41 #
42 # Per-row gating attrs (all optional):
43 # feature = permission_features.slug -- hide row from team
44 # members who lack the slug. Owners always pass.
45 # owner_only = 1 -- only the account owner (no owner_user_id) sees
46 # this row. Team members never see it regardless of role.
47 # Used for billing + team management + "danger zone" entries.
48 # admin_only = 1 -- hides from non-admin users entirely.
49 { section => 'Workspace' },
50 { key => 'dashboard', label => 'Dashboard', href => '/dashboard.cgi', icon => 'welcome' },
51 { key => 'inbox', label => 'Inbox', href => '/inbox.cgi', icon => 'messages', feature => 'view_inbox' },
52 { key => 'calendar', label => 'Calendar', href => '/calendar.cgi', icon => 'calendar' },
53
54 { section => 'Merchant' },
55 { key => 'programs', label => 'My Programs', href => '/programs.cgi', icon => 'storefront' },
56 { key => 'program_new', label => 'New Program', href => '/program_new.cgi', icon => 'promotions' },
57 { key => 'conversions', label => 'Conversions', href => '/conversions.cgi', icon => 'billing' },
58 { key => 'fraud_review', label => 'Fraud Review', href => '/fraud_review.cgi', icon => 'billing' },
59 { key => 'payouts', label => 'Payouts', href => '/payouts.cgi', icon => 'billing' },
60 { key => 'merchant_announcements', label => 'Announcements', href => '/merchant_announcements.cgi', icon => 'megaphone', feature => 'merchant_announce' },
61
62 { section => 'Affiliate' },
63 { key => 'marketplace', label => 'Affiliate Marketplace', href => '/marketplace.cgi', icon => 'storefront' },
64 { key => 'discover', label => 'Discover Programs', href => '/discover.cgi', icon => 'models' },
65 { key => 'my_links', label => 'My Links', href => '/my_links.cgi', icon => 'analytics' },
66 { key => 'my_earnings', label => 'My Earnings', href => '/my_earnings.cgi', icon => 'dashboard' },
67
68 # Help-section items (Tutorials, A/B & MVT tutorials, Product tour)
69 # live in the topbar Help-button dropdown, not the sidebar -- the
70 # dropdown is reachable from every page so help is always one click
71 # away without taking sidebar real estate. Source: affsoft_wrapper.html.
72
73 { section => 'Account' },
74 { key => 'my_profile', label => 'My Profile', href => '/profile.cgi', icon => 'profile' },
75 { key => 'billing', label => 'Billing & Plan', href => '/billing.cgi', icon => 'billing', feature => 'manage_billing' },
76 { key => 'team', label => 'Team Members', href => '/team.cgi', icon => 'team', owner_only => 1 },
77 { key => 'preferences', label => 'Preferences', href => '/preferences.cgi', icon => 'settings' },
78
79 { section => 'Admin', admin_only => 1 },
80 { key => 'admin_earnings', label => 'Earnings & Expenses', href => '/admin_earnings.cgi', icon => 'billing', super_admin_only => 1, tip => 'Revenue from paid invoices + manual expense ledger. Net + margin. Date range with this-month default.' },
81 { key => 'admin_funnels', label => 'Conversion Funnel', href => '/admin_funnels.cgi', icon => 'funnel',
82 admin_only => 1 },
83 { key => 'admin_traffic', label => 'Traffic Reports', href => '/admin_traffic.cgi', icon => 'analytics',
84 admin_only => 1, tip => 'Where visitors are coming from + globe + per-country drill.' },
85 { key => 'admin_messages', label => 'Support inbox', href => '/admin_messages.cgi', icon => 'messages',
86 admin_only => 1, feature => 'admin_view_messages' },
87 { key => 'admin_announcements', label => 'Announcements', href => '/admin_email_announcements.cgi', icon => 'megaphone',
88 admin_only => 1, feature => 'admin_announce', tip => 'Compose, schedule, and broadcast announcements + email blasts. Tab of Email Setup.' }, { key => 'admin_companies', label => 'All Companies', href => '/admin_companies.cgi', icon => 'building', admin_only => 1 },
89
90 { key => 'admin_users', label => 'All Users', href => '/admin_users.cgi', icon => 'admin',
91 admin_only => 1, feature => 'admin_view_users' },
92
93 { key => 'admin_teams', label => 'Team Members', href => '/admin_teams.cgi', icon => 'team',
94 admin_only => 1 },
95 { key => 'admin_billing', label => 'User Billing', href => '/admin_billing.cgi', icon => 'billing',
96 admin_only => 1, feature => 'admin_view_billing' },
97 # Pricing & Promotions is one sidebar entry covering BOTH the
98 # billing-packages catalogue and the platform-wide promotions list.
99 # The two pages share a tab nav at the top so the user can switch
100 # between them; both set page_key='admin_packages' so this entry
101 # stays highlighted across either URL.
102 { key => 'admin_packages', label => 'Pricing & Promotions', href => '/admin_packages.cgi', icon => 'billing',
103 admin_only => 1, super_admin_only => 1 },
104 # Tax: rates + marketplace-facilitator collection policy. Visible
105 # to all admins, but only super-admins can save changes (the CGI
106 # gates the POST handlers). Separate from Pricing & Promotions
107 # because tax is platform-wide compliance, not seller-facing
108 # pricing.
109 { key => 'admin_tax', label => 'Tax', href => '/admin_tax.cgi', icon => 'billing',
110 admin_only => 1 },
111 { key => 'admin_seo', label => 'SEO', href => '/admin_seo.cgi', icon => 'seo',
112 admin_only => 1, feature => 'admin_view_seo' },
113 # Heatmaps now lives as a tab inside /admin_visitors.cgi (no
114 # separate sidebar entry). /admin_heatmap.cgi is still reachable
115 # at its direct URL and renders the same tab nav with Heatmaps
116 # highlighted.
117 { key => 'admin_chat', label => 'Chat', href => '/admin_chat.cgi', icon => 'chat',
118 admin_only => 1, feature => 'admin_chat' }, # Sandbox CCs for QA. Super-admin-only because a misused test card
119 # could be assigned to a real seller's account and silently
120 # "approve" their next charge. Staff with manage-billing get to see
121 # tests it; only the platform owner gets to create them.
122 { key => 'admin_test_cards', label => 'Test Credit Cards', href => '/admin_test_cards.cgi', icon => 'card',
123 admin_only => 1, super_admin_only => 1 },
124 { key => 'email_setup', label => 'Email Setup', href => '/admin_emails.cgi', icon => 'messages', admin_only => 1, super_admin_only => 1 },
125 # Platform-owner-only: edits Stripe keys / branding / email and
126 # other DB-backed settings. Regular admins do NOT see this row.
127 { key => 'admin_maintenance', label => 'Maintenance', href => '/admin_maintenance.cgi', icon => 'settings', super_admin_only => 1, tip => 'Lock the site for everyone except admins; edit the message users see while locked.' }, { key => 'admin_config', label => 'Configuration', href => '/admin_config.cgi', icon => 'settings',
128 admin_only => 1, super_admin_only => 1 },
129);
130
131sub new {
132 my ($class) = @_;
133 return bless({}, $class);
134}
135
136#----------------------------------------------------------------------
137# render(\%opts) — print the full page (Content-Type already sent
138# by the calling .cgi).
139#----------------------------------------------------------------------
140sub render {
141 my ($self, $opts) = @_;
142 $opts ||= {};
143
144 my $userinfo = $opts->{userinfo};
145 my $logged_in = $userinfo && $userinfo->{user_id} ? 1 : 0;
146
147 my $tvars = {
148 website_title => $config->settings('website_title'),
149 brand_name => $config->settings('brand_name'),
150 brand_tagline => $config->settings('brand_tagline'),
151 assets => $config->settings('assets'),
152 assets_css => $config->settings('assets_css'),
153 assets_js => $config->settings('assets_js'),
154 title => $opts->{title} || 'AffSoft',
155 body => $opts->{body} || '',
156 extra_js => _format_scripts($opts->{extra_js}),
157 extra_css => _format_styles($opts->{extra_css}),
158 # SEO meta block (built below). Renders into affsoft_wrapper.html
159 # / affsoft_marketing.html at $meta_tags so every page emits a
160 # consistent <meta> set without each CGI having to know.
161 meta_tags => _build_meta_tags($opts),
162 };
163
164 if ($logged_in) {
165 $tvars->{user_id} = $userinfo->{user_id};
166 $tvars->{display_name} = $userinfo->{display_name};
167 $tvars->{email} = $userinfo->{email};
168 $tvars->{plan_tier} = ucfirst($userinfo->{plan_tier} || 'free');
169 $tvars->{initials} = _initials($userinfo->{display_name} || $userinfo->{email});
170
171 my $is_admin = $userinfo->{is_admin} || $userinfo->{_admin_is_admin} || 0;
172 $tvars->{is_admin} = $is_admin;
173 # Unread support-thread count drives the red dot on the bell icon
174 # in the topbar. Querying once here so the topbar template + any
175 # sidebar consumers read the same number.
176 my $unread = _unread_messages_count($userinfo->{user_id}, $is_admin);
177 $tvars->{unread_messages_count} = $unread;
178 $tvars->{has_unread_messages} = $unread > 0 ? 1 : 0;
179 # Where the topbar Messages pill lands. Admins manage every
180 # user's support tickets via the admin console; sellers see
181 # their own support threads at the customer-facing portal.
182 $tvars->{messages_href} = $is_admin ? '/admin_messages.cgi' : '/support.cgi';
183 # Live chats awaiting an admin reply. Drives the topbar pill
184 # so admins can't miss a visitor mid-conversation no matter
185 # what page they're on. Pill always renders for admins; the
186 # idle (blue) vs waiting (green/pulsing) state is gated on
187 # count. Clicking the waiting pill jumps straight to the
188 # longest-waiting chat so admins skip the console list.
189 my $chat_data = $is_admin ? _pending_chats_data() : { n => 0, first_id => 0 };
190 $tvars->{admin_chats_pending} = $chat_data->{n};
191 $tvars->{has_admin_chats_pending} = $chat_data->{n} > 0 ? 1 : 0;
192 $tvars->{admin_chats_first_id} = $chat_data->{first_id} || 0;
193 $tvars->{admin_chats_href} = $chat_data->{first_id}
194 ? "/admin_chat.cgi?chat=$chat_data->{first_id}"
195 : '/admin_chat.cgi';
196 # In-app fraud alert: if the merchant has unreviewed suspicious
197 # conversions, prepend a banner to body. This is the reliable
198 # delivery channel -- email may end up in spam, but this banner
199 # shows the moment they log in.
200 my $fraud_n = _fraud_unreviewed_count($userinfo->{user_id});
201 if ($fraud_n > 0 && ($opts->{page_key} || '') ne 'fraud_review') {
202 my $plur = $fraud_n == 1 ? '' : 's';
203 my $banner = qq~<div style="background:linear-gradient(90deg, rgba(239,68,68,.18), rgba(239,68,68,.08));border:1px solid rgba(239,68,68,.45);border-radius:12px;padding:14px 18px;margin:0 0 24px;display:flex;align-items:center;gap:12px;flex-wrap:wrap"><div style="background:#ef4444;color:#fff;font-weight:700;font-size:12px;padding:4px 10px;border-radius:99px">FRAUD</div><div style="flex:1;font-size:14px;color:#fecaca"><strong>$fraud_n suspicious conversion$plur</strong> on your program$plur waiting for review. Commission is on hold until you decide.</div><a href="/fraud_review.cgi" style="background:#ef4444;color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:8px 16px;border-radius:8px">Review now &rarr;</a></div>~;
204 $tvars->{body} = $banner . $tvars->{body};
205 }
206 $tvars->{sidebar} = _build_sidebar($opts->{page_key} || '', $is_admin, $unread, $userinfo, $fraud_n);
207 $tvars->{dropdown_links} = _build_dropdown_links($is_admin, $unread, $userinfo);
208 $tvars->{impersonation_banner} = _impersonation_banner($userinfo);
209
210 # Per-user sidebar pref: applied as a class on <html> so the
211 # final layout is in place on first paint (no flash). Guarded
212 # against the user_settings.sidebar_collapsed column not having
213 # been added yet -- a missing-column SELECT would 500 the page.
214 my ($collapsed, $mobile_open) = _sidebar_pref($userinfo->{user_id});
215 my @cls;
216 push @cls, 'sidebar-collapsed' if $collapsed;
217 push @cls, 'mobile-nav-open' if $mobile_open;
218 $tvars->{html_class} = join(' ', @cls);
219 }
220
221 # Heatmap-view mode: when /admin_heatmap.cgi loads a page in its
222 # preview iframe it appends `heatmap_view=1` to the URL. We render
223 # that request as if the visitor were logged OUT so the DOM
224 # matches exactly what a real visitor saw -- otherwise the admin
225 # session would inject the studio sidebar wrapper and every stored
226 # selector would resolve to a different element. We also suppress
227 # track.js so the admin's preview clicks don't pollute analytics.
228 my $heatmap_view = ($ENV{QUERY_STRING} || '') =~ /(?:^|&)heatmap_view=1(?:&|$)/ ? 1 : 0;
229 $tvars->{heatmap_view} = $heatmap_view;
230
231 # Tutorial-embed mode: a [frame:/url] block inside a tutorial body
232 # loads the target URL in an iframe with ?tut_embed=1 appended. We
233 # strip the sidebar/topbar (same as heatmap_view does) so the
234 # iframe shows just the page content, not the whole studio shell.
235 my $tut_embed = ($ENV{QUERY_STRING} || '') =~ /(?:^|&)tut_embed=1(?:&|$)/ ? 1 : 0;
236 $tvars->{tut_embed} = $tut_embed;
237
238 my $template_file = ($logged_in && !$heatmap_view && !$tut_embed)
239 ? 'affsoft_wrapper.html'
240 : 'affsoft_marketing.html';
241
242 my @html = $tfile->template($template_file, $tvars, $userinfo);
243 print "@html";
244 return;
245}
246
247#======================================================================
248# Helpers
249#======================================================================
250
251sub _initials {
252 my ($name) = @_;
253 return '?' unless $name;
254 my @parts = split /[\s\@\.]+/, $name;
255 my $i = '';
256 $i .= uc(substr($parts[0], 0, 1)) if $parts[0];
257 $i .= uc(substr($parts[1], 0, 1)) if $parts[1];
258 return $i || uc(substr($name, 0, 1));
259}
260
261sub _format_scripts {
262 my ($list) = @_;
263 return '' unless $list && ref($list) eq 'ARRAY';
264 return join("\n", map { qq~<script src="$_"></script>~ } @$list);
265}
266
267sub _format_styles {
268 my ($list) = @_;
269 return '' unless $list && ref($list) eq 'ARRAY';
270 return join("\n", map { qq~<link rel="stylesheet" href="$_">~ } @$list);
271}
272
273# Cache the column-existence check so we don't run information_schema
274# on every page load. Under plain CGI this only saves work within a
275# single request; under FCGI/mod_perl it survives across requests.
276our $_sidebar_col_checked;
277our $_sidebar_col_exists;
278
279# Returns ($collapsed, $mobile_open) for the user's sidebar prefs.
280# Both default to 0 (expanded desktop, closed mobile drawer) when no
281# row exists or the migration columns are missing. sidebar_mobile_open
282# was added in a later migration so we guard for its absence.
283sub _sidebar_pref {
284 my ($user_id) = @_;
285 return (0, 0) unless $user_id;
286 $user_id =~ s/[^0-9]//g;
287 return (0, 0) unless $user_id;
288
289 my $DB = $config->settings('database_name');
290 my $dbh = $db->db_connect();
291 return (0, 0) unless $dbh;
292
293 unless ($_sidebar_col_checked) {
294 my $r = $db->db_readwrite($dbh, qq~
295 SELECT COUNT(*) AS n FROM information_schema.columns
296 WHERE table_schema='$DB'
297 AND table_name='user_settings'
298 AND column_name='sidebar_collapsed'
299 ~, $ENV{SCRIPT_NAME}, __LINE__);
300 $_sidebar_col_exists = ($r && $r->{n}) ? 1 : 0;
301 $_sidebar_col_checked = 1;
302 }
303
304 unless ($_sidebar_col_exists) {
305 $db->db_disconnect($dbh);
306 return (0, 0);
307 }
308
309 # Probe the optional mobile column (cheap, cached per-request).
310 my $mob_check = $db->db_readwrite($dbh, qq~
311 SELECT COUNT(*) AS n FROM information_schema.columns
312 WHERE table_schema='$DB'
313 AND table_name='user_settings'
314 AND column_name='sidebar_mobile_open'
315 ~, $ENV{SCRIPT_NAME}, __LINE__);
316 my $mob_extra = ($mob_check && $mob_check->{n}) ? ', sidebar_mobile_open' : '';
317
318 my $r = $db->db_readwrite($dbh, qq~
319 SELECT sidebar_collapsed$mob_extra
320 FROM `${DB}`.user_settings
321 WHERE user_id='$user_id'
322 LIMIT 1
323 ~, $ENV{SCRIPT_NAME}, __LINE__);
324 $db->db_disconnect($dbh);
325
326 my $collapsed = ($r && $r->{sidebar_collapsed}) ? 1 : 0;
327 my $mobile_open = ($r && $r->{sidebar_mobile_open}) ? 1 : 0;
328 return ($collapsed, $mobile_open);
329}
330
331sub _build_sidebar {
332 my ($active_key, $is_admin, $unread_messages, $userinfo, $fraud_count) = @_;
333 $unread_messages ||= 0;
334 $fraud_count ||= 0;
335 my $html = '';
336 my $pending_section; # buffered header; emitted when a child row gets through
337 my $is_team_member = ($userinfo && $userinfo->{owner_user_id}) ? 1 : 0;
338
339 foreach my $row (@SIDEBAR) {
340 # Admin entries (links AND section headers) are gated -- sellers
341 # must never see them. Hide the rows entirely (not just disable)
342 # so the page keys do not leak in HTML for non-admins to find.
343 next if $row->{admin_only} && !$is_admin;
344
345 if ($row->{section}) {
346 # Buffer the header; we only commit it once a visible child
347 # follows. Prevents dangling "Reporting" labels when every
348 # child is hidden by role.
349 $pending_section = $row->{section};
350 next;
351 }
352
353 # owner_only rows are visible only to the actual account owner
354 # (billing, team management, ...). Team members and impersonated
355 # admins are blocked here so the menu cannot leak them.
356 next if $row->{owner_only} && $is_team_member;
357
358 # super_admin_only rows are visible only to the platform owner
359 # (users.is_super_admin=1). Regular admins managing users / billing
360 # still see all the other admin rows but never this one.
361 if ($row->{super_admin_only}) {
362 require MODS::AffSoft::Permissions;
363 next unless MODS::AffSoft::Permissions->new->is_super_admin($userinfo);
364 }
365
366 # feature gate: skip if user lacks the slug. _has_user_feature
367 # short-circuits for owners (NULL permission_groups_id).
368 next if $row->{feature} && !_has_user_feature($userinfo, $row->{feature});
369
370 if (defined $pending_section) {
371 $html .= qq~<div class="sidebar-section-label">$pending_section</div>\n~;
372 $pending_section = undef;
373 }
374
375 my $cls = ($row->{key} eq $active_key) ? 'sidebar-link active' : 'sidebar-link';
376 my $svg = _icon($row->{icon});
377 my $badge = $row->{badge} ? qq~<span class="badge">$row->{badge}</span>~ : '';
378 my $unread_dot = '';
379 # Fraud Review: render count badge (red) when there are unreviewed flags
380 my $fraud_badge = '';
381 if ($row->{key} && $row->{key} eq 'fraud_review' && $fraud_count > 0) {
382 $fraud_badge = qq~<span style="background:#ef4444;color:#fff;font-size:10px;font-weight:700;padding:2px 7px;border-radius:99px;margin-left:auto" title="$fraud_count unreviewed">$fraud_count</span>~;
383 }
384 $html .= qq~<a class="$cls" href="$row->{href}" title="$row->{label}">$svg<span>$row->{label}</span>$unread_dot$badge$fraud_badge</a>\n~;
385 }
386 return $html;
387}
388
389# Build the profile-dropdown links from the SAME @SIDEBAR data the
390# left sidebar uses. This guarantees the two menus stay in lockstep
391# whenever items are added / renamed / removed -- never hand-edit the
392# dropdown HTML directly. Section headers become <div class="profile-
393# dropdown-section">, divider rows are inserted before each section
394# (except the first), and admin-only items get the .profile-admin
395# class so they render in the admin red/orange accent like before.
396sub _build_dropdown_links {
397 my ($is_admin, $unread_messages, $userinfo) = @_;
398 $unread_messages ||= 0;
399 my $html = '';
400 my $emitted_first_section = 0;
401 my $pending_section;
402 my $pending_divider = 0;
403 my $is_team_member = ($userinfo && $userinfo->{owner_user_id}) ? 1 : 0;
404
405 foreach my $row (@SIDEBAR) {
406 next if $row->{admin_only} && !$is_admin;
407
408 if ($row->{section}) {
409 # Buffer the header + its divider; emit only when a child
410 # row gets through. Otherwise the dropdown shows lone
411 # "Reporting" headers with nothing under them.
412 $pending_section = $row->{section};
413 $pending_divider = $emitted_first_section ? 1 : 0;
414 next;
415 }
416
417 next if $row->{owner_only} && $is_team_member;
418 if ($row->{super_admin_only}) {
419 require MODS::AffSoft::Permissions;
420 next unless MODS::AffSoft::Permissions->new->is_super_admin($userinfo);
421 }
422 next if $row->{feature} && !_has_user_feature($userinfo, $row->{feature});
423
424 if (defined $pending_section) {
425 $html .= qq~<div class="profile-dropdown-divider"></div>\n~ if $pending_divider;
426 $html .= qq~<div class="profile-dropdown-section">$pending_section</div>\n~;
427 $emitted_first_section = 1;
428 $pending_section = undef;
429 $pending_divider = 0;
430 }
431
432 my $svg = _icon($row->{icon});
433 my $admin_c = $row->{admin_only} ? ' class="profile-admin"' : '';
434 $html .= qq~<a href="$row->{href}"$admin_c><span class="dropdown-icon">$svg</span> $row->{label}</a>\n~;
435 }
436 return $html;
437}
438
439# Thin wrapper around MODS::AffSoft::Permissions->has_feature so the
440# sidebar builders stay decoupled from the module loader. Loaded once.
441my $_perm_singleton;
442sub _has_user_feature {
443 my ($userinfo, $slug) = @_;
444 return 1 unless $slug;
445 return 0 unless $userinfo;
446 # Owner / unconstrained: skip the DB trip
447 return 1 if !defined($userinfo->{permission_groups_id}) || $userinfo->{permission_groups_id} eq '';
448 if (!$_perm_singleton) {
449 return 1 unless eval { require MODS::AffSoft::Permissions; 1 };
450 $_perm_singleton = MODS::AffSoft::Permissions->new;
451 }
452 return $_perm_singleton->has_feature($userinfo, $slug);
453}
454
455# Count unread support threads driving the topbar Messages pill.
456# Admins see threads where admin_unread > 0 (any user needs reply);
457# regular users see their own threads where customer_unread > 0
458# (admin replied to them and they haven't read it yet). Guarded
459# against support_threads not existing yet on early-adopter installs.
460sub _unread_messages_count {
461 my ($uid, $is_admin) = @_;
462 return 0 unless $uid;
463 $uid =~ s/[^0-9]//g;
464 return 0 unless $uid;
465
466 require MODS::DBConnect;
467 my $db = MODS::DBConnect->new;
468 my $DB = $config->settings('database_name');
469 my $dbh = $db->db_connect();
470 return 0 unless $dbh;
471
472 my $total = 0;
473
474 # Support tickets / help threads. Admins count threads where
475 # admin_unread > 0 (anyone's ticket needing reply); regular
476 # users count their own threads where customer_unread > 0 (a
477 # reply from admin they haven't read yet). This is what makes
478 # a fresh help request light up the Messages pill.
479 my $have_st = $db->db_readwrite($dbh, qq~
480 SELECT COUNT(*) AS n FROM information_schema.tables
481 WHERE table_schema='$DB' AND table_name='support_threads'
482 ~, $ENV{SCRIPT_NAME}, __LINE__);
483 if ($have_st && $have_st->{n}) {
484 my $sql = $is_admin
485 ? qq~SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE admin_unread > 0~
486 : qq~SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE customer_user_id='$uid' AND customer_unread > 0~;
487 my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
488 $total += ($r && $r->{n}) ? $r->{n} : 0;
489 }
490
491 # DM inbox unreads (added with messaging v1). Counts THREADS, not
492 # individual messages, so the badge matches the inbox-sidebar UX
493 # (one bumped row per fresh conversation, not per message).
494 my $have_dm = $db->db_readwrite($dbh, qq~
495 SELECT COUNT(*) AS n FROM information_schema.tables
496 WHERE table_schema='$DB' AND table_name='dm_thread_participants'
497 ~, $ENV{SCRIPT_NAME}, __LINE__);
498 if ($have_dm && $have_dm->{n}) {
499 my $r = $db->db_readwrite($dbh, qq~
500 SELECT COUNT(DISTINCT p.thread_id) AS n
501 FROM `${DB}`.dm_thread_participants p
502 JOIN `${DB}`.dm_messages m
503 ON m.thread_id = p.thread_id
504 AND m.deleted_at IS NULL
505 AND m.sender_user_id <> p.user_id
506 AND (p.last_read_at IS NULL OR m.created_at > p.last_read_at)
507 WHERE p.user_id='$uid' AND p.removed_at IS NULL
508 ~, $ENV{SCRIPT_NAME}, __LINE__);
509 $total += ($r && $r->{n}) ? $r->{n} : 0;
510 }
511
512 $db->db_disconnect($dbh);
513 return $total;
514}
515
516# Open visitor chats with at least one unread message for the admin.
517# Drives both the topbar pill state (idle/waiting) and its click
518# target (jump direct to the longest-waiting chat). Table existence
519# is guarded via information_schema so pre-migration deploys can't
520# 500 the wrapper. Returns { n => count, first_id => oldest_pending_chat_id }.
521sub _pending_chats_data {
522 require MODS::DBConnect;
523 my $db = MODS::DBConnect->new;
524 my $DB = $config->settings('database_name');
525 my $dbh = $db->db_connect();
526 return { n => 0, first_id => 0 } unless $dbh;
527 my $have = $db->db_readwrite($dbh, qq~
528 SELECT COUNT(*) AS n FROM information_schema.tables
529 WHERE table_schema='$DB' AND table_name='support_chats'
530 ~, $ENV{SCRIPT_NAME}, __LINE__);
531 unless ($have && $have->{n}) {
532 $db->db_disconnect($dbh);
533 return { n => 0, first_id => 0 };
534 }
535 # ORDER BY last_message_at ASC -- oldest unread first. Sending an
536 # admin to the chat that has been waiting longest is the most
537 # courteous default; if they want a different one they can use
538 # the console list as normal.
539 my @rows = $db->db_readwrite_multiple($dbh, qq~
540 SELECT id FROM `${DB}`.support_chats
541 WHERE status = 'open'
542 AND unread_for_admin > 0
543 ORDER BY last_message_at ASC
544 ~, $ENV{SCRIPT_NAME}, __LINE__);
545 $db->db_disconnect($dbh);
546 return {
547 n => scalar(@rows),
548 first_id => scalar(@rows) ? ($rows[0]->{id} + 0) : 0,
549 };
550}
551
552# Banner shown across the top of every authenticated page WHILE an
553# admin is impersonating another user. The "Stop" link is a POST to
554# /admin_action.cgi (form, not GET) so accidental link-followers can't
555# end an impersonation by sharing a URL.
556sub _impersonation_banner {
557 my ($userinfo) = @_;
558 return '' unless $userinfo && $userinfo->{_impersonating};
559
560 my $target_email = _html_escape($userinfo->{email} || '');
561 my $target_name = _html_escape($userinfo->{display_name} || $target_email);
562 my $admin_email = _html_escape($userinfo->{_admin_email} || '');
563
564 return qq~
565<div class="impersonation-banner">
566 <div class="impersonation-banner-msg">
567 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/></svg>
568 <span><strong>Acting as</strong> $target_name &lt;$target_email&gt; &middot; admin: $admin_email</span>
569 </div>
570 <form method="POST" action="/admin_action.cgi" class="impersonation-banner-form">
571 <input type="hidden" name="act" value="stop_impersonation">
572 <button type="submit" class="impersonation-banner-stop">Stop &amp; return to admin</button>
573 </form>
574</div>
575~;
576}
577
578sub _html_escape {
579 my ($s) = @_;
580 return '' unless defined $s;
581 $s =~ s/&/&amp;/g;
582 $s =~ s/</&lt;/g;
583 $s =~ s/>/&gt;/g;
584 $s =~ s/"/&quot;/g;
585 $s =~ s/'/&#39;/g;
586 return $s;
587}
588
589# Inline SVG icon library — keeps the wrapper independent of any
590# external icon font / file. All icons are 24x24, stroke-based.
591my %ICONS = (
592 dashboard => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>',
593 models => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z"/><path d="m3 7 9 5 9-5"/><path d="M12 22V12"/></svg>',
594 upload => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
595 storefront => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m3 9 1-5h16l1 5"/><path d="M5 9v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9"/><path d="M9 21V13h6v8"/></svg>',
596 audience => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
597 messages => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>',
598 funnel => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg>',
599 analytics => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="m7 14 4-4 4 4 5-5"/></svg>',
600 optimize => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
601 settings => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33 1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>',
602 building => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="2" width="18" height="20" rx="2"/><path d="M9 22V12h6v10"/><path d="M9 6h.01"/><path d="M9 10h.01"/><path d="M15 6h.01"/><path d="M15 10h.01"/></svg>',
603 calendar => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
604 clock => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
605 admin => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/><path d="m9 12 2 2 4-4"/></svg>',
606 billing => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>',
607 promotions => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>',
608 profile => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
609 seo => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/><path d="M7 11h8"/><path d="M11 7v8"/></svg>',
610 visitors => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z"/><circle cx="12" cy="12" r="3"/></svg>',
611 chat => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>',
612 welcome => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12 12 3l9 9"/><path d="M5 10v10a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V10"/></svg>',
613 heatmap => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8" cy="9" r="3"/><circle cx="16" cy="14" r="4"/><circle cx="10" cy="17" r="2"/><rect x="2" y="2" width="20" height="20" rx="2"/></svg>',
614 team => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
615 card => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/><line x1="14" y1="15" x2="18" y2="15"/></svg>',
616 megaphone => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 11v2a1 1 0 0 0 1 1h3l5 4V6l-5 4H4a1 1 0 0 0-1 1Z"/><path d="M16 8a4 4 0 0 1 0 8"/><path d="M19 5a8 8 0 0 1 0 14"/></svg>',
617);
618
619sub _icon {
620 my ($name) = @_;
621 return $ICONS{$name} || '';
622}
623
624# Build the <meta> block injected into every page's <head>. Pulls
625# platform defaults from platform_settings, and if the caller passed
626# opts->{storefront} (e.g. /store.cgi), layers the per-storefront SEO
627# row over the top. Cached per-request via $_meta_cache so repeated
628# calls in one CGI don't re-query.
629our $_meta_cache;
630sub _build_meta_tags {
631 my ($opts) = @_;
632 return '' unless $opts;
633
634 require MODS::DBConnect;
635 require MODS::AffSoft::SEO;
636 my $db = MODS::DBConnect->new;
637 my $seo = MODS::AffSoft::SEO->new;
638 my $DB = $config->settings('database_name');
639 my $dbh = $db->db_connect();
640 return '' unless $dbh;
641
642 my $platform = $seo->get_platform_settings($db, $dbh, $DB);
643 my $store = $opts->{storefront} || {};
644
645 my $tags = $seo->render_tags(
646 platform => $platform,
647 storefront => $store,
648 page_title => $opts->{title} || '',
649 request_url => $opts->{canonical_url} || '',
650 );
651
652 $db->db_disconnect($dbh);
653 return $tags;
654}
655
656# Unreviewed suspicious-fraud-flag count for THIS merchant's programs.
657# Drives the in-app banner that appears on every page (except fraud_review
658# itself). Reliable signal independent of email deliverability.
659sub _fraud_unreviewed_count {
660 my $uid = shift;
661 return 0 unless $uid && $uid > 0;
662 my $db = MODS::DBConnect->new;
663 my $DB = $config->settings('database_name');
664 my $dbh = $db->db_connect();
665 return 0 unless $dbh;
666 my $row = $db->db_readwrite($dbh, qq~
667 SELECT COUNT(*) AS n
668 FROM ${DB}.affiliate_conversions c
669 JOIN ${DB}.affiliate_programs p ON p.id = c.program_id
670 WHERE p.merchant_user_id = $uid
671 AND c.fraud_review_status = 'suspicious'
672 AND c.reviewed_at IS NULL
673 ~, $0, __LINE__);
674 $db->db_disconnect($dbh);
675 return ($row && $row->{n}) ? $row->{n} : 0;
676}
677
6781;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help