added on local at 2026-07-01 12:34:14
| 1 | #!/usr/bin/perl | |
| 2 | ||
| 3 | ################################################################################################################################## | |
| 4 | # ______ ____ ____ ______ ______ ____ ___ __ ___ ______ _ __ ____ ____ __ __ _ __ _____ ____ | |
| 5 | # / ____// __ \ / __ \ / ____/ / ____// __ \ / | / |/ // ____/| | / // __ \ / __ \ / //_/ | | / /|__ / / __ \ | |
| 6 | # / / / / / // / / // __/ / /_ / /_/ // /| | / /|_/ // __/ | | /| / // / / // /_/ // ,< | | / / /_ < / / / / | |
| 7 | # / /___ / /_/ // /_/ // /___ / __/ / _, _// ___ | / / / // /___ | |/ |/ // /_/ // _, _// /| | | |/ / ___/ /_ / /_/ / | |
| 8 | # \____/ \____//_____//_____/ /_/ /_/ |_|/_/ |_|/_/ /_//_____/ |__/|__/ \____//_/ |_|/_/ |_| |___/ /____/(_)\____/ | |
| 9 | # | |
| 10 | # - Written by: Shawn Pickering | |
| 11 | # - shawn@shawnpickering.com | |
| 12 | ################################################################################################################################## | |
| 13 | use strict; | |
| 14 | use lib '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com'; | |
| 15 | ||
| 16 | ||
| 17 | ############################################################# | |
| 18 | ## Variables | |
| 19 | ############################################################# | |
| 20 | my $contact_log = '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/uploads/_contact.log'; | |
| 21 | ||
| 22 | ||
| 23 | ||
| 24 | ############################################################ | |
| 25 | # Modules | |
| 26 | ############################################################ | |
| 27 | use CGI; my $query = new CGI; my $form = $query->Vars; | |
| 28 | use MODS::Login; my $login = new MODS::Login; | |
| 29 | use MODS::PTMatrix::Urls; my $getlist = new MODS::PTMatrix::Urls; my $url = $getlist->urls(); | |
| 30 | use MODS::Template; my $tfile = new MODS::Template; | |
| 31 | use MODS::PTMatrix::Wrapper; my $load = new MODS::PTMatrix::Wrapper; | |
| 32 | use MODS::DBConnect; my $db = new MODS::DBConnect; | |
| 33 | use MODS::PTMatrix::Config; my $config = new MODS::PTMatrix::Config; my $database_name = $config->settings('database_name'); | |
| 34 | use MODS::PTMatrix::PM; | |
| 35 | ||
| 36 | ||
| 37 | ||
| 38 | ############################################################# | |
| 39 | ## Get form data | |
| 40 | ############################################################# | |
| 41 | foreach 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; | |
| 52 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'index'; | |
| 53 | my $userinfo = $login->login_verify(); | |
| 54 | ||
| 55 | if($entry eq 'status'){ | |
| 56 | &status_page; | |
| 57 | exit; | |
| 58 | } | |
| 59 | ||
| 60 | print qq~Content-type:text/html; Cache-Control:no-cache;\n\n~; | |
| 61 | ||
| 62 | if($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 | ############################################################ | |
| 92 | sub 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 | ||
| 110 | sub 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 | ||
| 121 | sub 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 | ||
| 131 | sub 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 | ||
| 149 | sub 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 | ||
| 178 | sub faq_body_html{ | |
| 179 | my $body = shift // ''; | |
| 180 | $body =~ s/\r//g; | |
| 181 | $body =~ s/&/&/g; | |
| 182 | $body =~ s/</</g; | |
| 183 | $body =~ s/>/>/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 | ||
| 205 | sub 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 | ||
| 216 | sub 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 | ||
| 231 | sub 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 | ||
| 250 | sub 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 | ||
| 265 | sub 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> | |
| 293 | PRIVACY | |
| 294 | } | |
| 295 | ||
| 296 | sub 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 | ||
| 311 | sub 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> | |
| 338 | TERMS | |
| 339 | } | |
| 340 | ||
| 341 | #============================================================ | |
| 342 | # status entry: public status page (self-manages Content-Type) | |
| 343 | #============================================================ | |
| 344 | sub 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 | ||
| 356 | sub 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 | ||
| 366 | sub 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 | ||
| 375 | sub status_md_to_html{ | |
| 376 | my $body = shift // ''; | |
| 377 | $body =~ s/\r//g; | |
| 378 | $body =~ s/&/&/g; $body =~ s/</</g; $body =~ s/>/>/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 | ||
| 390 | sub 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} ? ' · 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 | } |