Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/MODS/PTMatrix/Wrapper.pm
Diff

/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/MODS/PTMatrix/Wrapper.pm

added on local at 2026-07-01 12:34:29

Added
+565
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 85a6729604cf
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::PTMatrix::Wrapper;
2#======================================================================
3# TaskForge -- page wrapper.
4#
5# Renders the sidebar + topbar shell around a page's main content.
6# Two layouts:
7# tf_wrapper.html -- in-app authenticated layout (sidebar + topbar)
8# tf_marketing.html -- public marketing layout (top nav + footer)
9#
10# Caller pattern:
11# my $body = qq~ ...page body html... ~;
12# $wrap->render({
13# userinfo => $userinfo, # required for in-app shell
14# page_key => 'kanban', # marks the active sidebar item
15# title => 'Kanban',
16# body => $body,
17# extra_js => ['/assets/javascript/kanban.js'],
18# extra_css=> ['/assets/css/kanban.css'],
19# });
20#======================================================================
21
22use strict;
23use warnings;
24
25use MODS::Template;
26use MODS::DBConnect;
27use MODS::PTMatrix::Config;
28use MODS::PTMatrix::Terms;
29
30my $tfile = MODS::Template->new;
31my $config = MODS::PTMatrix::Config->new;
32my $db = MODS::DBConnect->new;
33
34# Sidebar nav: section headers + entries. Each entry has a key
35# matched against $page_key to set "active", an icon name (resolved
36# to inline SVG below), label, href, and optional gates:
37# admin_only = 1 -- hide unless role >= company_admin OR is_super_admin
38# manager_only = 1 -- hide unless role >= manager
39# super_admin_only = 1 -- platform owner only
40my @SIDEBAR = (
41 { section => 'Workspace' },
42 { key => 'dashboard', label => 'Dashboard', href => '/dashboard.cgi', icon => 'dashboard', tip => 'Your home screen. KPIs, next tasks, active projects, team workload.' },
43 { key => 'my_tasks', label => 'My Tasks', href => '/my_tasks.cgi', icon => 'check_circle', tip => 'Every task assigned to you, filterable by status / due date.' },
44 { key => 'tickets', label => 'Tickets', href => '/tickets.cgi', icon => 'tag', tip => 'Jira-style queue: every ticket across every project. Filter by status, priority, type, project, department, assignee.' },
45 { key => 'projects', label => 'Project Matrix', href => '/projects.cgi', icon => 'folder', tip => 'Top-down matrix of every project and its top tasks, connected by lines. Click a project to drill into its status buckets. Switch to Project List at the top for the classic grid.' },
46 { key => 'kanban', label => 'Kanban Boards', href => '/kanban.cgi', icon => 'columns', tip => 'Pick a project, see its board, drag cards between columns.' },
47 { key => 'gantt', label => 'Gantt Charts', href => '/gantt.cgi', icon => 'gantt', tip => 'Timeline view. Drag task bars to reschedule.' },
48 { key => 'calendar', label => 'Calendar', href => '/calendar.cgi', icon => 'calendar', tip => 'Month grid view of every task on its due date.' },
49 { key => 'time', label => 'Time Tracking', href => '/time.cgi', icon => 'clock', tip => 'Hours logged across tasks. Billable vs internal totals.' },
50
51 { section => 'Insights' },
52 { key => 'reports', label => 'Reports', href => '/reports.cgi', icon => 'chart', tip => 'Company KPIs, throughput, per-project rollups, per-person productivity.' },
53 { key => 'cost', label => 'Money View', href => '/cost.cgi', icon => 'dollar', tip => 'Where time becomes dollars. Stacked-area cost charts by billable status and department.' },
54 { key => 'cycle_time', label => 'Cycle Time', href => '/cycle_time.cgi', icon => 'clock', tip => 'Lead-time distribution + scatter chart. Spot slow-moving work and unstable delivery.' },
55 { key => 'portfolio', label => 'Portfolio Gantt', href => '/portfolio.cgi', icon => 'gantt', tip => 'Every active project on one timeline. Slipping projects flagged in red.' },
56 { key => 'forms', label => 'Forms', href => '/forms.cgi', icon => 'form', manager_only => 1, tip => 'Public intake URLs. Each submission becomes a task in the project you pick.' },
57 { key => 'automations', label => 'Automations', href => '/automations.cgi', icon => 'bolt', admin_only => 1, tip => 'Auto-react to task events. When X happens, do Y.' },
58 { key => 'workload', label => 'Team Workload', href => '/workload.cgi', icon => 'users', manager_only => 1, tip => 'Who is loaded, who is free. Spot overdue per person at a glance.' },
59 { key => 'activity', label => 'Activity Feed', href => '/activity.cgi', icon => 'pulse', tip => 'Audit log of every change your team made.' },
60
61 { section => 'Organization', manager_only => 1 },
62 { key => 'people', label => 'People', href => '/people.cgi', icon => 'users', tip => 'Org chart: who works here, who reports to whom, what they do. Click photos to view profiles, search by skill or department.' },
63 { key => 'team', label => 'Team Members', href => '/team.cgi', icon => 'users', admin_only => 1 },
64 { key => 'departments', label => 'Departments', href => '/departments.cgi', icon => 'building', admin_only => 1 },
65 { key => 'task_types', label => 'Task Types', href => '/task_types.cgi', icon => 'tag', admin_only => 1, tip => 'Define task types like Bug, Feature, Chore. Color-codes Kanban cards.' },
66 { key => 'custom_fields', label => 'Custom Fields', href => '/custom_fields.cgi', icon => 'settings', admin_only => 1, tip => 'Define ticket data captured beyond title/status: customer tier, story points, severity, etc.' },
67 { key => 'email_inboxes', label => 'Email Inboxes', href => '/email_inboxes.cgi', icon => 'form', admin_only => 1, tip => 'Connect your mailbox so customer emails become tickets. We poll your IMAP and send replies via your SMTP — never our servers.' },
68 { key => 'sla_policies', label => 'SLA Policies', href => '/sla_policies.cgi', icon => 'tag', admin_only => 1, tip => 'Response + resolution targets per priority / type / department. Breaches fire generic webhook events.' },
69 { key => 'api_keys', label => 'API & Webhooks', href => '/api_keys.cgi', icon => 'admin', admin_only => 1, tip => 'Public REST API keys + outbound webhooks to your own systems (Zapier, n8n, CRM, internal scripts).' },
70 { key => 'source_integrations', label => 'GitHub & Source', href => '/source_integrations.cgi', icon => 'form', admin_only => 1, tip => 'Link GitHub / GitLab / Bitbucket PRs to tickets. Mention TF-123 in a PR to auto-link.' },
71 { key => 'portal_settings', label => 'Customer Portal', href => '/portal_settings.cgi', icon => 'users', admin_only => 1, tip => 'Public-facing portal where your customers view and submit tickets without a PTMatrix account.' },
72 { key => 'kb', label => 'Knowledge Base', href => '/kb.cgi', icon => 'form', tip => 'Internal docs + customer-facing help articles + canned responses for fast replies.' },
73 { key => 'labels', label => 'Labels', href => '/labels.cgi', icon => 'label', admin_only => 1, tip => 'Free-form tags for tasks ("backend", "v2", "design").' },
74 { key => 'permission_groups', label => 'Permission Groups', href => '/permission_groups.cgi', icon => 'admin', admin_only => 1, tip => 'Named permission sets you can assign to users (overrides role defaults).' },
75 { key => 'sso', label => 'Single Sign-On',href => '/sso_setup.cgi', icon => 'admin', admin_only => 1, tip => 'Connect Okta, Entra ID, Google Workspace, LDAP, or direct SAML. Provisioning + auto-deactivation included.' },
76
77 { section => 'Account' },
78 { key => 'profile', label => 'My Profile', href => '/profile.cgi', icon => 'profile' },
79 { key => 'preferences', label => 'Preferences', href => '/preferences.cgi', icon => 'settings' },
80 { key => 'email_preferences', label => 'Email preferences', href => '/email_preferences.cgi', icon => 'messages', tip => 'Choose which emails to receive: lifecycle, product updates, marketing. Transactional always on.' },
81 { key => 'billing', label => 'Billing & Plan',href => '/billing.cgi', icon => 'billing', admin_only => 1 },
82 { key => 'company', label => 'Company', href => '/company.cgi', icon => 'briefcase', admin_only => 1 },
83
84 { section => 'Admin', super_admin_only => 1 },
85 { key => 'admin_funnels', label => 'Conversion Funnel', href => '/admin_funnels.cgi', icon => 'funnel', super_admin_only => 1, tip => 'Signup-to-paid funnel for the whole platform.' },
86 { key => 'admin_traffic', label => 'Traffic Reports', href => '/admin_traffic.cgi', icon => 'analytics', super_admin_only => 1, tip => 'Where visitors are coming from + globe + per-country drill.' },
87 { key => 'admin_companies', label => 'All Companies', href => '/admin_companies.cgi', icon => 'building', super_admin_only => 1 },
88 { key => 'admin_users', label => 'All Users', href => '/admin_users.cgi', icon => 'users', super_admin_only => 1 },
89 { key => 'admin_team', label => 'Team Members', href => '/admin_team.cgi', icon => 'users', super_admin_only => 1 },
90 { key => 'admin_billing', label => 'Platform Billing', href => '/admin_billing.cgi', icon => 'billing', super_admin_only => 1 },
91 { 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. Use at tax time.' },
92 { key => 'admin_support', label => 'Support queue', href => '/admin_support.cgi', icon => 'messages', super_admin_only => 1, tip => 'Customer-initiated support threads from the topbar Help → Contact support flow. Reply in-thread; close when done.' },
93 { key => 'admin_test_cards',label => 'Test Credit Cards',href => '/admin_test_cards.cgi', icon => 'billing', super_admin_only => 1, tip => 'Stripe test card numbers + decline simulations for QA.' },
94 { key => 'admin_discounts', label => 'Discounts & Deals',href => '/admin_discounts.cgi', icon => 'tag', super_admin_only => 1 },
95 { key => 'admin_metrics', label => 'System Metrics',href => '/admin_metrics.cgi', icon => 'pulse', super_admin_only => 1 },
96 { key => 'admin_seo', label => 'SEO', href => '/admin_seo.cgi', icon => 'seo', super_admin_only => 1, tip => 'Platform-wide page title, description, OG image, Twitter card, robots.' },
97 { key => 'email_setup', label => 'Email Setup', href => '/admin_emails.cgi', icon => 'messages', super_admin_only => 1, tip => 'Email templates, send schedules, and spam-protection controls.' },
98 { 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', super_admin_only => 1 },
99);
100
101sub new {
102 my ($class) = @_;
103 return bless({}, $class);
104}
105
106sub render {
107 my ($self, $opts) = @_;
108 $opts ||= {};
109
110 my $userinfo = $opts->{userinfo};
111 my $logged_in = $userinfo && $userinfo->{user_id} ? 1 : 0;
112
113 my $tvars = {
114 website_title => $config->settings('website_title'),
115 brand_name => $config->settings('brand_name'),
116 brand_tagline => $config->settings('brand_tagline'),
117 assets => $config->settings('assets'),
118 assets_css => $config->settings('assets_css'),
119 assets_js => $config->settings('assets_js'),
120 title => $opts->{title} || 'PTMatrix',
121 body => $opts->{body} || '',
122 extra_js => _format_scripts($opts->{extra_js}),
123 extra_css => _format_styles($opts->{extra_css}),
124 meta_tags => _build_meta_tags($opts),
125 year => (localtime)[5] + 1900,
126 };
127
128 if ($logged_in) {
129 my $is_super = $userinfo->{is_super_admin} || $userinfo->{_admin_is_super_admin} || 0;
130 my $role = $userinfo->{role} || 'member';
131 my $is_admin = ($role eq 'company_admin' || $is_super) ? 1 : 0;
132 my $is_mgr = ($role eq 'manager' || $is_admin) ? 1 : 0;
133
134 $tvars->{user_id} = $userinfo->{user_id};
135 $tvars->{display_name} = _h($userinfo->{display_name});
136 $tvars->{email} = _h($userinfo->{email});
137 $tvars->{job_title} = _h($userinfo->{job_title} || '');
138 $tvars->{role} = $role;
139 $tvars->{role_label} = _role_label($role);
140 $tvars->{is_admin} = $is_admin;
141 $tvars->{is_manager} = $is_mgr;
142 $tvars->{is_super_admin}= $is_super;
143 $tvars->{initials} = _initials($userinfo->{display_name} || $userinfo->{email});
144 $tvars->{avatar_color} = $userinfo->{avatar_color} || '#5aa9ff';
145 $tvars->{company_id} = $userinfo->{company_id} || 0;
146 $tvars->{company_name} = _h($userinfo->{company_name} || '');
147 $tvars->{company_brand_color} = $userinfo->{company_brand_color} || '#5aa9ff';
148 $tvars->{company_plan_tier} = ucfirst($userinfo->{company_plan_tier} || 'trial');
149
150 # Trial banner.
151 my $trial = '';
152 if ($userinfo->{company_trial_ends_at} && $userinfo->{company_plan_tier} eq 'trial') {
153 $trial = $userinfo->{company_trial_ends_at};
154 }
155 $tvars->{trial_ends_at} = $trial;
156 my $days_left = _days_until($trial);
157 $tvars->{trial_days_left} = $days_left;
158 $tvars->{is_trialing} = (length $trial && $userinfo->{company_plan_tier} eq 'trial' && $days_left >= 0) ? 1 : 0;
159
160 # Urgency tier drives banner color + copy:
161 # gentle: 8+ days (blue/info)
162 # amber : 2-7 days (yellow/warn)
163 # red : 0-1 day (red/critical)
164 my $tier = 'gentle';
165 if ($days_left <= 1) { $tier = 'red'; }
166 elsif ($days_left <= 7) { $tier = 'amber'; }
167 $tvars->{trial_tier_gentle} = ($tier eq 'gentle') ? 1 : 0;
168 $tvars->{trial_tier_amber} = ($tier eq 'amber') ? 1 : 0;
169 $tvars->{trial_tier_red} = ($tier eq 'red') ? 1 : 0;
170 $tvars->{trial_days_label} = ($days_left == 1) ? '1 day' : "$days_left days";
171
172 # Headline copy escalates with urgency.
173 $tvars->{trial_headline} =
174 $tier eq 'red' ? 'Final day of your trial.'
175 : $tier eq 'amber' ? "Trial ends in $tvars->{trial_days_label}."
176 : "Free trial: $tvars->{trial_days_label} left.";
177
178 $tvars->{sidebar} = _build_sidebar($opts->{page_key} || '', $is_admin, $is_mgr, $is_super, $userinfo);
179 $tvars->{dropdown_links} = _build_dropdown_links($is_admin, $is_mgr, $is_super, $userinfo);
180 $tvars->{impersonation_banner} = _impersonation_banner($userinfo);
181
182 my $unread_notif = _unread_notif_count($userinfo->{user_id});
183 $tvars->{unread_notif_count} = $unread_notif;
184 $tvars->{has_unread_notifs} = $unread_notif > 0 ? 1 : 0;
185
186 my $unread_support = _unread_support_count($userinfo);
187 $tvars->{unread_support_count} = $unread_support;
188 $tvars->{has_unread_support} = $unread_support > 0 ? 1 : 0;
189
190 my ($collapsed, $tips_off, $card_prefs, $mobile_open) = _user_ui_prefs($userinfo->{user_id});
191 my @cls;
192 push @cls, 'sidebar-collapsed' if $collapsed;
193 push @cls, 'sidebar-open' if $mobile_open;
194 push @cls, 'tips-off' if $tips_off;
195 $tvars->{html_class} = join(' ', @cls);
196
197 # Per-user card prefs (used by Kanban CSS via inline style block)
198 $tvars->{card_gradient_mode} = $card_prefs->{mode} || 'priority';
199 $tvars->{card_gradient_from} = $card_prefs->{from} || '#5aa9ff';
200 $tvars->{card_gradient_to} = $card_prefs->{to} || '#1a2230';
201 $tvars->{board_density} = $card_prefs->{density} || 'comfortable';
202
203 # Onboarding banner / redirect signal — set when company hasn't
204 # finished the wizard. Templates can read $needs_onboarding.
205 my $onb = _company_onboarding_state($userinfo->{company_id});
206 $tvars->{needs_onboarding} = $onb ? 0 : 1;
207
208 # Per-company terminology — every template gets $term_project, etc.
209 my %terms = MODS::PTMatrix::Terms::tvars_for($userinfo->{company_id});
210 $tvars->{$_} = $terms{$_} for keys %terms;
211 }
212
213 my $template_file = $logged_in ? 'tf_wrapper.html' : 'tf_marketing.html';
214 my @html = $tfile->template($template_file, $tvars, $userinfo);
215 print "@html";
216 return;
217}
218
219#======================================================================
220# Helpers
221#======================================================================
222
223sub _h {
224 my $s = shift // '';
225 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
226 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
227 return $s;
228}
229
230sub _initials {
231 my ($name) = @_;
232 return '?' unless $name;
233 my @parts = split /[\s\@\.]+/, $name;
234 my $i = '';
235 $i .= uc(substr($parts[0], 0, 1)) if $parts[0];
236 $i .= uc(substr($parts[1], 0, 1)) if defined $parts[1];
237 return $i || uc(substr($name, 0, 1));
238}
239
240sub _role_label {
241 my ($role) = @_;
242 return 'Owner Admin' if $role eq 'company_admin';
243 return 'Manager' if $role eq 'manager';
244 return 'Observer' if $role eq 'observer';
245 return 'Member';
246}
247
248sub _days_until {
249 my ($ts) = @_;
250 return 0 unless $ts;
251 my $dbh = $db->db_connect();
252 return 0 unless $dbh;
253 my $r = $db->db_readwrite($dbh, qq~
254 SELECT DATEDIFF('$ts', NOW()) AS days
255 ~, $ENV{SCRIPT_NAME}, __LINE__);
256 $db->db_disconnect($dbh);
257 return ($r && defined $r->{days}) ? int($r->{days}) : 0;
258}
259
260sub _format_scripts {
261 my ($list) = @_;
262 return '' unless $list && ref($list) eq 'ARRAY';
263 return join("\n", map { qq~<script src="$_"></script>~ } @$list);
264}
265
266sub _format_styles {
267 my ($list) = @_;
268 return '' unless $list && ref($list) eq 'ARRAY';
269 return join("\n", map { qq~<link rel="stylesheet" href="$_">~ } @$list);
270}
271
272our $_sidebar_col_checked;
273our $_sidebar_col_exists;
274
275sub _sidebar_pref {
276 my ($user_id) = @_;
277 my ($coll, $tips) = _user_ui_prefs($user_id);
278 return $coll;
279}
280
281# Returns (sidebar_collapsed, tips_off, card_prefs_hashref, sidebar_mobile_open) for a user.
282# Tips default ON so show_tooltips=1 means tips-off is 0. Mobile drawer defaults
283# CLOSED on first visit so we don't cover the page on a fresh load.
284sub _user_ui_prefs {
285 my ($user_id) = @_;
286 my $default_card = { mode => 'priority', from => '#5aa9ff', to => '#1a2230', density => 'comfortable' };
287 return (0, 0, $default_card, 0) unless $user_id;
288 $user_id =~ s/[^0-9]//g;
289 return (0, 0, $default_card, 0) unless $user_id;
290
291 my $DB = $config->settings('database_name');
292 my $dbh = $db->db_connect();
293 return (0, 0, $default_card, 0) unless $dbh;
294
295 # sidebar_mobile_open was added in a later migration; guard the SELECT
296 # so unmigrated installs still load. Falls back to the original column
297 # list when the new column is missing.
298 my $has_mobile_col = $db->db_readwrite($dbh, qq~
299 SELECT COUNT(*) AS n FROM information_schema.COLUMNS
300 WHERE TABLE_SCHEMA = '$DB'
301 AND TABLE_NAME = 'user_settings'
302 AND COLUMN_NAME = 'sidebar_mobile_open'
303 ~, $ENV{SCRIPT_NAME}, __LINE__);
304 my $mob_col = ($has_mobile_col && $has_mobile_col->{n}) ? ', sidebar_mobile_open' : '';
305
306 my $r = $db->db_readwrite($dbh, qq~
307 SELECT sidebar_collapsed, show_tooltips,
308 card_gradient_mode, card_gradient_from, card_gradient_to, board_density$mob_col
309 FROM `${DB}`.user_settings
310 WHERE user_id='$user_id'
311 LIMIT 1
312 ~, $ENV{SCRIPT_NAME}, __LINE__);
313 $db->db_disconnect($dbh);
314
315 my $coll = ($r && $r->{sidebar_collapsed}) ? 1 : 0;
316 my $mob = ($r && $r->{sidebar_mobile_open}) ? 1 : 0;
317 # default to ON (tips visible) if no row exists yet
318 my $show = ($r && defined $r->{show_tooltips}) ? ($r->{show_tooltips} + 0) : 1;
319 my $card = {
320 mode => ($r && $r->{card_gradient_mode}) ? $r->{card_gradient_mode} : 'priority',
321 from => ($r && $r->{card_gradient_from}) ? $r->{card_gradient_from} : '#5aa9ff',
322 to => ($r && $r->{card_gradient_to}) ? $r->{card_gradient_to} : '#1a2230',
323 density => ($r && $r->{board_density}) ? $r->{board_density} : 'comfortable',
324 };
325 return ($coll, $show ? 0 : 1, $card, $mob);
326}
327
328# Returns 1 if the company has completed onboarding (or has no company
329# row — super-admin without one).
330sub _company_onboarding_state {
331 my ($company_id) = @_;
332 return 1 unless $company_id;
333 $company_id =~ s/[^0-9]//g;
334 return 1 unless $company_id;
335 my $DB = $config->settings('database_name');
336 my $dbh = $db->db_connect();
337 return 1 unless $dbh;
338 my $r = $db->db_readwrite($dbh, qq~
339 SELECT onboarding_completed_at FROM `${DB}`.companies WHERE id='$company_id' LIMIT 1
340 ~, $ENV{SCRIPT_NAME}, __LINE__);
341 $db->db_disconnect($dbh);
342 return ($r && $r->{onboarding_completed_at}) ? 1 : 0;
343}
344
345sub _build_sidebar {
346 my ($active_key, $is_admin, $is_mgr, $is_super, $userinfo) = @_;
347 my $html = '';
348 my $pending_section;
349
350 foreach my $row (@SIDEBAR) {
351 next if $row->{super_admin_only} && !$is_super;
352 next if $row->{admin_only} && !$is_admin;
353 next if $row->{manager_only} && !$is_mgr;
354
355 if ($row->{section}) {
356 $pending_section = $row->{section};
357 next;
358 }
359
360 if (defined $pending_section) {
361 $html .= qq~<div class="sidebar-section-label">$pending_section</div>\n~;
362 $pending_section = undef;
363 }
364
365 my $cls = ($row->{key} && $row->{key} eq $active_key) ? 'sidebar-link active' : 'sidebar-link';
366 my $svg = _icon($row->{icon});
367 my $badge = $row->{badge} ? qq~<span class="badge">$row->{badge}</span>~ : '';
368 my $tip = $row->{tip} ? qq~ data-tip="~ . _h($row->{tip}) . qq~"~ : '';
369 $html .= qq~<a class="$cls" href="$row->{href}"$tip>$svg<span>$row->{label}</span>$badge</a>\n~;
370 }
371 return $html;
372}
373
374sub _build_dropdown_links {
375 my ($is_admin, $is_mgr, $is_super, $userinfo) = @_;
376 my $html = '';
377 my $emitted_first = 0;
378 my $pending_section;
379 my $pending_divider = 0;
380
381 foreach my $row (@SIDEBAR) {
382 next if $row->{super_admin_only} && !$is_super;
383 next if $row->{admin_only} && !$is_admin;
384 next if $row->{manager_only} && !$is_mgr;
385
386 if ($row->{section}) {
387 $pending_section = $row->{section};
388 $pending_divider = $emitted_first ? 1 : 0;
389 next;
390 }
391
392 if (defined $pending_section) {
393 $html .= qq~<div class="profile-dropdown-divider"></div>\n~ if $pending_divider;
394 $html .= qq~<div class="profile-dropdown-section">$pending_section</div>\n~;
395 $emitted_first = 1;
396 $pending_section = undef;
397 $pending_divider = 0;
398 }
399
400 my $svg = _icon($row->{icon});
401 my $extra_cls = $row->{super_admin_only} ? ' class="profile-admin"' : '';
402 $html .= qq~<a href="$row->{href}"$extra_cls><span class="dropdown-icon">$svg</span> $row->{label}</a>\n~;
403 }
404 return $html;
405}
406
407sub _unread_notif_count {
408 my ($user_id) = @_;
409 return 0 unless $user_id;
410 $user_id =~ s/[^0-9]//g;
411 return 0 unless $user_id;
412
413 my $DB = $config->settings('database_name');
414 my $dbh = $db->db_connect();
415 return 0 unless $dbh;
416 my $r = $db->db_readwrite($dbh, qq~
417 SELECT COUNT(*) AS n
418 FROM `${DB}`.notifications
419 WHERE user_id='$user_id' AND read_at IS NULL
420 ~, $ENV{SCRIPT_NAME}, __LINE__);
421 $db->db_disconnect($dbh);
422 return ($r && $r->{n}) ? int($r->{n}) : 0;
423}
424
425sub _unread_support_count {
426 my ($userinfo) = @_;
427 return 0 unless $userinfo && $userinfo->{user_id};
428 my $user_id = $userinfo->{user_id}; $user_id =~ s/[^0-9]//g;
429 return 0 unless $user_id;
430
431 my $DB = $config->settings('database_name');
432 my $dbh = $db->db_connect();
433 return 0 unless $dbh;
434
435 my $r;
436 if ($userinfo->{is_super_admin} || $userinfo->{is_admin}) {
437 # Platform admins: count threads with admin_unread=1 + status open
438 $r = $db->db_readwrite($dbh, qq~
439 SELECT COUNT(*) AS n
440 FROM `${DB}`.support_threads
441 WHERE admin_unread = 1 AND status <> 'closed'
442 ~, $ENV{SCRIPT_NAME}, __LINE__);
443 } else {
444 # Regular user: count their own threads with user_unread=1
445 $r = $db->db_readwrite($dbh, qq~
446 SELECT COUNT(*) AS n
447 FROM `${DB}`.support_threads
448 WHERE opened_by_user_id = '$user_id' AND user_unread = 1
449 ~, $ENV{SCRIPT_NAME}, __LINE__);
450 }
451 $db->db_disconnect($dbh);
452 return ($r && $r->{n}) ? int($r->{n}) : 0;
453}
454
455sub _impersonation_banner {
456 my ($userinfo) = @_;
457 return '' unless $userinfo && $userinfo->{_impersonating};
458
459 my $target_email = _h($userinfo->{email} || '');
460 my $target_name = _h($userinfo->{display_name} || $target_email);
461 my $admin_email = _h($userinfo->{_admin_email} || '');
462
463 return qq~
464<div class="impersonation-banner">
465 <div class="impersonation-banner-msg">
466 <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>
467 <span><strong>Acting as</strong> $target_name &lt;$target_email&gt; &middot; admin: $admin_email</span>
468 </div>
469 <form method="POST" action="/admin_action.cgi" class="impersonation-banner-form">
470 <input type="hidden" name="act" value="stop_impersonation">
471 <button type="submit" class="impersonation-banner-stop">Stop &amp; return to admin</button>
472 </form>
473</div>
474~;
475}
476
477# Inline SVG icon library.
478my %ICONS = (
479 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>',
480 check_circle => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
481 folder => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
482 columns => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="5" height="18" rx="1"/><rect x="10" y="3" width="5" height="14" rx="1"/><rect x="17" y="3" width="4" height="10" rx="1"/></svg>',
483 gantt => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h10"/><path d="M3 12h14"/><path d="M3 18h8"/><circle cx="13" cy="6" r="2"/><circle cx="17" cy="12" r="2"/><circle cx="11" cy="18" r="2"/></svg>',
484 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>',
485 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>',
486 chart => '<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>',
487 users => '<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>',
488 pulse => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>',
489 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>',
490 tag => '<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>',
491 label => '<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"/></svg>',
492 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>',
493 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>',
494 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>',
495 briefcase => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>',
496 dollar => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>',
497 form => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="3" width="16" height="18" rx="2"/><line x1="8" y1="8" x2="16" y2="8"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="16" x2="13" y2="16"/></svg>',
498 bolt => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
499 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>',
500 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"/></svg>',
501 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>',
502 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>',
503 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>',
504 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>',
505);
506
507sub _icon {
508 my ($name) = @_;
509 return $ICONS{$name} || '';
510}
511
512sub _build_meta_tags {
513 my ($opts) = @_;
514 return '' unless $opts;
515
516 my $title = _h($opts->{title} || '');
517 my $brand = _h($config->settings('seo.title') || 'PTMatrix');
518 my $desc = _h($config->settings('seo.description') || '');
519 my $kw = _h($config->settings('seo.keywords') || '');
520 my $canon = $config->settings('seo.canonical_base') || '';
521 my $req = $ENV{REQUEST_URI} || '/';
522 $req =~ s/[^\x21-\x7e]//g;
523 my $url = $canon ? "$canon$req" : '';
524 my $og_site = _h($config->settings('seo.og_site_name') || 'PTMatrix');
525 my $og_image = $config->settings('seo.og_image') || '';
526 my $tw_card = _h($config->settings('seo.twitter_card') || 'summary_large_image');
527 my $tw_site = _h($config->settings('seo.twitter_site') || '');
528 my $robots_raw = $config->settings('seo.robots_index');
529 my $robots_str = (defined $robots_raw && $robots_raw eq '0') ? 'noindex,nofollow' : 'index,follow';
530
531 # Relative og_image paths get prefixed with the canonical base so
532 # social scrapers can fetch them absolute.
533 if ($og_image && $og_image =~ m{^/} && $canon) {
534 my $base = $canon; $base =~ s{/+$}{};
535 $og_image = $base . $og_image;
536 }
537 $og_image = _h($og_image);
538
539 my $full_title = $title ? "$title &mdash; $og_site" : $brand;
540
541 my $tags = qq~
542<meta name="description" content="$desc">
543<meta name="keywords" content="$kw">
544<meta name="robots" content="$robots_str">
545<meta property="og:type" content="website">
546<meta property="og:site_name" content="$og_site">
547<meta property="og:title" content="$full_title">
548<meta property="og:description" content="$desc">
549~;
550 $tags .= qq~<meta property="og:url" content="$url">\n<link rel="canonical" href="$url">\n~ if $url;
551 $tags .= qq~<meta property="og:image" content="$og_image">\n~ if length $og_image;
552 $tags .= qq~<meta name="twitter:card" content="$tw_card">\n~;
553 $tags .= qq~<meta name="twitter:title" content="$full_title">\n~;
554 $tags .= qq~<meta name="twitter:description" content="$desc">\n~ if length $desc;
555 $tags .= qq~<meta name="twitter:image" content="$og_image">\n~ if length $og_image;
556 $tags .= qq~<meta name="twitter:site" content="$tw_site">\n~ if length $tw_site;
557
558 # Visitor-tracking pixel. Loaded on every page (marketing + in-app).
559 # Guards itself against admin/track URLs.
560 $tags .= qq~<script src="/assets/javascript/track.js" defer></script>\n~;
561
562 return $tags;
563}
564
5651;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help