Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/MODS/ContactForge/Wrapper.pm
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
to e9ae3876468f
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | package MODS::ContactForge::Wrapper; |
| 2 | 2 | #====================================================================== |
| 3 | 3 | # ContactForge — page wrapper. |
| 4 | 4 | # |
| 5 | 5 | # Renders the sidebar + topbar shell around a page's main content. |
| 6 | 6 | # Uses MODS::Template for the shell template (cf_wrapper.html |
| 7 | 7 | # for logged-in pages, cf_marketing.html for logged-out). |
| 8 | 8 | # |
| 9 | 9 | # Caller pattern: |
| 10 | 10 | # |
| 11 | 11 | # my $body = qq~ ...page body html... ~; |
| 12 | 12 | # $wrap->render({ |
| 13 | 13 | # userinfo => $userinfo, # required for logged-in shell |
| 14 | 14 | # page_key => 'dashboard', # marks the active sidebar item |
| 15 | 15 | # title => 'Dashboard', |
| 16 | 16 | # body => $body, |
| 17 | 17 | # extra_js => ['/assets/javascript/graphs.js'], # optional |
| 18 | 18 | # }); |
| 19 | 19 | #====================================================================== |
| 20 | 20 | |
| 21 | 21 | use strict; |
| 22 | 22 | use warnings; |
| 23 | 23 | |
| 24 | 24 | use MODS::Template; |
| 25 | 25 | use MODS::DBConnect; |
| 26 | 26 | use MODS::ContactForge::Config; |
| 27 | 27 | |
| 28 | 28 | my $tfile = MODS::Template->new; |
| 29 | 29 | my $config = MODS::ContactForge::Config->new; |
| 30 | 30 | my $db = MODS::DBConnect->new; |
| 31 | 31 | |
| 32 | 32 | # Sidebar nav: section headers + entries. Each entry has a key |
| 33 | 33 | # matched against $page_key to set "active", an icon name (resolved |
| 34 | 34 | # to inline SVG below), label, href, and optional badge. |
| 35 | 35 | my @SIDEBAR = ( |
| 36 | 36 | # Sidebar mirrors the profile dropdown in cf_wrapper.html -- |
| 37 | 37 | # same sections, same wording, same hrefs, same ordering so the |
| 38 | 38 | # two menus read as one family. Groups: WORKSPACE > CRM > GROWTH |
| 39 | 39 | # > DATA > ACCOUNT > ADMIN (admin-only). |
| 40 | 40 | # |
| 41 | 41 | # Per-row gating attrs (all optional): |
| 42 | 42 | # feature = permission_features.slug -- hide row from team |
| 43 | 43 | # members who lack the slug. Owners always pass. |
| 44 | 44 | # owner_only = 1 -- only the account owner (no owner_user_id) sees |
| 45 | 45 | # this row. Team members never see it regardless of role. |
| 46 | 46 | # Used for billing + team management + "danger zone" entries. |
| 47 | 47 | # admin_only = 1 -- hides from non-admin users entirely. |
| 48 | 48 | { section => 'Workspace' }, |
| 49 | 49 | { key => 'dashboard', label => 'Dashboard', href => '/dashboard.cgi', icon => 'dashboard' }, |
| 50 | 50 | { key => 'calendar', label => 'Calendar', href => '/calendar.cgi', icon => 'calendar' }, |
| 51 | 51 | |
| 52 | 52 | { section => 'CRM' }, |
| 53 | 53 | { key => 'contacts', label => 'Contacts', href => '/contacts.cgi', icon => 'team', feature => 'view_contacts' }, |
| 54 | 54 | { key => 'companies', label => 'Companies', href => '/companies.cgi', icon => 'companies', feature => 'view_contacts' }, |
| 55 | 55 | { key => 'deals', label => 'Deals', href => '/deals.cgi', icon => 'deals', feature => 'view_deals' }, |
| 56 | 56 | { key => 'activities', label => 'Activities', href => '/activities.cgi', icon => 'activities', feature => 'view_activities' }, |
| 57 | 57 | { key => 'tasks', label => 'Tasks', href => '/tasks.cgi', icon => 'tasks', feature => 'view_tasks' }, |
| 58 | 58 | |
| 59 | 59 | { section => 'Growth' }, |
| 60 | 60 | { key => 'campaigns', label => 'Campaigns', href => '/campaigns.cgi', icon => 'campaigns', feature => 'view_campaigns' }, |
| 61 | 61 | { key => 'automations', label => 'Automations', href => '/automations.cgi', icon => 'automations', feature => 'view_automations' }, |
| 62 | 62 | { key => 'segments', label => 'Segments', href => '/segments.cgi', icon => 'audience', feature => 'view_segments' }, |
| 63 | 63 | { key => 'tags', label => 'Tags', href => '/tags.cgi', icon => 'tags', feature => 'view_tags' }, |
| 64 | 64 | |
| 65 | 65 | { section => 'Analytics' }, |
| 66 | 66 | { key => 'reports', label => 'Reports', href => '/reports.cgi', icon => 'analytics', feature => 'view_reports' }, |
| 67 | 67 | { key => 'documents', label => 'Documents', href => '/documents.cgi', icon => 'documents', feature => 'view_documents' }, |
| 68 | 68 | |
| 69 | 69 | { section => 'Account' }, |
| 70 | 70 | { key => 'my_profile', label => 'My Profile', href => '/profile.cgi', icon => 'profile' }, |
| 71 | 71 | { key => 'preferences', label => 'Preferences', href => '/preferences.cgi', icon => 'settings' }, |
| 72 | 72 | { key => 'email_smtp', label => 'Email Provider', href => '/email_settings.cgi', icon => 'settings' }, |
| 73 | 73 | { key => 'billing', label => 'Billing & Plan', href => '/billing.cgi', icon => 'billing', feature => 'manage_billing' }, |
| 74 | 74 | { key => 'team', label => 'Team', href => '/team.cgi', icon => 'team', owner_only => 1 }, |
| 75 | 75 | |
| 76 | 76 | { section => 'Integrations' }, |
| 77 | 77 | { key => 'integrations', label => 'Integrations', href => '/integrations.cgi', icon => 'campaigns' }, |
| 78 | 78 | { key => 'import', label => 'Import', href => '/import.cgi', icon => 'admin' }, |
| 79 | 79 | { key => 'forms', label => 'Lead Forms', href => '/forms.cgi', icon => 'campaigns' }, |
| 80 | 80 | { key => 'api_docs', label => 'API Docs', href => '/api_docs.cgi', icon => 'team' }, |
| 81 | 81 | { key => 'api_keys', label => 'API Keys', href => '/api_keys.cgi', icon => 'team' }, |
| 82 | 82 | |
| 83 | 83 | { section => 'Admin', admin_only => 1 }, |
| 84 | 84 | { 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.' }, |
| 85 | 85 | { key => 'admin_funnels', label => 'Conversion Funnel', href => '/admin_funnels.cgi', icon => 'funnel', |
| 86 | 86 | admin_only => 1 }, |
| 87 | 87 | { key => 'admin_visitors', label => 'Live Visitors', href => '/admin_visitors.cgi', icon => 'admin', |
| 88 | 88 | admin_only => 1 }, |
| 89 | 89 | { key => 'admin_traffic', label => 'Traffic Reports', href => '/admin_traffic.cgi', icon => 'reports', |
| 90 | 90 | admin_only => 1 }, |
| 91 | 91 | # Heatmaps lives as a tab inside /admin_visitors.cgi (no separate |
| 92 | 92 | # sidebar entry). /admin_heatmap.cgi is still reachable at its direct |
| 93 | 93 | # URL and renders the same tab nav with Heatmaps highlighted. |
| 94 | 94 | { key => 'admin_messages', label => 'Support inbox', href => '/admin_messages.cgi', icon => 'messages', |
| 95 | 95 | admin_only => 1, feature => 'admin_view_messages' }, { key => 'admin_companies', label => 'All Companies', href => '/admin_companies.cgi', icon => 'building', admin_only => 1 }, |
| 96 | 96 | |
| 97 | 97 | { key => 'admin_users', label => 'All Users', href => '/admin_users.cgi', icon => 'admin', |
| 98 | 98 | admin_only => 1, feature => 'admin_view_users' }, |
| 99 | 99 | |
| 100 | 100 | { key => 'admin_teams', label => 'Team Members', href => '/admin_teams.cgi', icon => 'team', |
| 101 | 101 | admin_only => 1 }, |
| 102 | 102 | { key => 'admin_billing', label => 'User Billing', href => '/admin_billing.cgi', icon => 'billing', |
| 103 | 103 | admin_only => 1, feature => 'admin_view_billing' }, |
| 104 | 104 | # Pricing & Promotions is one sidebar entry covering BOTH the |
| 105 | 105 | # billing-packages catalogue and the platform-wide promotions list. |
| 106 | 106 | # The two pages share a tab nav at the top so the user can switch |
| 107 | 107 | # between them; both set page_key='admin_packages' so this entry |
| 108 | 108 | # stays highlighted across either URL. |
| 109 | 109 | { key => 'admin_packages', label => 'Pricing & Promotions', href => '/admin_packages.cgi', icon => 'billing', |
| 110 | 110 | admin_only => 1, super_admin_only => 1 }, |
| 111 | 111 | { key => 'admin_seo', label => 'SEO', href => '/admin_seo.cgi', icon => 'seo', |
| 112 | 112 | admin_only => 1, feature => 'admin_view_seo' }, |
| 113 | 113 | { key => 'admin_chat', label => 'Chat', href => '/admin_chat.cgi', icon => 'chat', |
| 114 | 114 | admin_only => 1, feature => 'admin_chat' }, # Sandbox CCs for QA. Super-admin-only because a misused test card |
| 115 | 115 | # could be assigned to a real rep's account and silently |
| 116 | 116 | # "approve" their next charge. Staff with manage-billing get to see |
| 117 | 117 | # tests it; only the platform owner gets to create them. |
| 118 | 118 | { key => 'admin_test_cards', label => 'Test Credit Cards', href => '/admin_test_cards.cgi', icon => 'card', |
| 119 | 119 | admin_only => 1, super_admin_only => 1 }, |
| 120 | 120 | { key => 'email_setup', label => 'Email Setup', href => '/admin_emails.cgi', icon => 'messages', admin_only => 1, super_admin_only => 1 }, |
| 121 | 121 | { key => 'admin_tax', label => 'Tax', href => '/admin_tax.cgi', icon => 'billing', |
| 122 | 122 | admin_only => 1, super_admin_only => 1 }, |
| 123 | 123 | # Platform-owner-only: edits Stripe keys / branding / email and |
| 124 | 124 | # other DB-backed settings. Regular admins do NOT see this row. |
| 125 | 125 | { 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', |
| 126 | 126 | admin_only => 1, super_admin_only => 1 }, |
| 127 | 127 | ); |
| 128 | 128 | |
| 129 | 129 | sub new { |
| 130 | 130 | my ($class) = @_; |
| 131 | 131 | return bless({}, $class); |
| 132 | 132 | } |
| 133 | 133 | |
| 134 | 134 | #---------------------------------------------------------------------- |
| 135 | 135 | # render(\%opts) — print the full page (Content-Type already sent |
| 136 | 136 | # by the calling .cgi). |
| 137 | 137 | #---------------------------------------------------------------------- |
| 138 | 138 | sub render { |
| 139 | 139 | my ($self, $opts) = @_; |
| 140 | 140 | $opts ||= {}; |
| 141 | 141 | |
| 142 | 142 | my $userinfo = $opts->{userinfo}; |
| 143 | 143 | my $logged_in = $userinfo && $userinfo->{user_id} ? 1 : 0; |
| 144 | 144 | |
| 145 | 145 | my $tvars = { |
| 146 | 146 | website_title => $config->settings('website_title'), |
| 147 | 147 | brand_name => $config->settings('brand_name'), |
| 148 | 148 | brand_tagline => $config->settings('brand_tagline'), |
| 149 | 149 | assets => $config->settings('assets'), |
| 150 | 150 | assets_css => $config->settings('assets_css'), |
| 151 | 151 | assets_js => $config->settings('assets_js'), |
| 152 | 152 | title => $opts->{title} || 'ContactForge', |
| 153 | 153 | body => $opts->{body} || '', |
| 154 | 154 | extra_js => _format_scripts($opts->{extra_js}), |
| 155 | 155 | extra_css => _format_styles($opts->{extra_css}), |
| 156 | 156 | # SEO meta block (built below). Renders into cf_wrapper.html |
| 157 | 157 | # / cf_marketing.html at $meta_tags so every page emits a |
| 158 | 158 | # consistent <meta> set without each CGI having to know. |
| 159 | 159 | meta_tags => _build_meta_tags($opts), |
| 160 | 160 | }; |
| 161 | 161 | |
| 162 | 162 | if ($logged_in) { |
| 163 | 163 | $tvars->{user_id} = $userinfo->{user_id}; |
| 164 | 164 | $tvars->{display_name} = $userinfo->{display_name}; |
| 165 | 165 | $tvars->{email} = $userinfo->{email}; |
| 166 | 166 | $tvars->{plan_tier} = ucfirst($userinfo->{plan_tier} || 'free'); |
| 167 | 167 | $tvars->{initials} = _initials($userinfo->{display_name} || $userinfo->{email}); |
| 168 | 168 | |
| 169 | 169 | my $is_admin = $userinfo->{is_admin} || $userinfo->{_admin_is_admin} || 0; |
| 170 | 170 | $tvars->{is_admin} = $is_admin; |
| 171 | 171 | # Unread support-thread count drives the red dot on the topbar |
| 172 | 172 | # Support inbox pill (admins only). Reps have no inbox surface, |
| 173 | 173 | # so the count is suppressed and the pill is hidden client-side. |
| 174 | 174 | my $unread = $is_admin ? _unread_messages_count($userinfo->{user_id}, $is_admin) : 0; |
| 175 | 175 | $tvars->{unread_messages_count} = $unread; |
| 176 | 176 | $tvars->{has_unread_messages} = $unread > 0 ? 1 : 0; |
| 177 | 177 | # Topbar Support-inbox pill lands at the admin console. |
| 178 | 178 | $tvars->{messages_href} = '/admin_messages.cgi'; |
| 179 | 179 | # Live chats awaiting an admin reply. Drives the topbar pill |
| 180 | 180 | # so admins can't miss a visitor mid-conversation no matter |
| 181 | 181 | # what page they're on. Pill always renders for admins; the |
| 182 | 182 | # idle (blue) vs waiting (green/pulsing) state is gated on |
| 183 | 183 | # count. Clicking the waiting pill jumps straight to the |
| 184 | 184 | # longest-waiting chat so admins skip the console list. |
| 185 | 185 | my $chat_data = $is_admin ? _pending_chats_data() : { n => 0, first_id => 0 }; |
| 186 | 186 | $tvars->{admin_chats_pending} = $chat_data->{n}; |
| 187 | 187 | $tvars->{has_admin_chats_pending} = $chat_data->{n} > 0 ? 1 : 0; |
| 188 | 188 | $tvars->{admin_chats_first_id} = $chat_data->{first_id} || 0; |
| 189 | 189 | $tvars->{admin_chats_href} = $chat_data->{first_id} |
| 190 | 190 | ? "/admin_chat.cgi?chat=$chat_data->{first_id}" |
| 191 | 191 | : '/admin_chat.cgi'; |
| 192 | 192 | $tvars->{sidebar} = _build_sidebar($opts->{page_key} || '', $is_admin, $unread, $userinfo); |
| 193 | 193 | $tvars->{dropdown_links} = _build_dropdown_links($is_admin, $unread, $userinfo); |
| 194 | 194 | $tvars->{impersonation_banner} = _impersonation_banner($userinfo); |
| 195 | 195 | $tvars->{trial_banner} = _trial_banner($userinfo); |
| 196 | 196 | $tvars->{cap_warn_banner} = _cap_warn_banner($userinfo); |
| 197 | 197 | |
| 198 | 198 | # Per-user sidebar pref: applied as a class on <html> so the |
| 199 | 199 | # final layout is in place on first paint (no flash). Guarded |
| 200 | 200 | # against the user_settings.sidebar_collapsed column not having |
| 201 | 201 | # been added yet -- a missing-column SELECT would 500 the page. |
| 202 | 202 | my ($collapsed, $mobile_open) = _sidebar_pref($userinfo->{user_id}); |
| 203 | 203 | my @cls; |
| 204 | 204 | push @cls, 'sidebar-collapsed' if $collapsed; |
| 205 | 205 | push @cls, 'mobile-nav-open' if $mobile_open; |
| 206 | 206 | $tvars->{html_class} = join(' ', @cls); |
| 207 | 207 | } |
| 208 | 208 | |
| 209 | 209 | # Heatmap-view mode: when /admin_heatmap.cgi loads a page in its |
| 210 | 210 | # preview iframe it appends `heatmap_view=1` to the URL. We render |
| 211 | 211 | # that request as if the visitor were logged OUT so the DOM |
| 212 | 212 | # matches exactly what a real visitor saw -- otherwise the admin |
| 213 | 213 | # session would inject the studio sidebar wrapper and every stored |
| 214 | 214 | # selector would resolve to a different element. We also suppress |
| 215 | 215 | # track.js so the admin's preview clicks don't pollute analytics. |
| 216 | 216 | my $heatmap_view = ($ENV{QUERY_STRING} || '') =~ /(?:^|&)heatmap_view=1(?:&|$)/ ? 1 : 0; |
| 217 | 217 | $tvars->{heatmap_view} = $heatmap_view; |
| 218 | 218 | |
| 219 | 219 | # Tutorial-embed mode: a [frame:/url] block inside a tutorial body |
| 220 | 220 | # loads the target URL in an iframe with ?tut_embed=1 appended. We |
| 221 | 221 | # strip the sidebar/topbar (same as heatmap_view does) so the |
| 222 | 222 | # iframe shows just the page content, not the whole studio shell. |
| 223 | 223 | my $tut_embed = ($ENV{QUERY_STRING} || '') =~ /(?:^|&)tut_embed=1(?:&|$)/ ? 1 : 0; |
| 224 | 224 | $tvars->{tut_embed} = $tut_embed; |
| 225 | 225 | |
| 226 | 226 | my $template_file = ($logged_in && !$heatmap_view && !$tut_embed) |
| 227 | 227 | ? 'cf_wrapper.html' |
| 228 | 228 | : 'cf_marketing.html'; |
| 229 | 229 | |
| 230 | 230 | my @html = $tfile->template($template_file, $tvars, $userinfo); |
| 231 | 231 | print "@html"; |
| 232 | 232 | return; |
| 233 | 233 | } |
| 234 | 234 | |
| 235 | 235 | #====================================================================== |
| 236 | 236 | # Helpers |
| 237 | 237 | #====================================================================== |
| 238 | 238 | |
| 239 | 239 | sub _initials { |
| 240 | 240 | my ($name) = @_; |
| 241 | 241 | return '?' unless $name; |
| 242 | 242 | my @parts = split /[\s\@\.]+/, $name; |
| 243 | 243 | my $i = ''; |
| 244 | 244 | $i .= uc(substr($parts[0], 0, 1)) if $parts[0]; |
| 245 | 245 | $i .= uc(substr($parts[1], 0, 1)) if $parts[1]; |
| 246 | 246 | return $i || uc(substr($name, 0, 1)); |
| 247 | 247 | } |
| 248 | 248 | |
| 249 | 249 | sub _format_scripts { |
| 250 | 250 | my ($list) = @_; |
| 251 | 251 | return '' unless $list && ref($list) eq 'ARRAY'; |
| 252 | 252 | return join("\n", map { qq~<script src="$_"></script>~ } @$list); |
| 253 | 253 | } |
| 254 | 254 | |
| 255 | 255 | sub _format_styles { |
| 256 | 256 | my ($list) = @_; |
| 257 | 257 | return '' unless $list && ref($list) eq 'ARRAY'; |
| 258 | 258 | return join("\n", map { qq~<link rel="stylesheet" href="$_">~ } @$list); |
| 259 | 259 | } |
| 260 | 260 | |
| 261 | 261 | # Cache the column-existence check so we don't run information_schema |
| 262 | 262 | # on every page load. Under plain CGI this only saves work within a |
| 263 | 263 | # single request; under FCGI/mod_perl it survives across requests. |
| 264 | 264 | our $_sidebar_col_checked; |
| 265 | 265 | our $_sidebar_col_exists; |
| 266 | 266 | |
| 267 | 267 | # Returns ($collapsed, $mobile_open) for the user's sidebar prefs. |
| 268 | 268 | # Both default to 0 (expanded desktop, closed mobile drawer) when no |
| 269 | 269 | # row exists or the migration columns are missing. sidebar_mobile_open |
| 270 | 270 | # was added in a later migration so we guard for its absence. |
| 271 | 271 | sub _sidebar_pref { |
| 272 | 272 | my ($user_id) = @_; |
| 273 | 273 | return (0, 0) unless $user_id; |
| 274 | 274 | $user_id =~ s/[^0-9]//g; |
| 275 | 275 | return (0, 0) unless $user_id; |
| 276 | 276 | |
| 277 | 277 | my $DB = $config->settings('database_name'); |
| 278 | 278 | my $dbh = $db->db_connect(); |
| 279 | 279 | return (0, 0) unless $dbh; |
| 280 | 280 | |
| 281 | 281 | unless ($_sidebar_col_checked) { |
| 282 | 282 | my $r = $db->db_readwrite($dbh, qq~ |
| 283 | 283 | SELECT COUNT(*) AS n FROM information_schema.columns |
| 284 | 284 | WHERE table_schema='$DB' |
| 285 | 285 | AND table_name='user_settings' |
| 286 | 286 | AND column_name='sidebar_collapsed' |
| 287 | 287 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 288 | 288 | $_sidebar_col_exists = ($r && $r->{n}) ? 1 : 0; |
| 289 | 289 | $_sidebar_col_checked = 1; |
| 290 | 290 | } |
| 291 | 291 | |
| 292 | 292 | unless ($_sidebar_col_exists) { |
| 293 | 293 | $db->db_disconnect($dbh); |
| 294 | 294 | return (0, 0); |
| 295 | 295 | } |
| 296 | 296 | |
| 297 | 297 | # Probe the optional mobile column (cheap, cached per-request). |
| 298 | 298 | my $mob_check = $db->db_readwrite($dbh, qq~ |
| 299 | 299 | SELECT COUNT(*) AS n FROM information_schema.columns |
| 300 | 300 | WHERE table_schema='$DB' |
| 301 | 301 | AND table_name='user_settings' |
| 302 | 302 | AND column_name='sidebar_mobile_open' |
| 303 | 303 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 304 | 304 | my $mob_extra = ($mob_check && $mob_check->{n}) ? ', sidebar_mobile_open' : ''; |
| 305 | 305 | |
| 306 | 306 | my $r = $db->db_readwrite($dbh, qq~ |
| 307 | 307 | SELECT sidebar_collapsed$mob_extra |
| 308 | 308 | FROM `${DB}`.user_settings |
| 309 | 309 | WHERE user_id='$user_id' |
| 310 | 310 | LIMIT 1 |
| 311 | 311 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 312 | 312 | $db->db_disconnect($dbh); |
| 313 | 313 | |
| 314 | 314 | my $collapsed = ($r && $r->{sidebar_collapsed}) ? 1 : 0; |
| 315 | 315 | my $mobile_open = ($r && $r->{sidebar_mobile_open}) ? 1 : 0; |
| 316 | 316 | return ($collapsed, $mobile_open); |
| 317 | 317 | } |
| 318 | 318 | |
| 319 | 319 | |
| 320 | 320 | sub _build_sidebar { |
| 321 | 321 | my ($active_key, $is_admin, $unread_messages, $userinfo) = @_; |
| 322 | 322 | $unread_messages ||= 0; |
| 323 | 323 | my $html = ''; |
| 324 | 324 | my $pending_section; # buffered header; emitted when a child row gets through |
| 325 | 325 | my $is_team_member = ($userinfo && $userinfo->{owner_user_id}) ? 1 : 0; |
| 326 | 326 | |
| 327 | 327 | foreach my $row (@SIDEBAR) { |
| 328 | 328 | # Admin entries (links AND section headers) are gated -- reps |
| 329 | 329 | # must never see them. Hide the rows entirely (not just disable) |
| 330 | 330 | # so the page keys do not leak in HTML for non-admins to find. |
| 331 | 331 | next if $row->{admin_only} && !$is_admin; |
| 332 | 332 | |
| 333 | 333 | if ($row->{section}) { |
| 334 | 334 | # Buffer the header; we only commit it once a visible child |
| 335 | 335 | # follows. Prevents dangling "Reporting" labels when every |
| 336 | 336 | # child is hidden by role. |
| 337 | 337 | $pending_section = $row->{section}; |
| 338 | 338 | next; |
| 339 | 339 | } |
| 340 | 340 | |
| 341 | 341 | # owner_only rows are visible only to the actual account owner |
| 342 | 342 | # (billing, team management, ...). Team members and impersonated |
| 343 | 343 | # admins are blocked here so the menu cannot leak them. |
| 344 | 344 | next if $row->{owner_only} && $is_team_member; |
| 345 | 345 | |
| 346 | 346 | # super_admin_only rows are visible only to the platform owner |
| 347 | 347 | # (users.is_super_admin=1). Regular admins managing users / billing |
| 348 | 348 | # still see all the other admin rows but never this one. |
| 349 | 349 | if ($row->{super_admin_only}) { |
| 350 | 350 | require MODS::ContactForge::Permissions; |
| 351 | 351 | next unless MODS::ContactForge::Permissions->new->is_super_admin($userinfo); |
| 352 | 352 | } |
| 353 | 353 | |
| 354 | 354 | # feature gate: skip if user lacks the slug. _has_user_feature |
| 355 | 355 | # short-circuits for owners (NULL permission_groups_id). |
| 356 | 356 | next if $row->{feature} && !_has_user_feature($userinfo, $row->{feature}); |
| 357 | 357 | |
| 358 | 358 | if (defined $pending_section) { |
| 359 | 359 | $html .= qq~<div class="sidebar-section-label">$pending_section</div>\n~; |
| 360 | 360 | $pending_section = undef; |
| 361 | 361 | } |
| 362 | 362 | |
| 363 | 363 | my $cls = ($row->{key} eq $active_key) ? 'sidebar-link active' : 'sidebar-link'; |
| 364 | 364 | my $svg = _icon($row->{icon}); |
| 365 | 365 | my $badge = $row->{badge} ? qq~<span class="badge">$row->{badge}</span>~ : ''; |
| 366 | 366 | # Live unread dot on the Messages row only -- the dot is omitted |
| 367 | 367 | # when there are zero unread comments/reviews so the navigation |
| 368 | 368 | # never shows a permanent "alert" indicator. |
| 369 | 369 | my $unread_dot = ''; |
| 370 | 370 | if ($row->{key} && $row->{key} eq 'messages' && $unread_messages > 0) { |
| 371 | 371 | $unread_dot = qq~<span class="menu-dot" title="$unread_messages unread"></span>~; |
| 372 | 372 | } |
| 373 | 373 | # title="..." surfaces as a hover tooltip when the sidebar is |
| 374 | 374 | # collapsed to the icon rail (the CSS rule reads attr(title)). |
| 375 | 375 | $html .= qq~<a class="$cls" href="$row->{href}" title="$row->{label}">$svg<span>$row->{label}</span>$unread_dot$badge</a>\n~; |
| 376 | 376 | } |
| 377 | 377 | return $html; |
| 378 | 378 | } |
| 379 | 379 | |
| 380 | 380 | # Build the profile-dropdown links from the SAME @SIDEBAR data the |
| 381 | 381 | # left sidebar uses. This guarantees the two menus stay in lockstep |
| 382 | 382 | # whenever items are added / renamed / removed -- never hand-edit the |
| 383 | 383 | # dropdown HTML directly. Section headers become <div class="profile- |
| 384 | 384 | # dropdown-section">, divider rows are inserted before each section |
| 385 | 385 | # (except the first), and admin-only items get the .profile-admin |
| 386 | 386 | # class so they render in the admin red/orange accent like before. |
| 387 | 387 | sub _build_dropdown_links { |
| 388 | 388 | my ($is_admin, $unread_messages, $userinfo) = @_; |
| 389 | 389 | $unread_messages ||= 0; |
| 390 | 390 | my $html = ''; |
| 391 | 391 | my $emitted_first_section = 0; |
| 392 | 392 | my $pending_section; |
| 393 | 393 | my $pending_divider = 0; |
| 394 | 394 | my $is_team_member = ($userinfo && $userinfo->{owner_user_id}) ? 1 : 0; |
| 395 | 395 | |
| 396 | 396 | foreach my $row (@SIDEBAR) { |
| 397 | 397 | next if $row->{admin_only} && !$is_admin; |
| 398 | 398 | |
| 399 | 399 | if ($row->{section}) { |
| 400 | 400 | # Buffer the header + its divider; emit only when a child |
| 401 | 401 | # row gets through. Otherwise the dropdown shows lone |
| 402 | 402 | # "Reporting" headers with nothing under them. |
| 403 | 403 | $pending_section = $row->{section}; |
| 404 | 404 | $pending_divider = $emitted_first_section ? 1 : 0; |
| 405 | 405 | next; |
| 406 | 406 | } |
| 407 | 407 | |
| 408 | 408 | next if $row->{owner_only} && $is_team_member; |
| 409 | 409 | if ($row->{super_admin_only}) { |
| 410 | 410 | require MODS::ContactForge::Permissions; |
| 411 | 411 | next unless MODS::ContactForge::Permissions->new->is_super_admin($userinfo); |
| 412 | 412 | } |
| 413 | 413 | next if $row->{feature} && !_has_user_feature($userinfo, $row->{feature}); |
| 414 | 414 | |
| 415 | 415 | if (defined $pending_section) { |
| 416 | 416 | $html .= qq~<div class="profile-dropdown-divider"></div>\n~ if $pending_divider; |
| 417 | 417 | $html .= qq~<div class="profile-dropdown-section">$pending_section</div>\n~; |
| 418 | 418 | $emitted_first_section = 1; |
| 419 | 419 | $pending_section = undef; |
| 420 | 420 | $pending_divider = 0; |
| 421 | 421 | } |
| 422 | 422 | |
| 423 | 423 | my $svg = _icon($row->{icon}); |
| 424 | 424 | my $admin_c = $row->{admin_only} ? ' class="profile-admin"' : ''; |
| 425 | 425 | my $unread_dot = ''; |
| 426 | 426 | if ($row->{key} && $row->{key} eq 'messages' && $unread_messages > 0) { |
| 427 | 427 | $unread_dot = qq~<span class="menu-dot" style="margin-left:auto" title="$unread_messages unread"></span>~; |
| 428 | 428 | } |
| 429 | 429 | $html .= qq~<a href="$row->{href}"$admin_c><span class="dropdown-icon">$svg</span> $row->{label}$unread_dot</a>\n~; |
| 430 | 430 | } |
| 431 | 431 | return $html; |
| 432 | 432 | } |
| 433 | 433 | |
| 434 | 434 | # Thin wrapper around MODS::ContactForge::Permissions->has_feature so the |
| 435 | 435 | # sidebar builders stay decoupled from the module loader. Loaded once. |
| 436 | 436 | my $_perm_singleton; |
| 437 | 437 | sub _has_user_feature { |
| 438 | 438 | my ($userinfo, $slug) = @_; |
| 439 | 439 | return 1 unless $slug; |
| 440 | 440 | return 0 unless $userinfo; |
| 441 | 441 | # Owner / unconstrained: skip the DB trip |
| 442 | 442 | return 1 if !defined($userinfo->{permission_groups_id}) || $userinfo->{permission_groups_id} eq ''; |
| 443 | 443 | if (!$_perm_singleton) { |
| 444 | 444 | return 1 unless eval { require MODS::ContactForge::Permissions; 1 }; |
| 445 | 445 | $_perm_singleton = MODS::ContactForge::Permissions->new; |
| 446 | 446 | } |
| 447 | 447 | return $_perm_singleton->has_feature($userinfo, $slug); |
| 448 | 448 | } |
| 449 | 449 | |
| 450 | 450 | # Count rows that drive the unread indicator on the topbar Support- |
| 451 | 451 | # inbox pill. Admin-only surface; reps never see the pill so the |
| 452 | 452 | # caller is expected to skip this for non-admins. |
| 453 | 453 | sub _unread_messages_count { |
| 454 | 454 | my ($uid, $is_admin) = @_; |
| 455 | 455 | return 0 unless $uid; |
| 456 | 456 | $uid =~ s/[^0-9]//g; |
| 457 | 457 | return 0 unless $uid; |
| 458 | 458 | |
| 459 | 459 | require MODS::DBConnect; |
| 460 | 460 | my $db = MODS::DBConnect->new; |
| 461 | 461 | my $DB = $config->settings('database_name'); |
| 462 | 462 | my $dbh = $db->db_connect(); |
| 463 | 463 | return 0 unless $dbh; |
| 464 | 464 | |
| 465 | 465 | my $total = 0; |
| 466 | 466 | |
| 467 | 467 | # Support tickets / help threads. Admins count threads where |
| 468 | 468 | # admin_unread > 0 (anyone's ticket needing reply); regular |
| 469 | 469 | # users count their own threads where customer_unread > 0 (a |
| 470 | 470 | # reply from admin they haven't read yet). |
| 471 | 471 | my $have_st = $db->db_readwrite($dbh, qq~ |
| 472 | 472 | SELECT COUNT(*) AS n FROM information_schema.tables |
| 473 | 473 | WHERE table_schema='$DB' AND table_name='support_threads' |
| 474 | 474 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 475 | 475 | if ($have_st && $have_st->{n}) { |
| 476 | 476 | my $sql = $is_admin |
| 477 | 477 | ? qq~SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE admin_unread > 0~ |
| 478 | 478 | : qq~SELECT COUNT(*) AS n FROM `${DB}`.support_threads WHERE customer_user_id='$uid' AND customer_unread > 0~; |
| 479 | 479 | my $r = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); |
| 480 | 480 | $total += ($r && $r->{n}) ? $r->{n} : 0; |
| 481 | 481 | } |
| 482 | 482 | |
| 483 | 483 | $db->db_disconnect($dbh); |
| 484 | 484 | return $total; |
| 485 | 485 | } |
| 486 | 486 | |
| 487 | 487 | # Open visitor chats with at least one unread message for the admin. |
| 488 | 488 | # Drives both the topbar pill state (idle/waiting) and its click |
| 489 | 489 | # target (jump direct to the longest-waiting chat). Table existence |
| 490 | 490 | # is guarded via information_schema so pre-migration deploys can't |
| 491 | 491 | # 500 the wrapper. Returns { n => count, first_id => oldest_pending_chat_id }. |
| 492 | 492 | sub _pending_chats_data { |
| 493 | 493 | require MODS::DBConnect; |
| 494 | 494 | my $db = MODS::DBConnect->new; |
| 495 | 495 | my $DB = $config->settings('database_name'); |
| 496 | 496 | my $dbh = $db->db_connect(); |
| 497 | 497 | return { n => 0, first_id => 0 } unless $dbh; |
| 498 | 498 | my $have = $db->db_readwrite($dbh, qq~ |
| 499 | 499 | SELECT COUNT(*) AS n FROM information_schema.tables |
| 500 | 500 | WHERE table_schema='$DB' AND table_name='support_chats' |
| 501 | 501 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 502 | 502 | unless ($have && $have->{n}) { |
| 503 | 503 | $db->db_disconnect($dbh); |
| 504 | 504 | return { n => 0, first_id => 0 }; |
| 505 | 505 | } |
| 506 | 506 | # ORDER BY last_message_at ASC -- oldest unread first. Sending an |
| 507 | 507 | # admin to the chat that has been waiting longest is the most |
| 508 | 508 | # courteous default; if they want a different one they can use |
| 509 | 509 | # the console list as normal. |
| 510 | 510 | my @rows = $db->db_readwrite_multiple($dbh, qq~ |
| 511 | 511 | SELECT id FROM `${DB}`.support_chats |
| 512 | 512 | WHERE status = 'open' |
| 513 | 513 | AND unread_for_admin > 0 |
| 514 | 514 | ORDER BY last_message_at ASC |
| 515 | 515 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 516 | 516 | $db->db_disconnect($dbh); |
| 517 | 517 | return { |
| 518 | 518 | n => scalar(@rows), |
| 519 | 519 | first_id => scalar(@rows) ? ($rows[0]->{id} + 0) : 0, |
| 520 | 520 | }; |
| 521 | 521 | } |
| 522 | 522 | |
| 523 | 523 | # Banner shown across the top of every authenticated page WHILE an |
| 524 | 524 | # admin is impersonating another user. The "Stop" link is a POST to |
| 525 | 525 | # /admin_action.cgi (form, not GET) so accidental link-followers can't |
| 526 | 526 | # end an impersonation by sharing a URL. |
| 527 | 527 | #--------------------------------------------------------------------- |
| 528 | 528 | # Trial banner. Renders a thin strip across every authenticated page |
| 529 | 529 | # when the user is on a 14-day trial: |
| 530 | 530 | # - active trial -> cool blue strip "N days left in your trial" |
| 531 | 531 | # - expired trial -> red strip "Trial ended -- add a payment method" |
| 532 | 532 | # Suppressed entirely when trial_ends_at IS NULL (user has converted |
| 533 | 533 | # OR was provisioned without a trial -- e.g., team members). |
| 534 | 534 | #--------------------------------------------------------------------- |
| 535 | 535 | sub _trial_banner { |
| 536 | 536 | my ($userinfo) = @_; |
| 537 | 537 | return '' unless $userinfo && $userinfo->{user_id}; |
| 538 | 538 | |
| 539 | 539 | require MODS::DBConnect; |
| 540 | 540 | require MODS::ContactForge::Caps; |
| 541 | 541 | require MODS::ContactForge::Config; |
| 542 | 542 | my $cfg = MODS::ContactForge::Config->new; |
| 543 | 543 | my $DB = $cfg->settings('database_name'); |
| 544 | 544 | my $db = MODS::DBConnect->new; |
| 545 | 545 | my $dbh = eval { $db->db_connect() }; |
| 546 | 546 | return '' unless $dbh; |
| 547 | 547 | |
| 548 | 548 | my $caps = MODS::ContactForge::Caps->new; |
| 549 | 549 | my $ts = eval { $caps->trial_state($db, $dbh, $DB, $userinfo->{user_id}) }; |
| 550 | 550 | eval { $db->db_disconnect($dbh) }; |
| 551 | 551 | return '' unless $ts && $ts->{state}; |
| 552 | 552 | return '' if $ts->{state} eq 'none'; |
| 553 | 553 | |
| 554 | 554 | if ($ts->{state} eq 'active') { |
| 555 | 555 | my $d = $ts->{days_left}; |
| 556 | 556 | my $pl = $d == 1 ? 'day' : 'days'; |
| 557 | 557 | return qq~<div style="background:#0c1f33;border-bottom:1px solid #1e3a5f;color:#7dd3fc;padding:10px 18px;font-size:13px;text-align:center"> |
| 558 | 558 | <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. |
| 559 | 559 | </div>~; |
| 560 | 560 | } |
| 561 | 561 | # expired |
| 562 | 562 | return qq~<div style="background:#2a0e0f;border-bottom:1px solid #7f1d1d;color:#fca5a5;padding:12px 18px;font-size:13px;text-align:center"> |
| 563 | 563 | <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. |
| 564 | 564 | </div>~; |
| 565 | 565 | } |
| 566 | 566 | |
| 567 | 567 | #--------------------------------------------------------------------- |
| 568 | 568 | # Cap warning banner. Renders ONE thin amber strip across the top of |
| 569 | 569 | # every authenticated page when the user is past 80% on any tracked |
| 570 | 570 | # resource (contacts / seats / sends). Pro users see "upgrade to |
| 571 | 571 | # Business" CTA; Business users see "you'll see overage on next invoice". |
| 572 | 572 | # Suppressed once usage drops back under 80%, so it's a self-clearing |
| 573 | 573 | # nudge -- never a hard wall. |
| 574 | 574 | #--------------------------------------------------------------------- |
| 575 | 575 | sub _cap_warn_banner { |
| 576 | 576 | my ($userinfo) = @_; |
| 577 | 577 | return '' unless $userinfo && $userinfo->{user_id}; |
| 578 | 578 | |
| 579 | 579 | require MODS::DBConnect; |
| 580 | 580 | require MODS::ContactForge::Caps; |
| 581 | 581 | require MODS::ContactForge::Config; |
| 582 | 582 | my $cfg = MODS::ContactForge::Config->new; |
| 583 | 583 | my $DB = $cfg->settings('database_name'); |
| 584 | 584 | my $db = MODS::DBConnect->new; |
| 585 | 585 | my $dbh = eval { $db->db_connect() }; |
| 586 | 586 | return '' unless $dbh; |
| 587 | 587 | |
| 588 | 588 | my $caps = MODS::ContactForge::Caps->new; |
| 589 | 589 | my @hits; |
| 590 | 590 | foreach my $r (qw(contacts seats sends)) { |
| 591 | 591 | my $v = eval { $caps->verdict($db, $dbh, $DB, $userinfo->{user_id}, $r) }; |
| 592 | 592 | next unless $v && ($v->{warn} || $v->{over}); |
| 593 | 593 | push @hits, $v; |
| 594 | 594 | } |
| 595 | 595 | eval { $db->db_disconnect($dbh) }; |
| 596 | 596 | return '' unless @hits; |
| 597 | 597 | |
| 598 | 598 | # Pick the "most pressing" hit (highest percent_of_cap). |
| 599 | 599 | my ($worst) = sort { ($b->{percent_of_cap}||0) <=> ($a->{percent_of_cap}||0) } @hits; |
| 600 | 600 | my $rlabel = { |
| 601 | 601 | contacts => 'contacts', |
| 602 | 602 | seats => 'team seats', |
| 603 | 603 | sends => 'campaign sends this month', |
| 604 | 604 | }->{ $worst->{resource} } || $worst->{resource}; |
| 605 | 605 | |
| 606 | 606 | my ($used_f, $cap_f) = ($worst->{used}, $worst->{cap}); |
| 607 | 607 | 1 while $used_f =~ s/^(-?\d+)(\d{3})/$1,$2/; |
| 608 | 608 | 1 while $cap_f =~ s/^(-?\d+)(\d{3})/$1,$2/; |
| 609 | 609 | |
| 610 | 610 | my ($icon, $bg, $border, $text, $msg); |
| 611 | 611 | if ($worst->{over}) { |
| 612 | 612 | if ($worst->{overage_allowed}) { |
| 613 | 613 | $bg = '#1f2308'; $border = '#92740e'; $text = '#facc15'; |
| 614 | 614 | $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."; |
| 615 | 615 | } else { |
| 616 | 616 | $bg = '#2a0e0f'; $border = '#7f1d1d'; $text = '#fca5a5'; |
| 617 | 617 | $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."; |
| 618 | 618 | } |
| 619 | 619 | } else { |
| 620 | 620 | # Warn (80-99%) |
| 621 | 621 | $bg = '#1f2308'; $border = '#92740e'; $text = '#facc15'; |
| 622 | 622 | my $pct = $worst->{percent_of_cap} || 0; |
| 623 | 623 | if ($worst->{overage_allowed}) { |
| 624 | 624 | $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."; |
| 625 | 625 | } else { |
| 626 | 626 | $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."; |
| 627 | 627 | } |
| 628 | 628 | } |
| 629 | 629 | |
| 630 | 630 | return qq~<div style="background:$bg;border-bottom:1px solid $border;color:$text;padding:10px 18px;font-size:13px;text-align:center"> |
| 631 | 631 | <strong>$msg</strong> |
| 632 | 632 | </div>~; |
| 633 | 633 | } |
| 634 | 634 | |
| 635 | 635 | sub _impersonation_banner { |
| 636 | 636 | my ($userinfo) = @_; |
| 637 | 637 | return '' unless $userinfo && $userinfo->{_impersonating}; |
| 638 | 638 | |
| 639 | 639 | my $target_email = _html_escape($userinfo->{email} || ''); |
| 640 | 640 | my $target_name = _html_escape($userinfo->{display_name} || $target_email); |
| 641 | 641 | my $admin_email = _html_escape($userinfo->{_admin_email} || ''); |
| 642 | 642 | |
| 643 | 643 | return qq~ |
| 644 | 644 | <div class="impersonation-banner"> |
| 645 | 645 | <div class="impersonation-banner-msg"> |
| 646 | 646 | <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> |
| 647 | 647 | <span><strong>Acting as</strong> $target_name <$target_email> · admin: $admin_email</span> |
| 648 | 648 | </div> |
| 649 | 649 | <form method="POST" action="/admin_action.cgi" class="impersonation-banner-form"> |
| 650 | 650 | <input type="hidden" name="act" value="stop_impersonation"> |
| 651 | 651 | <button type="submit" class="impersonation-banner-stop">Stop & return to admin</button> |
| 652 | 652 | </form> |
| 653 | 653 | </div> |
| 654 | 654 | ~; |
| 655 | 655 | } |
| 656 | 656 | |
| 657 | 657 | sub _html_escape { |
| 658 | 658 | my ($s) = @_; |
| 659 | 659 | return '' unless defined $s; |
| 660 | 660 | $s =~ s/&/&/g; |
| 661 | 661 | $s =~ s/</</g; |
| 662 | 662 | $s =~ s/>/>/g; |
| 663 | 663 | $s =~ s/"/"/g; |
| 664 | 664 | $s =~ s/'/'/g; |
| 665 | 665 | return $s; |
| 666 | 666 | } |
| 667 | 667 | |
| 668 | 668 | # Inline SVG icon library — keeps the wrapper independent of any |
| 669 | 669 | # external icon font / file. All icons are 24x24, stroke-based. |
| 670 | 670 | my %ICONS = ( |
| 671 | 671 | 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>', |
| 672 | 672 | 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>', |
| 673 | 673 | 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>', |
| 674 | 674 | 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>', |
| 675 | 675 | 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>', |
| 676 | 676 | 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>', |
| 677 | 677 | 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>', |
| 678 | 678 | 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>', |
| 679 | 679 | 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>', |
| 680 | 680 | 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>', |
| 681 | 681 | 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>', |
| 682 | 682 | 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>', |
| 683 | 683 | 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>', |
| 684 | 684 | 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>', |
| 685 | 685 | 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>', |
| 686 | 686 | 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>', |
| 687 | 687 | 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>', |
| 688 | 688 | 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>', |
| 689 | 689 | 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>', |
| 690 | 690 | 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>', |
| 691 | 691 | 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>', |
| 692 | 692 | 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>', |
| 693 | 693 | 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>', |
| 694 | 694 | 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>', |
| 695 | 695 | 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>', |
| 696 | 696 | 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>', |
| 697 | 697 | 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>', |
| 698 | 698 | 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>', |
| 699 | 699 | 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>', |
| 700 | 700 | 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>', |
| 701 | 701 | 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>', |
| 702 | 702 | ); |
| 703 | 703 | |
| 704 | 704 | sub _icon { |
| 705 | 705 | my ($name) = @_; |
| 706 | 706 | return $ICONS{$name} || ''; |
| 707 | 707 | } |
| 708 | 708 | |
| 709 | 709 | # Build the <meta> block injected into every page's <head>. Pulls |
| 710 | 710 | # platform defaults from platform_settings, and if the caller passed |
| 711 | 711 | # opts->{dashboard} (e.g. /store.cgi), layers the per-dashboard SEO |
| 712 | 712 | # row over the top. Cached per-request via $_meta_cache so repeated |
| 713 | 713 | # calls in one CGI don't re-query. |
| 714 | 714 | our $_meta_cache; |
| 715 | 715 | sub _build_meta_tags { |
| 716 | 716 | my ($opts) = @_; |
| 717 | 717 | return '' unless $opts; |
| 718 | 718 | |
| 719 | 719 | require MODS::DBConnect; |
| 720 | 720 | require MODS::ContactForge::SEO; |
| 721 | 721 | my $db = MODS::DBConnect->new; |
| 722 | 722 | my $seo = MODS::ContactForge::SEO->new; |
| 723 | 723 | my $DB = $config->settings('database_name'); |
| 724 | 724 | my $dbh = $db->db_connect(); |
| 725 | 725 | return '' unless $dbh; |
| 726 | 726 | |
| 727 | 727 | my $platform = $seo->get_platform_settings($db, $dbh, $DB); |
| 728 | 728 | my $store = $opts->{dashboard} || {}; |
| 729 | 729 | |
| 730 | 730 | my $tags = $seo->render_tags( |
| 731 | 731 | platform => $platform, |
| 732 | 732 | dashboard => $store, |
| 733 | 733 | page_title => $opts->{title} || '', |
| 734 | 734 | request_url => $opts->{canonical_url} || '', |
| 735 | 735 | ); |
| 736 | 736 | |
| 737 | 737 | $db->db_disconnect($dbh); |
| 738 | 738 | return $tags; |
| 739 | 739 | } |
| 740 | 740 | |
| 741 | 741 | 1; |