Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/MODS/ContactForge/Wrapper.pm

O Operator
Diff

/var/www/vhosts/3dshawn.com/crm.3dshawn.com/MODS/ContactForge/Wrapper.pm

added on local at 2026-07-01 15:20:26

Added
+0
lines
Removed
-0
lines
Context
741
unchanged
Blobs
from e9ae3876468f
to e9ae3876468f
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11package MODS::ContactForge::Wrapper;
22#======================================================================
33# ContactForge — page wrapper.
44#
55# Renders the sidebar + topbar shell around a page's main content.
66# Uses MODS::Template for the shell template (cf_wrapper.html
77# for logged-in pages, cf_marketing.html for logged-out).
88#
99# Caller pattern:
1010#
1111# my $body = qq~ ...page body html... ~;
1212# $wrap->render({
1313# userinfo => $userinfo, # required for logged-in shell
1414# page_key => 'dashboard', # marks the active sidebar item
1515# title => 'Dashboard',
1616# body => $body,
1717# extra_js => ['/assets/javascript/graphs.js'], # optional
1818# });
1919#======================================================================
2020
2121use strict;
2222use warnings;
2323
2424use MODS::Template;
2525use MODS::DBConnect;
2626use MODS::ContactForge::Config;
2727
2828my $tfile = MODS::Template->new;
2929my $config = MODS::ContactForge::Config->new;
3030my $db = MODS::DBConnect->new;
3131
3232# Sidebar nav: section headers + entries. Each entry has a key
3333# matched against $page_key to set "active", an icon name (resolved
3434# to inline SVG below), label, href, and optional badge.
3535my @SIDEBAR = (
3636 # Sidebar mirrors the profile dropdown in cf_wrapper.html --
3737 # same sections, same wording, same hrefs, same ordering so the
3838 # two menus read as one family. Groups: WORKSPACE > CRM > GROWTH
3939 # > DATA > ACCOUNT > ADMIN (admin-only).
4040 #
4141 # Per-row gating attrs (all optional):
4242 # feature = permission_features.slug -- hide row from team
4343 # members who lack the slug. Owners always pass.
4444 # owner_only = 1 -- only the account owner (no owner_user_id) sees
4545 # this row. Team members never see it regardless of role.
4646 # Used for billing + team management + "danger zone" entries.
4747 # admin_only = 1 -- hides from non-admin users entirely.
4848 { section => 'Workspace' },
4949 { key => 'dashboard', label => 'Dashboard', href => '/dashboard.cgi', icon => 'dashboard' },
5050 { key => 'calendar', label => 'Calendar', href => '/calendar.cgi', icon => 'calendar' },
5151
5252 { section => 'CRM' },
5353 { key => 'contacts', label => 'Contacts', href => '/contacts.cgi', icon => 'team', feature => 'view_contacts' },
5454 { key => 'companies', label => 'Companies', href => '/companies.cgi', icon => 'companies', feature => 'view_contacts' },
5555 { key => 'deals', label => 'Deals', href => '/deals.cgi', icon => 'deals', feature => 'view_deals' },
5656 { key => 'activities', label => 'Activities', href => '/activities.cgi', icon => 'activities', feature => 'view_activities' },
5757 { key => 'tasks', label => 'Tasks', href => '/tasks.cgi', icon => 'tasks', feature => 'view_tasks' },
5858
5959 { section => 'Growth' },
6060 { key => 'campaigns', label => 'Campaigns', href => '/campaigns.cgi', icon => 'campaigns', feature => 'view_campaigns' },
6161 { key => 'automations', label => 'Automations', href => '/automations.cgi', icon => 'automations', feature => 'view_automations' },
6262 { key => 'segments', label => 'Segments', href => '/segments.cgi', icon => 'audience', feature => 'view_segments' },
6363 { key => 'tags', label => 'Tags', href => '/tags.cgi', icon => 'tags', feature => 'view_tags' },
6464
6565 { section => 'Analytics' },
6666 { key => 'reports', label => 'Reports', href => '/reports.cgi', icon => 'analytics', feature => 'view_reports' },
6767 { key => 'documents', label => 'Documents', href => '/documents.cgi', icon => 'documents', feature => 'view_documents' },
6868
6969 { section => 'Account' },
7070 { key => 'my_profile', label => 'My Profile', href => '/profile.cgi', icon => 'profile' },
7171 { key => 'preferences', label => 'Preferences', href => '/preferences.cgi', icon => 'settings' },
7272 { key => 'email_smtp', label => 'Email Provider', href => '/email_settings.cgi', icon => 'settings' },
7373 { key => 'billing', label => 'Billing & Plan', href => '/billing.cgi', icon => 'billing', feature => 'manage_billing' },
7474 { key => 'team', label => 'Team', href => '/team.cgi', icon => 'team', owner_only => 1 },
7575
7676 { section => 'Integrations' },
7777 { key => 'integrations', label => 'Integrations', href => '/integrations.cgi', icon => 'campaigns' },
7878 { key => 'import', label => 'Import', href => '/import.cgi', icon => 'admin' },
7979 { key => 'forms', label => 'Lead Forms', href => '/forms.cgi', icon => 'campaigns' },
8080 { key => 'api_docs', label => 'API Docs', href => '/api_docs.cgi', icon => 'team' },
8181 { key => 'api_keys', label => 'API Keys', href => '/api_keys.cgi', icon => 'team' },
8282
8383 { section => 'Admin', admin_only => 1 },
8484 { 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.' },
8585 { key => 'admin_funnels', label => 'Conversion Funnel', href => '/admin_funnels.cgi', icon => 'funnel',
8686 admin_only => 1 },
8787 { key => 'admin_visitors', label => 'Live Visitors', href => '/admin_visitors.cgi', icon => 'admin',
8888 admin_only => 1 },
8989 { key => 'admin_traffic', label => 'Traffic Reports', href => '/admin_traffic.cgi', icon => 'reports',
9090 admin_only => 1 },
9191 # Heatmaps lives as a tab inside /admin_visitors.cgi (no separate
9292 # sidebar entry). /admin_heatmap.cgi is still reachable at its direct
9393 # URL and renders the same tab nav with Heatmaps highlighted.
9494 { key => 'admin_messages', label => 'Support inbox', href => '/admin_messages.cgi', icon => 'messages',
9595 admin_only => 1, feature => 'admin_view_messages' }, { key => 'admin_companies', label => 'All Companies', href => '/admin_companies.cgi', icon => 'building', admin_only => 1 },
9696
9797 { key => 'admin_users', label => 'All Users', href => '/admin_users.cgi', icon => 'admin',
9898 admin_only => 1, feature => 'admin_view_users' },
9999
100100 { key => 'admin_teams', label => 'Team Members', href => '/admin_teams.cgi', icon => 'team',
101101 admin_only => 1 },
102102 { key => 'admin_billing', label => 'User Billing', href => '/admin_billing.cgi', icon => 'billing',
103103 admin_only => 1, feature => 'admin_view_billing' },
104104 # Pricing & Promotions is one sidebar entry covering BOTH the
105105 # billing-packages catalogue and the platform-wide promotions list.
106106 # The two pages share a tab nav at the top so the user can switch
107107 # between them; both set page_key='admin_packages' so this entry
108108 # stays highlighted across either URL.
109109 { key => 'admin_packages', label => 'Pricing & Promotions', href => '/admin_packages.cgi', icon => 'billing',
110110 admin_only => 1, super_admin_only => 1 },
111111 { key => 'admin_seo', label => 'SEO', href => '/admin_seo.cgi', icon => 'seo',
112112 admin_only => 1, feature => 'admin_view_seo' },
113113 { key => 'admin_chat', label => 'Chat', href => '/admin_chat.cgi', icon => 'chat',
114114 admin_only => 1, feature => 'admin_chat' }, # Sandbox CCs for QA. Super-admin-only because a misused test card
115115 # could be assigned to a real rep's account and silently
116116 # "approve" their next charge. Staff with manage-billing get to see
117117 # tests it; only the platform owner gets to create them.
118118 { key => 'admin_test_cards', label => 'Test Credit Cards', href => '/admin_test_cards.cgi', icon => 'card',
119119 admin_only => 1, super_admin_only => 1 },
120120 { key => 'email_setup', label => 'Email Setup', href => '/admin_emails.cgi', icon => 'messages', admin_only => 1, super_admin_only => 1 },
121121 { key => 'admin_tax', label => 'Tax', href => '/admin_tax.cgi', icon => 'billing',
122122 admin_only => 1, super_admin_only => 1 },
123123 # Platform-owner-only: edits Stripe keys / branding / email and
124124 # other DB-backed settings. Regular admins do NOT see this row.
125125 { 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',
126126 admin_only => 1, super_admin_only => 1 },
127127);
128128
129129sub new {
130130 my ($class) = @_;
131131 return bless({}, $class);
132132}
133133
134134#----------------------------------------------------------------------
135135# render(\%opts) — print the full page (Content-Type already sent
136136# by the calling .cgi).
137137#----------------------------------------------------------------------
138138sub render {
139139 my ($self, $opts) = @_;
140140 $opts ||= {};
141141
142142 my $userinfo = $opts->{userinfo};
143143 my $logged_in = $userinfo && $userinfo->{user_id} ? 1 : 0;
144144
145145 my $tvars = {
146146 website_title => $config->settings('website_title'),
147147 brand_name => $config->settings('brand_name'),
148148 brand_tagline => $config->settings('brand_tagline'),
149149 assets => $config->settings('assets'),
150150 assets_css => $config->settings('assets_css'),
151151 assets_js => $config->settings('assets_js'),
152152 title => $opts->{title} || 'ContactForge',
153153 body => $opts->{body} || '',
154154 extra_js => _format_scripts($opts->{extra_js}),
155155 extra_css => _format_styles($opts->{extra_css}),
156156 # SEO meta block (built below). Renders into cf_wrapper.html
157157 # / cf_marketing.html at $meta_tags so every page emits a
158158 # consistent <meta> set without each CGI having to know.
159159 meta_tags => _build_meta_tags($opts),
160160 };
161161
162162 if ($logged_in) {
163163 $tvars->{user_id} = $userinfo->{user_id};
164164 $tvars->{display_name} = $userinfo->{display_name};
165165 $tvars->{email} = $userinfo->{email};
166166 $tvars->{plan_tier} = ucfirst($userinfo->{plan_tier} || 'free');
167167 $tvars->{initials} = _initials($userinfo->{display_name} || $userinfo->{email});
168168
169169 my $is_admin = $userinfo->{is_admin} || $userinfo->{_admin_is_admin} || 0;
170170 $tvars->{is_admin} = $is_admin;
171171 # Unread support-thread count drives the red dot on the topbar
172172 # Support inbox pill (admins only). Reps have no inbox surface,
173173 # so the count is suppressed and the pill is hidden client-side.
174174 my $unread = $is_admin ? _unread_messages_count($userinfo->{user_id}, $is_admin) : 0;
175175 $tvars->{unread_messages_count} = $unread;
176176 $tvars->{has_unread_messages} = $unread > 0 ? 1 : 0;
177177 # Topbar Support-inbox pill lands at the admin console.
178178 $tvars->{messages_href} = '/admin_messages.cgi';
179179 # Live chats awaiting an admin reply. Drives the topbar pill
180180 # so admins can't miss a visitor mid-conversation no matter
181181 # what page they're on. Pill always renders for admins; the
182182 # idle (blue) vs waiting (green/pulsing) state is gated on
183183 # count. Clicking the waiting pill jumps straight to the
184184 # longest-waiting chat so admins skip the console list.
185185 my $chat_data = $is_admin ? _pending_chats_data() : { n => 0, first_id => 0 };
186186 $tvars->{admin_chats_pending} = $chat_data->{n};
187187 $tvars->{has_admin_chats_pending} = $chat_data->{n} > 0 ? 1 : 0;
188188 $tvars->{admin_chats_first_id} = $chat_data->{first_id} || 0;
189189 $tvars->{admin_chats_href} = $chat_data->{first_id}
190190 ? "/admin_chat.cgi?chat=$chat_data->{first_id}"
191191 : '/admin_chat.cgi';
192192 $tvars->{sidebar} = _build_sidebar($opts->{page_key} || '', $is_admin, $unread, $userinfo);
193193 $tvars->{dropdown_links} = _build_dropdown_links($is_admin, $unread, $userinfo);
194194 $tvars->{impersonation_banner} = _impersonation_banner($userinfo);
195195 $tvars->{trial_banner} = _trial_banner($userinfo);
196196 $tvars->{cap_warn_banner} = _cap_warn_banner($userinfo);
197197
198198 # Per-user sidebar pref: applied as a class on <html> so the
199199 # final layout is in place on first paint (no flash). Guarded
200200 # against the user_settings.sidebar_collapsed column not having
201201 # been added yet -- a missing-column SELECT would 500 the page.
202202 my ($collapsed, $mobile_open) = _sidebar_pref($userinfo->{user_id});
203203 my @cls;
204204 push @cls, 'sidebar-collapsed' if $collapsed;
205205 push @cls, 'mobile-nav-open' if $mobile_open;
206206 $tvars->{html_class} = join(' ', @cls);
207207 }
208208
209209 # Heatmap-view mode: when /admin_heatmap.cgi loads a page in its
210210 # preview iframe it appends `heatmap_view=1` to the URL. We render
211211 # that request as if the visitor were logged OUT so the DOM
212212 # matches exactly what a real visitor saw -- otherwise the admin
213213 # session would inject the studio sidebar wrapper and every stored
214214 # selector would resolve to a different element. We also suppress
215215 # track.js so the admin's preview clicks don't pollute analytics.
216216 my $heatmap_view = ($ENV{QUERY_STRING} || '') =~ /(?:^|&)heatmap_view=1(?:&|$)/ ? 1 : 0;
217217 $tvars->{heatmap_view} = $heatmap_view;
218218
219219 # Tutorial-embed mode: a [frame:/url] block inside a tutorial body
220220 # loads the target URL in an iframe with ?tut_embed=1 appended. We
221221 # strip the sidebar/topbar (same as heatmap_view does) so the
222222 # iframe shows just the page content, not the whole studio shell.
223223 my $tut_embed = ($ENV{QUERY_STRING} || '') =~ /(?:^|&)tut_embed=1(?:&|$)/ ? 1 : 0;
224224 $tvars->{tut_embed} = $tut_embed;
225225
226226 my $template_file = ($logged_in && !$heatmap_view && !$tut_embed)
227227 ? 'cf_wrapper.html'
228228 : 'cf_marketing.html';
229229
230230 my @html = $tfile->template($template_file, $tvars, $userinfo);
231231 print "@html";
232232 return;
233233}
234234
235235#======================================================================
236236# Helpers
237237#======================================================================
238238
239239sub _initials {
240240 my ($name) = @_;
241241 return '?' unless $name;
242242 my @parts = split /[\s\@\.]+/, $name;
243243 my $i = '';
244244 $i .= uc(substr($parts[0], 0, 1)) if $parts[0];
245245 $i .= uc(substr($parts[1], 0, 1)) if $parts[1];
246246 return $i || uc(substr($name, 0, 1));
247247}
248248
249249sub _format_scripts {
250250 my ($list) = @_;
251251 return '' unless $list && ref($list) eq 'ARRAY';
252252 return join("\n", map { qq~<script src="$_"></script>~ } @$list);
253253}
254254
255255sub _format_styles {
256256 my ($list) = @_;
257257 return '' unless $list && ref($list) eq 'ARRAY';
258258 return join("\n", map { qq~<link rel="stylesheet" href="$_">~ } @$list);
259259}
260260
261261# Cache the column-existence check so we don't run information_schema
262262# on every page load. Under plain CGI this only saves work within a
263263# single request; under FCGI/mod_perl it survives across requests.
264264our $_sidebar_col_checked;
265265our $_sidebar_col_exists;
266266
267267# Returns ($collapsed, $mobile_open) for the user's sidebar prefs.
268268# Both default to 0 (expanded desktop, closed mobile drawer) when no
269269# row exists or the migration columns are missing. sidebar_mobile_open
270270# was added in a later migration so we guard for its absence.
271271sub _sidebar_pref {
272272 my ($user_id) = @_;
273273 return (0, 0) unless $user_id;
274274 $user_id =~ s/[^0-9]//g;
275275 return (0, 0) unless $user_id;
276276
277277 my $DB = $config->settings('database_name');
278278 my $dbh = $db->db_connect();
279279 return (0, 0) unless $dbh;
280280
281281 unless ($_sidebar_col_checked) {
282282 my $r = $db->db_readwrite($dbh, qq~
283283 SELECT COUNT(*) AS n FROM information_schema.columns
284284 WHERE table_schema='$DB'
285285 AND table_name='user_settings'
286286 AND column_name='sidebar_collapsed'
287287 ~, $ENV{SCRIPT_NAME}, __LINE__);
288288 $_sidebar_col_exists = ($r && $r->{n}) ? 1 : 0;
289289 $_sidebar_col_checked = 1;
290290 }
291291
292292 unless ($_sidebar_col_exists) {
293293 $db->db_disconnect($dbh);
294294 return (0, 0);
295295 }
296296
297297 # Probe the optional mobile column (cheap, cached per-request).
298298 my $mob_check = $db->db_readwrite($dbh, qq~
299299 SELECT COUNT(*) AS n FROM information_schema.columns
300300 WHERE table_schema='$DB'
301301 AND table_name='user_settings'
302302 AND column_name='sidebar_mobile_open'
303303 ~, $ENV{SCRIPT_NAME}, __LINE__);
304304 my $mob_extra = ($mob_check && $mob_check->{n}) ? ', sidebar_mobile_open' : '';
305305
306306 my $r = $db->db_readwrite($dbh, qq~
307307 SELECT sidebar_collapsed$mob_extra
308308 FROM `${DB}`.user_settings
309309 WHERE user_id='$user_id'
310310 LIMIT 1
311311 ~, $ENV{SCRIPT_NAME}, __LINE__);
312312 $db->db_disconnect($dbh);
313313
314314 my $collapsed = ($r && $r->{sidebar_collapsed}) ? 1 : 0;
315315 my $mobile_open = ($r && $r->{sidebar_mobile_open}) ? 1 : 0;
316316 return ($collapsed, $mobile_open);
317317}
318318
319319
320320sub _build_sidebar {
321321 my ($active_key, $is_admin, $unread_messages, $userinfo) = @_;
322322 $unread_messages ||= 0;
323323 my $html = '';
324324 my $pending_section; # buffered header; emitted when a child row gets through
325325 my $is_team_member = ($userinfo && $userinfo->{owner_user_id}) ? 1 : 0;
326326
327327 foreach my $row (@SIDEBAR) {
328328 # Admin entries (links AND section headers) are gated -- reps
329329 # must never see them. Hide the rows entirely (not just disable)
330330 # so the page keys do not leak in HTML for non-admins to find.
331331 next if $row->{admin_only} && !$is_admin;
332332
333333 if ($row->{section}) {
334334 # Buffer the header; we only commit it once a visible child
335335 # follows. Prevents dangling "Reporting" labels when every
336336 # child is hidden by role.
337337 $pending_section = $row->{section};
338338 next;
339339 }
340340
341341 # owner_only rows are visible only to the actual account owner
342342 # (billing, team management, ...). Team members and impersonated
343343 # admins are blocked here so the menu cannot leak them.
344344 next if $row->{owner_only} && $is_team_member;
345345
346346 # super_admin_only rows are visible only to the platform owner
347347 # (users.is_super_admin=1). Regular admins managing users / billing
348348 # still see all the other admin rows but never this one.
349349 if ($row->{super_admin_only}) {
350350 require MODS::ContactForge::Permissions;
351351 next unless MODS::ContactForge::Permissions->new->is_super_admin($userinfo);
352352 }
353353
354354 # feature gate: skip if user lacks the slug. _has_user_feature
355355 # short-circuits for owners (NULL permission_groups_id).
356356 next if $row->{feature} && !_has_user_feature($userinfo, $row->{feature});
357357
358358 if (defined $pending_section) {
359359 $html .= qq~<div class="sidebar-section-label">$pending_section</div>\n~;
360360 $pending_section = undef;
361361 }
362362
363363 my $cls = ($row->{key} eq $active_key) ? 'sidebar-link active' : 'sidebar-link';
364364 my $svg = _icon($row->{icon});
365365 my $badge = $row->{badge} ? qq~<span class="badge">$row->{badge}</span>~ : '';
366366 # Live unread dot on the Messages row only -- the dot is omitted
367367 # when there are zero unread comments/reviews so the navigation
368368 # never shows a permanent "alert" indicator.
369369 my $unread_dot = '';
370370 if ($row->{key} && $row->{key} eq 'messages' && $unread_messages > 0) {
371371 $unread_dot = qq~<span class="menu-dot" title="$unread_messages unread"></span>~;
372372 }
373373 # title="..." surfaces as a hover tooltip when the sidebar is
374374 # collapsed to the icon rail (the CSS rule reads attr(title)).
375375 $html .= qq~<a class="$cls" href="$row->{href}" title="$row->{label}">$svg<span>$row->{label}</span>$unread_dot$badge</a>\n~;
376376 }
377377 return $html;
378378}
379379
380380# Build the profile-dropdown links from the SAME @SIDEBAR data the
381381# left sidebar uses. This guarantees the two menus stay in lockstep
382382# whenever items are added / renamed / removed -- never hand-edit the
383383# dropdown HTML directly. Section headers become <div class="profile-
384384# dropdown-section">, divider rows are inserted before each section
385385# (except the first), and admin-only items get the .profile-admin
386386# class so they render in the admin red/orange accent like before.
387387sub _build_dropdown_links {
388388 my ($is_admin, $unread_messages, $userinfo) = @_;
389389 $unread_messages ||= 0;
390390 my $html = '';
391391 my $emitted_first_section = 0;
392392 my $pending_section;
393393 my $pending_divider = 0;
394394 my $is_team_member = ($userinfo && $userinfo->{owner_user_id}) ? 1 : 0;
395395
396396 foreach my $row (@SIDEBAR) {
397397 next if $row->{admin_only} && !$is_admin;
398398
399399 if ($row->{section}) {
400400 # Buffer the header + its divider; emit only when a child
401401 # row gets through. Otherwise the dropdown shows lone
402402 # "Reporting" headers with nothing under them.
403403 $pending_section = $row->{section};
404404 $pending_divider = $emitted_first_section ? 1 : 0;
405405 next;
406406 }
407407
408408 next if $row->{owner_only} && $is_team_member;
409409 if ($row->{super_admin_only}) {
410410 require MODS::ContactForge::Permissions;
411411 next unless MODS::ContactForge::Permissions->new->is_super_admin($userinfo);
412412 }
413413 next if $row->{feature} && !_has_user_feature($userinfo, $row->{feature});
414414
415415 if (defined $pending_section) {
416416 $html .= qq~<div class="profile-dropdown-divider"></div>\n~ if $pending_divider;
417417 $html .= qq~<div class="profile-dropdown-section">$pending_section</div>\n~;
418418 $emitted_first_section = 1;
419419 $pending_section = undef;
420420 $pending_divider = 0;
421421 }
422422
423423 my $svg = _icon($row->{icon});
424424 my $admin_c = $row->{admin_only} ? ' class="profile-admin"' : '';
425425 my $unread_dot = '';
426426 if ($row->{key} && $row->{key} eq 'messages' && $unread_messages > 0) {
427427 $unread_dot = qq~<span class="menu-dot" style="margin-left:auto" title="$unread_messages unread"></span>~;
428428 }
429429 $html .= qq~<a href="$row->{href}"$admin_c><span class="dropdown-icon">$svg</span> $row->{label}$unread_dot</a>\n~;
430430 }
431431 return $html;
432432}
433433
434434# Thin wrapper around MODS::ContactForge::Permissions->has_feature so the
435435# sidebar builders stay decoupled from the module loader. Loaded once.
436436my $_perm_singleton;
437437sub _has_user_feature {
438438 my ($userinfo, $slug) = @_;
439439 return 1 unless $slug;
440440 return 0 unless $userinfo;
441441 # Owner / unconstrained: skip the DB trip
442442 return 1 if !defined($userinfo->{permission_groups_id}) || $userinfo->{permission_groups_id} eq '';
443443 if (!$_perm_singleton) {
444444 return 1 unless eval { require MODS::ContactForge::Permissions; 1 };
445445 $_perm_singleton = MODS::ContactForge::Permissions->new;
446446 }
447447 return $_perm_singleton->has_feature($userinfo, $slug);
448448}
449449
450450# Count rows that drive the unread indicator on the topbar Support-
451451# inbox pill. Admin-only surface; reps never see the pill so the
452452# caller is expected to skip this for non-admins.
453453sub _unread_messages_count {
454454 my ($uid, $is_admin) = @_;
455455 return 0 unless $uid;
456456 $uid =~ s/[^0-9]//g;
457457 return 0 unless $uid;
458458
459459 require MODS::DBConnect;
460460 my $db = MODS::DBConnect->new;
461461 my $DB = $config->settings('database_name');
462462 my $dbh = $db->db_connect();
463463 return 0 unless $dbh;
464464
465465 my $total = 0;
466466
467467 # Support tickets / help threads. Admins count threads where
468468 # admin_unread > 0 (anyone's ticket needing reply); regular
469469 # users count their own threads where customer_unread > 0 (a
470470 # reply from admin they haven't read yet).
471471 my $have_st = $db->db_readwrite($dbh, qq~
472472 SELECT COUNT(*) AS n FROM information_schema.tables
473473 WHERE table_schema='$DB' AND table_name='support_threads'
474474 ~, $ENV{SCRIPT_NAME}, __LINE__);
475475 if ($have_st && $have_st->{n}) {
476476 my $sql = $is_admin
477477 ? qq~SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE admin_unread > 0~
478478 : qq~SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE customer_user_id='$uid' AND customer_unread > 0~;
479479 my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
480480 $total += ($r && $r->{n}) ? $r->{n} : 0;
481481 }
482482
483483 $db->db_disconnect($dbh);
484484 return $total;
485485}
486486
487487# Open visitor chats with at least one unread message for the admin.
488488# Drives both the topbar pill state (idle/waiting) and its click
489489# target (jump direct to the longest-waiting chat). Table existence
490490# is guarded via information_schema so pre-migration deploys can't
491491# 500 the wrapper. Returns { n => count, first_id => oldest_pending_chat_id }.
492492sub _pending_chats_data {
493493 require MODS::DBConnect;
494494 my $db = MODS::DBConnect->new;
495495 my $DB = $config->settings('database_name');
496496 my $dbh = $db->db_connect();
497497 return { n => 0, first_id => 0 } unless $dbh;
498498 my $have = $db->db_readwrite($dbh, qq~
499499 SELECT COUNT(*) AS n FROM information_schema.tables
500500 WHERE table_schema='$DB' AND table_name='support_chats'
501501 ~, $ENV{SCRIPT_NAME}, __LINE__);
502502 unless ($have && $have->{n}) {
503503 $db->db_disconnect($dbh);
504504 return { n => 0, first_id => 0 };
505505 }
506506 # ORDER BY last_message_at ASC -- oldest unread first. Sending an
507507 # admin to the chat that has been waiting longest is the most
508508 # courteous default; if they want a different one they can use
509509 # the console list as normal.
510510 my @rows = $db->db_readwrite_multiple($dbh, qq~
511511 SELECT id FROM `${DB}`.support_chats
512512 WHERE status = 'open'
513513 AND unread_for_admin > 0
514514 ORDER BY last_message_at ASC
515515 ~, $ENV{SCRIPT_NAME}, __LINE__);
516516 $db->db_disconnect($dbh);
517517 return {
518518 n => scalar(@rows),
519519 first_id => scalar(@rows) ? ($rows[0]->{id} + 0) : 0,
520520 };
521521}
522522
523523# Banner shown across the top of every authenticated page WHILE an
524524# admin is impersonating another user. The "Stop" link is a POST to
525525# /admin_action.cgi (form, not GET) so accidental link-followers can't
526526# end an impersonation by sharing a URL.
527527#---------------------------------------------------------------------
528528# Trial banner. Renders a thin strip across every authenticated page
529529# when the user is on a 14-day trial:
530530# - active trial -> cool blue strip "N days left in your trial"
531531# - expired trial -> red strip "Trial ended -- add a payment method"
532532# Suppressed entirely when trial_ends_at IS NULL (user has converted
533533# OR was provisioned without a trial -- e.g., team members).
534534#---------------------------------------------------------------------
535535sub _trial_banner {
536536 my ($userinfo) = @_;
537537 return '' unless $userinfo && $userinfo->{user_id};
538538
539539 require MODS::DBConnect;
540540 require MODS::ContactForge::Caps;
541541 require MODS::ContactForge::Config;
542542 my $cfg = MODS::ContactForge::Config->new;
543543 my $DB = $cfg->settings('database_name');
544544 my $db = MODS::DBConnect->new;
545545 my $dbh = eval { $db->db_connect() };
546546 return '' unless $dbh;
547547
548548 my $caps = MODS::ContactForge::Caps->new;
549549 my $ts = eval { $caps->trial_state($db, $dbh, $DB, $userinfo->{user_id}) };
550550 eval { $db->db_disconnect($dbh) };
551551 return '' unless $ts && $ts->{state};
552552 return '' if $ts->{state} eq 'none';
553553
554554 if ($ts->{state} eq 'active') {
555555 my $d = $ts->{days_left};
556556 my $pl = $d == 1 ? 'day' : 'days';
557557 return qq~<div style="background:#0c1f33;border-bottom:1px solid #1e3a5f;color:#7dd3fc;padding:10px 18px;font-size:13px;text-align:center">
558558 <strong>$d $pl left in your free trial.</strong> <a href="/billing.cgi" style="color:#bae6fd;text-decoration:underline">Add a payment method</a> to keep going past day 14.
559559</div>~;
560560 }
561561 # expired
562562 return qq~<div style="background:#2a0e0f;border-bottom:1px solid #7f1d1d;color:#fca5a5;padding:12px 18px;font-size:13px;text-align:center">
563563 <strong>Your free trial has ended.</strong> Adding contacts, sending campaigns, and inviting teammates are paused. <a href="/billing.cgi" style="color:#fda4af;text-decoration:underline">Add a payment method</a> to reactivate your account.
564564</div>~;
565565}
566566
567567#---------------------------------------------------------------------
568568# Cap warning banner. Renders ONE thin amber strip across the top of
569569# every authenticated page when the user is past 80% on any tracked
570570# resource (contacts / seats / sends). Pro users see "upgrade to
571571# Business" CTA; Business users see "you'll see overage on next invoice".
572572# Suppressed once usage drops back under 80%, so it's a self-clearing
573573# nudge -- never a hard wall.
574574#---------------------------------------------------------------------
575575sub _cap_warn_banner {
576576 my ($userinfo) = @_;
577577 return '' unless $userinfo && $userinfo->{user_id};
578578
579579 require MODS::DBConnect;
580580 require MODS::ContactForge::Caps;
581581 require MODS::ContactForge::Config;
582582 my $cfg = MODS::ContactForge::Config->new;
583583 my $DB = $cfg->settings('database_name');
584584 my $db = MODS::DBConnect->new;
585585 my $dbh = eval { $db->db_connect() };
586586 return '' unless $dbh;
587587
588588 my $caps = MODS::ContactForge::Caps->new;
589589 my @hits;
590590 foreach my $r (qw(contacts seats sends)) {
591591 my $v = eval { $caps->verdict($db, $dbh, $DB, $userinfo->{user_id}, $r) };
592592 next unless $v && ($v->{warn} || $v->{over});
593593 push @hits, $v;
594594 }
595595 eval { $db->db_disconnect($dbh) };
596596 return '' unless @hits;
597597
598598 # Pick the "most pressing" hit (highest percent_of_cap).
599599 my ($worst) = sort { ($b->{percent_of_cap}||0) <=> ($a->{percent_of_cap}||0) } @hits;
600600 my $rlabel = {
601601 contacts => 'contacts',
602602 seats => 'team seats',
603603 sends => 'campaign sends this month',
604604 }->{ $worst->{resource} } || $worst->{resource};
605605
606606 my ($used_f, $cap_f) = ($worst->{used}, $worst->{cap});
607607 1 while $used_f =~ s/^(-?\d+)(\d{3})/$1,$2/;
608608 1 while $cap_f =~ s/^(-?\d+)(\d{3})/$1,$2/;
609609
610610 my ($icon, $bg, $border, $text, $msg);
611611 if ($worst->{over}) {
612612 if ($worst->{overage_allowed}) {
613613 $bg = '#1f2308'; $border = '#92740e'; $text = '#facc15';
614614 $msg = "You're over the $worst->{plan_name} $rlabel cap ($used_f / $cap_f). Overage at your plan's rates will show on the next invoice.";
615615 } else {
616616 $bg = '#2a0e0f'; $border = '#7f1d1d'; $text = '#fca5a5';
617617 $msg = "You've hit the $worst->{plan_name} $rlabel cap ($used_f / $cap_f). <a href=\"/billing.cgi\" style=\"color:#fda4af;text-decoration:underline\">Upgrade to Business</a> to keep growing.";
618618 }
619619 } else {
620620 # Warn (80-99%)
621621 $bg = '#1f2308'; $border = '#92740e'; $text = '#facc15';
622622 my $pct = $worst->{percent_of_cap} || 0;
623623 if ($worst->{overage_allowed}) {
624624 $msg = "Heads up: you're at ${pct}% of your $worst->{plan_name} $rlabel cap ($used_f / $cap_f). Going over is fine -- overage will bill at your plan's rate.";
625625 } else {
626626 $msg = "Heads up: you're at ${pct}% of your $worst->{plan_name} $rlabel cap ($used_f / $cap_f). <a href=\"/billing.cgi\" style=\"color:#fde68a;text-decoration:underline\">Upgrade to Business</a> before you hit the wall.";
627627 }
628628 }
629629
630630 return qq~<div style="background:$bg;border-bottom:1px solid $border;color:$text;padding:10px 18px;font-size:13px;text-align:center">
631631 <strong>$msg</strong>
632632</div>~;
633633}
634634
635635sub _impersonation_banner {
636636 my ($userinfo) = @_;
637637 return '' unless $userinfo && $userinfo->{_impersonating};
638638
639639 my $target_email = _html_escape($userinfo->{email} || '');
640640 my $target_name = _html_escape($userinfo->{display_name} || $target_email);
641641 my $admin_email = _html_escape($userinfo->{_admin_email} || '');
642642
643643 return qq~
644644<div class="impersonation-banner">
645645 <div class="impersonation-banner-msg">
646646 <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>
647647 <span><strong>Acting as</strong> $target_name &lt;$target_email&gt; &middot; admin: $admin_email</span>
648648 </div>
649649 <form method="POST" action="/admin_action.cgi" class="impersonation-banner-form">
650650 <input type="hidden" name="act" value="stop_impersonation">
651651 <button type="submit" class="impersonation-banner-stop">Stop &amp; return to admin</button>
652652 </form>
653653</div>
654654~;
655655}
656656
657657sub _html_escape {
658658 my ($s) = @_;
659659 return '' unless defined $s;
660660 $s =~ s/&/&amp;/g;
661661 $s =~ s/</&lt;/g;
662662 $s =~ s/>/&gt;/g;
663663 $s =~ s/"/&quot;/g;
664664 $s =~ s/'/&#39;/g;
665665 return $s;
666666}
667667
668668# Inline SVG icon library — keeps the wrapper independent of any
669669# external icon font / file. All icons are 24x24, stroke-based.
670670my %ICONS = (
671671 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>',
672672 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>',
673673 companies => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18"/><path d="M5 21V7l8-4v18"/><path d="M19 21V11l-6-4"/><path d="M9 9v.01"/><path d="M9 12v.01"/><path d="M9 15v.01"/><path d="M9 18v.01"/></svg>',
674674 deals => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>',
675675 activities => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
676676 tasks => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>',
677677 campaigns => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 11l18-8-3 18-7-7-8-3z"/><path d="M11 13L21 3"/></svg>',
678678 automations => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4"/><path d="M12 18v4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/><circle cx="12" cy="12" r="4"/></svg>',
679679 tags => '<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>',
680680 documents => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>',
681681 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>',
682682 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>',
683683 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>',
684684 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>',
685685 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>',
686686 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>',
687687 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>',
688688 reports => '<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>',
689689 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>',
690690 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>',
691691 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>',
692692 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>',
693693 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>',
694694 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>',
695695 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>',
696696 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>',
697697 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>',
698698 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>',
699699 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>',
700700 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>',
701701 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>',
702702);
703703
704704sub _icon {
705705 my ($name) = @_;
706706 return $ICONS{$name} || '';
707707}
708708
709709# Build the <meta> block injected into every page's <head>. Pulls
710710# platform defaults from platform_settings, and if the caller passed
711711# opts->{dashboard} (e.g. /store.cgi), layers the per-dashboard SEO
712712# row over the top. Cached per-request via $_meta_cache so repeated
713713# calls in one CGI don't re-query.
714714our $_meta_cache;
715715sub _build_meta_tags {
716716 my ($opts) = @_;
717717 return '' unless $opts;
718718
719719 require MODS::DBConnect;
720720 require MODS::ContactForge::SEO;
721721 my $db = MODS::DBConnect->new;
722722 my $seo = MODS::ContactForge::SEO->new;
723723 my $DB = $config->settings('database_name');
724724 my $dbh = $db->db_connect();
725725 return '' unless $dbh;
726726
727727 my $platform = $seo->get_platform_settings($db, $dbh, $DB);
728728 my $store = $opts->{dashboard} || {};
729729
730730 my $tags = $seo->render_tags(
731731 platform => $platform,
732732 dashboard => $store,
733733 page_title => $opts->{title} || '',
734734 request_url => $opts->{canonical_url} || '',
735735 );
736736
737737 $db->db_disconnect($dbh);
738738 return $tags;
739739}
740740
7417411;