Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/index.cgi
Diff

/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/index.cgi

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

Added
+522
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 19e50eddd893
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2
3##################################################################################################################################
4# ______ ____ ____ ______ ______ ____ ___ __ ___ ______ _ __ ____ ____ __ __ _ __ _____ ____
5# / ____// __ \ / __ \ / ____/ / ____// __ \ / | / |/ // ____/| | / // __ \ / __ \ / //_/ | | / /|__ / / __ \
6# / / / / / // / / // __/ / /_ / /_/ // /| | / /|_/ // __/ | | /| / // / / // /_/ // ,< | | / / /_ < / / / /
7# / /___ / /_/ // /_/ // /___ / __/ / _, _// ___ | / / / // /___ | |/ |/ // /_/ // _, _// /| | | |/ / ___/ /_ / /_/ /
8# \____/ \____//_____//_____/ /_/ /_/ |_|/_/ |_|/_/ /_//_____/ |__/|__/ \____//_/ |_|/_/ |_| |___/ /____/(_)\____/
9#
10# - Written by: Shawn Pickering
11# - shawn@shawnpickering.com
12##################################################################################################################################
13use strict;
14use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com';
15
16
17#############################################################
18## Variables
19#############################################################
20my $contact_log = '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/uploads/_contact.log';
21
22
23
24############################################################
25# Modules
26############################################################
27use CGI; my $query = new CGI; my $form = $query->Vars;
28use MODS::Login; my $login = new MODS::Login;
29use MODS::PTMatrix::Urls; my $getlist = new MODS::PTMatrix::Urls; my $url = $getlist->urls();
30use MODS::Template; my $tfile = new MODS::Template;
31use MODS::PTMatrix::Wrapper; my $load = new MODS::PTMatrix::Wrapper;
32use MODS::DBConnect; my $db = new MODS::DBConnect;
33use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name');
34use MODS::PTMatrix::PM;
35
36
37
38#############################################################
39## Get form data
40#############################################################
41foreach my $line(keys %$form){
42 $form->{$line} =~ s/[^!-~\s]//g;
43 $form->{$line} = quotemeta("$form->{$line}");
44}
45
46
47
48############################################################
49# Program Controls
50############################################################
51$|=1;
52my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'index';
53my $userinfo = $login->login_verify();
54
55if($entry eq 'status'){
56 &status_page;
57 exit;
58}
59
60print qq~Content-type:text/html; Cache-Control:no-cache;\n\n~;
61
62if($entry eq 'index'){
63 if($userinfo){print qq~<script>window.location="$url->{dashboard}";</script>~;}
64 else{&landing_page;}
65}elsif($entry eq 'about'){
66 &about_page;
67}elsif($entry eq 'contact'){
68 if(($ENV{REQUEST_METHOD} || '') eq 'POST'){&submit_contact;}
69 else{&contact_page;}
70}elsif($entry eq 'faq'){
71 &faq_page;
72}elsif($entry eq 'features'){
73 &features_page;
74}elsif($entry eq 'help'){
75 if(!$userinfo){print qq~<script>window.location="$url->{login}";</script>~;}
76 else{&help_page;}
77}elsif($entry eq 'pricing'){
78 &pricing_page;
79}elsif($entry eq 'privacy'){
80 &privacy_page;
81}elsif($entry eq 'terms'){
82 &terms_page;
83}else{
84 if($userinfo){print qq~<script>window.location="$url->{dashboard}";</script>~;}
85 else{&landing_page;}
86}
87
88
89############################################################
90# Subroutines
91############################################################
92sub landing_page{
93 my @plans = MODS::PTMatrix::PM::load_marketing_plans();
94 my $annual_pct = ($config->settings('annual_discount_pct') || 20) + 0;
95
96 my $tvars;
97 $tvars->{plans} = \@plans;
98 $tvars->{has_plans} = scalar(@plans) ? 1 : 0;
99 $tvars->{annual_discount_pct} = $annual_pct;
100
101 my $body = join('', $tfile->template('tf_landing.html', $tvars, undef));
102
103 $load->render({
104 userinfo => undef,
105 title => 'Project Management for Teams That Ship',
106 body => $body,
107 });
108}
109
110sub about_page{
111 my $body = join('', $tfile->template('tf_about.html', {}, $userinfo));
112
113 $load->render({
114 userinfo => $userinfo,
115 page_key => 'about',
116 title => 'About',
117 body => $body,
118 });
119}
120
121sub submit_contact{
122 my $sent = 0;
123 if(open(my $fh, '>>', $contact_log)){
124 print $fh scalar(localtime), "\t", ($form->{name}||''), "\t", ($form->{email}||''), "\t", ($form->{topic}||''), "\t", ($form->{message}||''), "\n";
125 close $fh;
126 $sent = 1;
127 }
128 &contact_page($sent);
129}
130
131sub contact_page{
132 my($sent) = @_;
133 $sent ||= 0;
134
135 my $tvars;
136 $tvars->{sent} = $sent;
137 $tvars->{topic} = $form->{topic} || '';
138
139 my $body = join('', $tfile->template('tf_contact.html', $tvars, $userinfo));
140
141 $load->render({
142 userinfo => $userinfo,
143 page_key => 'contact',
144 title => 'Contact',
145 body => $body,
146 });
147}
148
149sub faq_page{
150 my $dbh = $db->db_connect();
151 my $sql = qq~SELECT id, title, body FROM `${database_name}`.kb_articles WHERE category='faq' AND is_active=1 AND is_public=1 ORDER BY view_count DESC, updated_at DESC LIMIT 50~;
152 my @rows = $db->db_readwrite_multiple($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__);
153 $db->db_disconnect($dbh);
154
155 my @faq_rows;
156 foreach my $r(@rows){
157 push @faq_rows, {
158 id => $r->{id},
159 title => MODS::PTMatrix::PM::h($r->{title}),
160 body => &faq_body_html($r->{body}),
161 };
162 }
163
164 my $tvars;
165 $tvars->{faqs} = \@faq_rows;
166 $tvars->{has_faqs} = scalar(@faq_rows) ? 1 : 0;
167
168 my $body = join('', $tfile->template('tf_faq.html', $tvars, $userinfo));
169
170 $load->render({
171 userinfo => $userinfo,
172 page_key => 'faq',
173 title => 'FAQ',
174 body => $body,
175 });
176}
177
178sub faq_body_html{
179 my $body = shift // '';
180 $body =~ s/\r//g;
181 $body =~ s/&/&amp;/g;
182 $body =~ s/</&lt;/g;
183 $body =~ s/>/&gt;/g;
184 $body =~ s/`([^`]+)`/<code>$1<\/code>/g;
185 $body =~ s/\*\*([^\*]+)\*\*/<strong>$1<\/strong>/g;
186 $body =~ s|\[([^\]]+)\]\(([^)]+)\)|<a href="$2">$1</a>|g;
187 my @out;
188 foreach my $block(split /\n{2,}/, $body){
189 next if $block =~ /^\s*$/;
190 if($block =~ /^##\s+(.+)$/m){
191 (my $rest = $block) =~ s/^##\s+//;
192 push @out, "<h4>$rest</h4>";
193 }elsif($block =~ /^-\s+/m){
194 my @items = grep { /^-\s+/ } split /\n/, $block;
195 s/^-\s+// for @items;
196 push @out, '<ul><li>' . join('</li><li>', @items) . '</li></ul>';
197 }else{
198 $block =~ s/\n/<br>/g;
199 push @out, "<p>$block</p>";
200 }
201 }
202 return join("\n", @out);
203}
204
205sub features_page{
206 my $body = join('', $tfile->template('tf_features.html', {}, $userinfo));
207
208 $load->render({
209 userinfo => $userinfo,
210 page_key => 'features',
211 title => 'Features',
212 body => $body,
213 });
214}
215
216sub help_page{
217 my $tvars;
218 $tvars->{page_label} = 'Help center';
219 $tvars->{page_subtitle} = 'Get answers fast, or talk to our support team.';
220
221 my $body = join('', $tfile->template('tf_help.html', $tvars, $userinfo));
222
223 $load->render({
224 userinfo => $userinfo,
225 page_key => 'help',
226 title => 'Help center',
227 body => $body,
228 });
229}
230
231sub pricing_page{
232 my @plans = MODS::PTMatrix::PM::load_marketing_plans();
233 my $annual_pct = ($config->settings('annual_discount_pct') || 20) + 0;
234
235 my $tvars;
236 $tvars->{plans} = \@plans;
237 $tvars->{has_plans} = scalar(@plans) ? 1 : 0;
238 $tvars->{annual_discount_pct} = $annual_pct;
239
240 my $body = join('', $tfile->template('tf_pricing.html', $tvars, $userinfo));
241
242 $load->render({
243 userinfo => $userinfo,
244 page_key => 'pricing',
245 title => 'Pricing',
246 body => $body,
247 });
248}
249
250sub privacy_page{
251 my $tvars;
252 $tvars->{page_title} = 'Privacy Policy';
253 $tvars->{legal_body} = &privacy_body;
254
255 my $body = join('', $tfile->template('tf_legal.html', $tvars, $userinfo));
256
257 $load->render({
258 userinfo => $userinfo,
259 page_key => 'privacy',
260 title => 'Privacy Policy',
261 body => $body,
262 });
263}
264
265sub privacy_body{
266 return <<'PRIVACY';
267<h2>What we collect</h2>
268<p>Account info (name, email), workspace content (projects, tasks, comments, files you upload), and usage telemetry (page-load timings, error stacks) to keep the Service running.</p>
269
270<h2>What we don't collect</h2>
271<ul>
272<li>We do <strong>not</strong> sell your data.</li>
273<li>We do <strong>not</strong> use your content to train AI models.</li>
274<li>We do <strong>not</strong> share content with third parties except processors (Stripe for billing, email provider) listed in our DPA.</li>
275</ul>
276
277<h2>Cookies</h2>
278<p>One session cookie per signed-in browser. That's it. No third-party trackers on the marketing site.</p>
279
280<h2>Subprocessors</h2>
281<p>Stripe (billing), Cloudflare (CDN), Let's Encrypt (TLS). Full list and DPA available on request.</p>
282
283<h2>Your rights</h2>
284<p>You can export or delete your data at any time from <code>/profile.cgi</code>. EU / UK / California residents have additional rights under GDPR / CCPA; reach out to <code>privacy@ptmatrix.3dshawn.com</code>.</p>
285
286<h2>Security</h2>
287<p>Argon2 password hashing. TLS-only transport. Encryption at rest. Annual third-party penetration test. SOC 2 Type II on Business plan and above.</p>
288
289<h2>Retention</h2>
290<p>Customer content is kept while your subscription is active and for 30 days after cancellation. Audit logs are kept for 12 months by default; longer on Business plan.</p>
291
292<p class="text-muted mt-3">Last updated 2026-06-06.</p>
293PRIVACY
294}
295
296sub terms_page{
297 my $tvars;
298 $tvars->{page_title} = 'Terms of Service';
299 $tvars->{legal_body} = &terms_body;
300
301 my $body = join('', $tfile->template('tf_legal.html', $tvars, $userinfo));
302
303 $load->render({
304 userinfo => $userinfo,
305 page_key => 'terms',
306 title => 'Terms of Service',
307 body => $body,
308 });
309}
310
311sub terms_body{
312 return <<'TERMS';
313<h2>1. Acceptance</h2>
314<p>By creating a PTMatrix workspace, you (the "Customer") agree to these Terms of Service.</p>
315
316<h2>2. Service</h2>
317<p>PTMatrix provides a hosted project-management platform. We do our best to maintain 99.9% monthly uptime. Scheduled maintenance windows will be communicated in advance.</p>
318
319<h2>3. Your account</h2>
320<p>You are responsible for keeping credentials confidential and for activity under your account. Notify us immediately of any unauthorized use.</p>
321
322<h2>4. Data</h2>
323<p>You own your data. We process it solely to deliver the Service. We will never use Customer content to train AI models or sell it to third parties.</p>
324
325<h2>5. Payment</h2>
326<p>Paid plans are billed monthly or annually in advance. Cancellation takes effect at the end of the current billing period. Refunds for unused time are at our sole discretion.</p>
327
328<h2>6. Termination</h2>
329<p>Either party may terminate at any time. On termination you may export your data for 30 days, after which we will delete it from production systems.</p>
330
331<h2>7. Limitation of liability</h2>
332<p>To the maximum extent permitted by law, PTMatrix's total liability for any claim related to the Service is limited to the amount you paid in the trailing 12 months.</p>
333
334<h2>8. Changes</h2>
335<p>We may update these Terms occasionally. Material changes will be announced via in-product notice or email at least 30 days in advance.</p>
336
337<p class="text-muted mt-3">Last updated 2026-06-06.</p>
338TERMS
339}
340
341#============================================================
342# status entry: public status page (self-manages Content-Type)
343#============================================================
344sub state_label{
345 my $s = shift // '';
346 my %m = (
347 operational => 'Operational',
348 degraded => 'Degraded',
349 partial_outage => 'Partial outage',
350 major_outage => 'Major outage',
351 maintenance => 'Maintenance',
352 );
353 return $m{$s} || ucfirst($s);
354}
355
356sub state_colour{
357 my $s = shift // 'operational';
358 return '#22c55e' if $s eq 'operational';
359 return '#3b82f6' if $s eq 'maintenance';
360 return '#f59e0b' if $s eq 'degraded';
361 return '#fb923c' if $s eq 'partial_outage';
362 return '#ef4444' if $s eq 'major_outage';
363 return '#6b7280';
364}
365
366sub impact_colour{
367 my $i = shift // 'minor';
368 return '#6b7280' if $i eq 'none';
369 return '#f59e0b' if $i eq 'minor';
370 return '#fb923c' if $i eq 'major';
371 return '#ef4444' if $i eq 'critical';
372 return '#6b7280';
373}
374
375sub status_md_to_html{
376 my $body = shift // '';
377 $body =~ s/\r//g;
378 $body =~ s/&/&amp;/g; $body =~ s/</&lt;/g; $body =~ s/>/&gt;/g;
379 $body =~ s/`([^`]+)`/<code>$1<\/code>/g;
380 $body =~ s/\*\*([^\*]+)\*\*/<strong>$1<\/strong>/g;
381 my @out;
382 for my $blk(split /\n{2,}/, $body){
383 next if $blk =~ /^\s*$/;
384 $blk =~ s/\n/<br>/g;
385 push @out, "<p>$blk</p>";
386 }
387 return join("\n", @out);
388}
389
390sub status_page{
391 my $dbh = $db->db_connect();
392
393 my $schema_ready = $db->db_readwrite($dbh, qq~SELECT COUNT(*) AS n FROM information_schema.TABLES WHERE TABLE_SCHEMA='${database_name}' AND TABLE_NAME='system_status'~, $ENV{SCRIPT_NAME}, __LINE__);
394 $schema_ready = ($schema_ready && $schema_ready->{n}) ? 1 : 0;
395
396 my (@services, @incidents, $overall);
397 if($schema_ready){
398 @services = $db->db_readwrite_multiple($dbh, qq~SELECT service_key, service_label, state, note, updated_at FROM `${database_name}`.system_status ORDER BY sort_order, service_label~, $ENV{SCRIPT_NAME}, __LINE__);
399 @incidents = $db->db_readwrite_multiple($dbh, qq~SELECT id, title, body, impact, state, started_at, resolved_at, affected FROM `${database_name}`.system_incidents WHERE started_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) OR resolved_at IS NULL ORDER BY started_at DESC LIMIT 30~, $ENV{SCRIPT_NAME}, __LINE__);
400
401 my %rank = (operational => 0, maintenance => 1, degraded => 2, partial_outage => 3, major_outage => 4);
402 my $worst = 'operational';
403 for my $s(@services){
404 $worst = $s->{state} if ($rank{$s->{state}} // 0) > ($rank{$worst} // 0);
405 }
406 $overall = $worst;
407 }
408
409 $db->db_disconnect($dbh);
410
411 my $overall_lbl = $schema_ready ? &state_label($overall) : 'Status page not yet provisioned';
412 my $overall_col = $schema_ready ? &state_colour($overall) : '#6b7280';
413 my $overall_msg = $schema_ready
414 ? ($overall eq 'operational'
415 ? 'All systems operational.'
416 : 'Some services are experiencing issues. See details below.')
417 : 'A platform admin needs to apply _status_migration.sql to enable the public status page.';
418
419 my $services_html = '';
420 for my $s(@services){
421 my $col = &state_colour($s->{state});
422 my $lbl = &state_label($s->{state});
423 my $note = MODS::PTMatrix::PM::h($s->{note} || '');
424 my $note_blk = length($note) ? '<div style="font-size:12px;color:var(--col-text-dim);margin-top:4px">' . $note . '</div>' : '';
425 $services_html .= '<div style="display:flex;align-items:center;gap:12px;padding:14px 18px;border-top:1px solid var(--col-border)">';
426 $services_html .= '<div style="width:10px;height:10px;border-radius:50%;background:' . $col . ';flex-shrink:0"></div>';
427 $services_html .= '<div style="flex:1"><div style="font-size:14px;font-weight:600">' . MODS::PTMatrix::PM::h($s->{service_label}) . '</div>' . $note_blk . '</div>';
428 $services_html .= '<div style="font-size:12px;color:' . $col . ';font-weight:600">' . $lbl . '</div>';
429 $services_html .= '</div>';
430 }
431 $services_html ||= '<div style="padding:24px;text-align:center;color:var(--col-text-dim)">No services configured yet.</div>';
432
433 my $incidents_html = '';
434 for my $i(@incidents){
435 my $i_col = &impact_colour($i->{impact});
436 my $i_state_col = $i->{state} eq 'resolved' ? '#22c55e' : '#f59e0b';
437 my $title = MODS::PTMatrix::PM::h($i->{title});
438 my $body = &status_md_to_html($i->{body});
439 my $impact = uc($i->{impact});
440 my $state = ucfirst($i->{state});
441 my $start = $i->{started_at} || '';
442 my $end_blk = $i->{resolved_at} ? ' &middot; resolved ' . $i->{resolved_at} : '';
443 my $affected = $i->{affected} ? '<div style="font-size:12px;color:var(--col-text-dim);margin-bottom:4px">Affects: ' . MODS::PTMatrix::PM::h($i->{affected}) . '</div>' : '';
444 $incidents_html .= '<div style="padding:16px 18px;border-top:1px solid var(--col-border)">';
445 $incidents_html .= '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">';
446 $incidents_html .= '<div style="font-size:14px;font-weight:600">' . $title . '</div>';
447 $incidents_html .= '<div><span style="font-size:10px;padding:2px 8px;border-radius:999px;background:' . $i_col . '20;color:' . $i_col . ';margin-right:4px">' . $impact . '</span>';
448 $incidents_html .= '<span style="font-size:10px;padding:2px 8px;border-radius:999px;background:' . $i_state_col . '20;color:' . $i_state_col . '">' . $state . '</span></div>';
449 $incidents_html .= '</div>';
450 $incidents_html .= '<div style="font-size:11px;color:var(--col-text-dim);margin-bottom:6px">Started ' . $start . $end_blk . '</div>';
451 $incidents_html .= $affected;
452 $incidents_html .= '<div style="font-size:13px;line-height:1.6">' . $body . '</div>';
453 $incidents_html .= '</div>';
454 }
455 $incidents_html ||= '<div style="padding:24px;text-align:center;color:var(--col-text-dim)">No incidents in the last 30 days. Clean run.</div>';
456
457 my $body = '<div style="max-width:760px;margin:0 auto;padding:0 16px">';
458 $body .= '<div style="text-align:center;padding:32px 0 28px">';
459 $body .= '<h1 style="font-size:28px;margin:0 0 8px">PTMatrix status</h1>';
460 $body .= '<p style="color:var(--col-text-dim);margin:0">Live operational status of every PTMatrix service.</p>';
461 $body .= '</div>';
462 $body .= '<div style="display:flex;align-items:center;gap:16px;padding:22px;background:' . $overall_col . '15;border:1px solid ' . $overall_col . ';border-radius:12px;margin-bottom:24px">';
463 $body .= '<div style="width:16px;height:16px;border-radius:50%;background:' . $overall_col . '"></div>';
464 $body .= '<div><div style="font-size:18px;font-weight:700;color:' . $overall_col . '">' . $overall_lbl . '</div>';
465 $body .= '<div style="font-size:13px;color:var(--col-text)">' . $overall_msg . '</div></div>';
466 $body .= '</div>';
467 $body .= '<div style="background:var(--col-bg-2);border:1px solid var(--col-border);border-radius:12px;overflow:hidden;margin-bottom:24px">';
468 $body .= '<div style="padding:14px 18px;font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--col-text-dim);font-weight:600">Services</div>';
469 $body .= $services_html;
470 $body .= '</div>';
471 $body .= '<div style="background:var(--col-bg-2);border:1px solid var(--col-border);border-radius:12px;overflow:hidden;margin-bottom:24px">';
472 $body .= '<div style="padding:14px 18px;font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--col-text-dim);font-weight:600">Recent incidents (last 30 days)</div>';
473 $body .= $incidents_html;
474 $body .= '</div>';
475 $body .= '<div style="text-align:center;color:var(--col-text-dim);font-size:12px;padding:16px 0 28px">Want updates by email? <a href="' . $url->{contact} . '&topic=status" style="color:var(--col-brand-1)">Subscribe to status alerts</a>.</div>';
476 $body .= '</div>';
477
478 if($userinfo){
479 print qq~Content-type:text/html; Cache-Control:no-cache;\n\n~;
480 $load->render({
481 userinfo => $userinfo,
482 page_key => 'status',
483 title => 'Status',
484 body => $body,
485 });
486 }else{
487 print qq~Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n~;
488 print '<!doctype html><html><head><meta charset="utf-8"><title>PTMatrix status</title>';
489 print '<link rel="stylesheet" href="/assets/css/site.css">';
490 print '<meta name="viewport" content="width=device-width,initial-scale=1">';
491 print '<style>';
492 print '.status-marketing-nav{padding:14px 28px;border-bottom:1px solid var(--col-border);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;background:var(--col-bg-1)}';
493 print '.status-marketing-nav .brand{display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--col-text);font-weight:700;font-size:17px}';
494 print '.status-marketing-nav .brand-logo{width:30px;height:30px;border-radius:7px;background:var(--col-brand-1);color:#fff;display:grid;place-items:center;font-weight:800;font-size:13px;letter-spacing:.5px}';
495 print '.status-marketing-nav .nav-links{display:flex;align-items:center;gap:18px}';
496 print '.status-marketing-nav .nav-links a{color:var(--col-text-dim);text-decoration:none;font-size:14px}';
497 print '.status-marketing-nav .nav-links a:hover{color:var(--col-text)}';
498 print '.status-marketing-nav .nav-cta{display:flex;align-items:center;gap:10px}';
499 print '.status-marketing-nav .btn-signin{color:var(--col-text);text-decoration:none;font-size:13px;padding:7px 14px;border:1px solid var(--col-border);border-radius:7px}';
500 print '.status-marketing-nav .btn-trial{color:#fff;background:var(--col-brand-1);text-decoration:none;font-size:13px;padding:7px 14px;border-radius:7px;font-weight:600}';
501 print '.status-marketing-nav .btn-signin:hover{border-color:var(--col-text-dim)}';
502 print '.status-marketing-nav .btn-trial:hover{background:var(--col-brand-2)}';
503 print '@media(max-width:640px){.status-marketing-nav .nav-links{display:none}}';
504 print '</style>';
505 print '</head><body style="background:var(--col-bg-1);color:var(--col-text);min-height:100vh;font-family:system-ui,-apple-system,sans-serif">';
506 print '<nav class="status-marketing-nav">';
507 print '<a class="brand" href="/"><span class="brand-logo">PT</span><span>PTMatrix</span></a>';
508 print '<div class="nav-links">';
509 print qq~<a href="$url->{features}">Features</a>~;
510 print qq~<a href="$url->{pricing}">Pricing</a>~;
511 print qq~<a href="$url->{about}">About</a>~;
512 print qq~<a href="$url->{contact}">Contact</a>~;
513 print '</div>';
514 print '<div class="nav-cta">';
515 print qq~<a class="btn-signin" href="$url->{login}">Sign in</a>~;
516 print qq~<a class="btn-trial" href="$url->{signup}">Start free trial</a>~;
517 print '</div>';
518 print '</nav>';
519 print $body;
520 print '</body></html>';
521 }
522}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help