added on local at 2026-07-01 15:02:47
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # ContactForge - Customer Relationship Platform landing page | |
| 4 | # Page body lives in TEMPLATES/cf_index.html | |
| 5 | # | |
| 6 | # Pricing block on this page pulls from billing_plans so it stays in | |
| 7 | # lockstep with /pricing.cgi and the admin Packages console. Falls | |
| 8 | # back to a built-in 4-tier list if the billing schema hasn't been | |
| 9 | # migrated yet. | |
| 10 | # | |
| 11 | # Also dispatches the small marketing pages via SCRIPT_NAME (Group 6 | |
| 12 | # merge, 2026-07): | |
| 13 | # /about.cgi /contact.cgi /faq.cgi /features.cgi | |
| 14 | # /help.cgi /privacy.cgi /terms.cgi /why.cgi | |
| 15 | # Each of those .cgi files is a 3-line wrapper that `do`s this file. | |
| 16 | #====================================================================== | |
| 17 | use strict; | |
| 18 | use warnings; | |
| 19 | ||
| 20 | use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com'; | |
| 21 | use CGI; | |
| 22 | use MODS::Template; | |
| 23 | use MODS::DBConnect; | |
| 24 | use MODS::Login; | |
| 25 | use MODS::ContactForge::Config; | |
| 26 | use MODS::ContactForge::Wrapper; | |
| 27 | use MODS::ContactForge::Billing; | |
| 28 | ||
| 29 | use MODS::ContactForge::NoOp; | |
| 30 | my $q = CGI->new; | |
| 31 | my $form = $q->Vars; | |
| 32 | my $auth = MODS::Login->new; | |
| 33 | my $wrap = MODS::ContactForge::Wrapper->new; | |
| 34 | my $tfile = MODS::Template->new; | |
| 35 | my $db = MODS::DBConnect->new; | |
| 36 | my $cfg = MODS::ContactForge::Config->new; | |
| 37 | my $bill = MODS::ContactForge::Billing->new; | |
| 38 | my $DB = $cfg->settings('database_name'); | |
| 39 | ||
| 40 | $|=1; | |
| 41 | my $userinfo = $auth->login_verify(); # may be undef on marketing pages | |
| 42 | ||
| 43 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'index'; | |
| 44 | ||
| 45 | if ($entry eq 'about') { _mkt_render_about($userinfo); exit; } | |
| 46 | if ($entry eq 'contact') { _mkt_render_contact($userinfo); exit; } | |
| 47 | if ($entry eq 'faq') { _mkt_render_faq($userinfo); exit; } | |
| 48 | if ($entry eq 'features') { _mkt_render_features($userinfo); exit; } | |
| 49 | if ($entry eq 'help') { _mkt_render_help($userinfo); exit; } | |
| 50 | if ($entry eq 'privacy') { _mkt_render_privacy($userinfo); exit; } | |
| 51 | if ($entry eq 'terms') { _mkt_render_terms($userinfo); exit; } | |
| 52 | if ($entry eq 'why') { _mkt_render_why($userinfo); exit; } | |
| 53 | ||
| 54 | my $dbh = $db->db_connect(); | |
| 55 | my @plans = _build_landing_plan_cards($db, $dbh, $DB, $bill); | |
| 56 | ||
| 57 | # Active plan promo for the marketing banner above the pricing block. | |
| 58 | my $promo_obj = MODS::ContactForge::NoOp->new; | |
| 59 | my $active_promo = $promo_obj->active_plan_promo($db, $dbh, $DB); | |
| 60 | my $promo_code = ($active_promo && $active_promo->{code}) ? $active_promo->{code} : ''; | |
| 61 | my $promo_label = $active_promo ? $promo_obj->marketing_label($active_promo) : ''; | |
| 62 | ||
| 63 | # Per-card eligibility badge (same logic as /pricing.cgi). Cards the | |
| 64 | # active promo doesn't cover (free + plans outside the eligibility | |
| 65 | # set) simply don't render the promo line. | |
| 66 | if ($active_promo) { | |
| 67 | my @eligible = $promo_obj->eligible_plan_ids($db, $dbh, $DB, $active_promo->{id}); | |
| 68 | my %eligible_set = map { $_ => 1 } @eligible; | |
| 69 | my $all_paid = !scalar(@eligible); | |
| 70 | foreach my $c (@plans) { | |
| 71 | next if ($c->{price_display} || '$0') eq '$0'; | |
| 72 | next unless $all_paid || $eligible_set{ $c->{id} || 0 }; | |
| 73 | $c->{promo_active} = 1; | |
| 74 | $c->{promo_code} = $promo_code; | |
| 75 | $c->{promo_label} = $promo_label; | |
| 76 | $c->{promo_card_line} = $promo_label . ' with code <strong>' . $promo_code . '</strong>'; | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | $db->db_disconnect($dbh); | |
| 81 | ||
| 82 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 83 | ||
| 84 | my $tvars = { | |
| 85 | user_id => $userinfo ? $userinfo->{user_id} : '', | |
| 86 | plans => \@plans, | |
| 87 | has_plans => scalar(@plans) ? 1 : 0, | |
| 88 | has_promo => $active_promo ? 1 : 0, | |
| 89 | promo_code => $promo_code, | |
| 90 | promo_label => $promo_label, | |
| 91 | }; | |
| 92 | ||
| 93 | my $body = join('', $tfile->template('cf_index.html', $tvars, $userinfo)); | |
| 94 | ||
| 95 | $wrap->render({ | |
| 96 | userinfo => $userinfo, | |
| 97 | title => 'Every customer touchpoint in one shared CRM', | |
| 98 | body => $body, | |
| 99 | }); | |
| 100 | ||
| 101 | #---------------------------------------------------------------------- | |
| 102 | # Compact card shape used by the landing pricing grid (4 cards, one | |
| 103 | # per tier, monthly price). Bullets are pre-rendered to side-step the | |
| 104 | # template engine's nested-loop quirks. | |
| 105 | sub _build_landing_plan_cards { | |
| 106 | my ($db, $dbh, $DB, $bill) = @_; | |
| 107 | return _landing_fallback_plans() unless $bill->schema_ready($db, $dbh, $DB); | |
| 108 | ||
| 109 | my @rows = $bill->list_plans($db, $dbh, $DB); | |
| 110 | return _landing_fallback_plans() unless scalar @rows; | |
| 111 | ||
| 112 | my $entitle_ready = $bill->plan_features_ready($db, $dbh, $DB); | |
| 113 | my @catalog = $bill->feature_catalog; | |
| 114 | ||
| 115 | my %by_tier; | |
| 116 | foreach my $r (@rows) { push @{ $by_tier{ $r->{tier} } }, $r; } | |
| 117 | ||
| 118 | my @tiers_seen; | |
| 119 | foreach my $r (@rows) { | |
| 120 | next if grep { $_ eq $r->{tier} } @tiers_seen; | |
| 121 | push @tiers_seen, $r->{tier}; | |
| 122 | } | |
| 123 | ||
| 124 | my @out; | |
| 125 | foreach my $tier (@tiers_seen) { | |
| 126 | my $monthly = (grep { ($_->{cadence}||'') eq 'monthly' } @{ $by_tier{$tier} })[0] | |
| 127 | || $by_tier{$tier}->[0]; | |
| 128 | next unless $monthly; | |
| 129 | ||
| 130 | # Build a short bullet list. Prefer the marketing copy supplied | |
| 131 | # by the admin; otherwise synthesise from entitlements so the | |
| 132 | # card never renders empty on a fresh install. | |
| 133 | my @bullets = _parse_features_for_landing($monthly->{features}); | |
| 134 | if (!scalar @bullets && $entitle_ready) { | |
| 135 | my $set = $bill->plan_feature_set($db, $dbh, $DB, $monthly->{id}); | |
| 136 | foreach my $f (@catalog) { | |
| 137 | next unless $set->{ $f->{key} }; | |
| 138 | push @bullets, $f->{name} . ' — ' . $f->{blurb}; | |
| 139 | } | |
| 140 | } | |
| 141 | # Cap to 5 bullets to match the original card visual density. | |
| 142 | @bullets = @bullets[0 .. ( $#bullets > 4 ? 4 : $#bullets )]; | |
| 143 | my $bullets_html = ''; | |
| 144 | foreach my $b (@bullets) { | |
| 145 | $bullets_html .= '<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">' | |
| 146 | . '<polyline points="20 6 9 17 4 12"/></svg> ' . $b . '</li>'; | |
| 147 | } | |
| 148 | ||
| 149 | my $tier_slug = lc($monthly->{tier} || ''); $tier_slug =~ s/[^a-z0-9_\-]//g; | |
| 150 | ||
| 151 | my $is_ent = $monthly->{is_enterprise} ? 1 : 0; | |
| 152 | push @out, { | |
| 153 | id => $monthly->{id}, | |
| 154 | tier => $monthly->{tier}, | |
| 155 | display_name => $monthly->{display_name} || ucfirst($monthly->{tier} || ''), | |
| 156 | price_display => $is_ent ? 'Contact us' : _fmt_price_short($monthly->{price_cents}), | |
| 157 | is_enterprise => $is_ent, | |
| 158 | not_enterprise => $is_ent ? 0 : 1, | |
| 159 | is_featured => $monthly->{is_featured} ? 1 : 0, | |
| 160 | not_featured => $monthly->{is_featured} ? 0 : 1, | |
| 161 | tagline => $monthly->{tagline} || '', | |
| 162 | has_tagline => $monthly->{tagline} ? 1 : 0, | |
| 163 | cta_label => $monthly->{cta_label} | |
| 164 | || ($is_ent ? 'Contact sales' : 'Start 14-day free trial'), | |
| 165 | cta_href => $is_ent | |
| 166 | ? 'mailto:sales@3dshawn.com?subject=ContactForge%20Enterprise%20inquiry' | |
| 167 | : ('signup.cgi?plan=' . $tier_slug), | |
| 168 | bullets_html => $bullets_html, | |
| 169 | }; | |
| 170 | } | |
| 171 | return @out; | |
| 172 | } | |
| 173 | ||
| 174 | sub _fmt_price_short { | |
| 175 | my ($cents) = @_; | |
| 176 | $cents = 0 unless defined $cents; | |
| 177 | if ($cents % 100 == 0) { | |
| 178 | return '$' . int($cents / 100); | |
| 179 | } | |
| 180 | return '$' . sprintf('%.2f', $cents / 100); | |
| 181 | } | |
| 182 | ||
| 183 | sub _parse_features_for_landing { | |
| 184 | my ($raw) = @_; | |
| 185 | return () unless defined $raw && $raw =~ /\S/; | |
| 186 | my @out; | |
| 187 | while ($raw =~ m/\{\s*"name"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"included"\s*:\s*(true|false|0|1)\s*\}/gi) { | |
| 188 | my $name = $1; my $inc = lc $2; | |
| 189 | $name =~ s/\\"/"/g; $name =~ s/\\\\/\\/g; | |
| 190 | # Landing block only shows the positive bullets. | |
| 191 | push @out, $name if $inc eq 'true' || $inc eq '1'; | |
| 192 | } | |
| 193 | return @out; | |
| 194 | } | |
| 195 | ||
| 196 | sub _landing_fallback_plans { | |
| 197 | my $li = sub { | |
| 198 | my @items = @_; | |
| 199 | my $h = ''; | |
| 200 | foreach my $t (@items) { | |
| 201 | $h .= '<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">' | |
| 202 | . '<polyline points="20 6 9 17 4 12"/></svg> ' . $t . '</li>'; | |
| 203 | } | |
| 204 | return $h; | |
| 205 | }; | |
| 206 | # Post pricing-v4 (2026-06): Pro + Business + Enterprise. | |
| 207 | return ( | |
| 208 | { tier=>'pro', display_name=>'Pro', price_display=>'$29', | |
| 209 | is_enterprise=>0, not_enterprise=>1, | |
| 210 | is_featured=>1, not_featured=>0, | |
| 211 | tagline=>'Everything a growing sales team needs.', | |
| 212 | has_tagline=>1, cta_label=>'Start 14-day free trial', cta_href=>'signup.cgi?plan=pro', | |
| 213 | bullets_html => $li->('Up to 25,000 contacts', '5 team seats', 'Unlimited pipelines', '5,000 campaign sends / month', 'Connect your own SMTP') }, | |
| 214 | { tier=>'business', display_name=>'Business', price_display=>'$99', | |
| 215 | is_enterprise=>0, not_enterprise=>1, | |
| 216 | is_featured=>0, not_featured=>1, | |
| 217 | tagline=>'For growing sales teams that need real scale.', | |
| 218 | has_tagline=>1, cta_label=>'Start 14-day free trial', cta_href=>'signup.cgi?plan=business', | |
| 219 | bullets_html => $li->('Up to 100,000 contacts', 'Up to 25 team seats', '50,000 campaign sends / month', 'API access + SSO + audit log export', 'Priority support') }, | |
| 220 | ); | |
| 221 | } | |
| 222 | ||
| 223 | #====================================================================== | |
| 224 | # Marketing page renderers (Group 6 merge) | |
| 225 | # Each corresponds to a former standalone .cgi (about/contact/faq/ | |
| 226 | # features/help/privacy/terms/why). All use the same header + | |
| 227 | # template + wrapper pattern. help.cgi is the only one that requires | |
| 228 | # login. | |
| 229 | #====================================================================== | |
| 230 | sub _mkt_render_about { | |
| 231 | my ($userinfo) = @_; | |
| 232 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 233 | my $body = join('', $tfile->template('cf_about.html', {}, $userinfo)); | |
| 234 | $wrap->render({ userinfo => $userinfo, title => 'About', body => $body }); | |
| 235 | return; | |
| 236 | } | |
| 237 | ||
| 238 | sub _mkt_render_contact { | |
| 239 | my ($userinfo) = @_; | |
| 240 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 241 | my $body = join('', $tfile->template('cf_contact.html', {}, $userinfo)); | |
| 242 | $wrap->render({ userinfo => $userinfo, title => 'Contact', body => $body }); | |
| 243 | return; | |
| 244 | } | |
| 245 | ||
| 246 | sub _mkt_render_faq { | |
| 247 | my ($userinfo) = @_; | |
| 248 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 249 | my $body = join('', $tfile->template('cf_faq.html', {}, $userinfo)); | |
| 250 | $wrap->render({ userinfo => $userinfo, title => 'FAQ', body => $body }); | |
| 251 | return; | |
| 252 | } | |
| 253 | ||
| 254 | sub _mkt_render_features { | |
| 255 | my ($userinfo) = @_; | |
| 256 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 257 | my $body = join('', $tfile->template('cf_features.html', {}, $userinfo)); | |
| 258 | $wrap->render({ userinfo => $userinfo, title => 'Features', body => $body }); | |
| 259 | return; | |
| 260 | } | |
| 261 | ||
| 262 | # help.cgi is login-gated (topbar help icon target); redirect anon | |
| 263 | # visitors to /login.cgi. | |
| 264 | sub _mkt_render_help { | |
| 265 | my ($userinfo) = @_; | |
| 266 | if (!$userinfo) { | |
| 267 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 268 | return; | |
| 269 | } | |
| 270 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 271 | my $body = join('', $tfile->template('cf_help.html', {}, $userinfo)); | |
| 272 | $wrap->render({ | |
| 273 | userinfo => $userinfo, | |
| 274 | page_key => '', | |
| 275 | title => 'Help', | |
| 276 | body => $body, | |
| 277 | }); | |
| 278 | return; | |
| 279 | } | |
| 280 | ||
| 281 | sub _mkt_render_privacy { | |
| 282 | my ($userinfo) = @_; | |
| 283 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 284 | my $body = join('', $tfile->template('cf_privacy.html', {}, $userinfo)); | |
| 285 | $wrap->render({ userinfo => $userinfo, title => 'Privacy Policy', body => $body }); | |
| 286 | return; | |
| 287 | } | |
| 288 | ||
| 289 | sub _mkt_render_terms { | |
| 290 | my ($userinfo) = @_; | |
| 291 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 292 | my $body = join('', $tfile->template('cf_terms.html', {}, $userinfo)); | |
| 293 | $wrap->render({ userinfo => $userinfo, title => 'Terms of Service', body => $body }); | |
| 294 | return; | |
| 295 | } | |
| 296 | ||
| 297 | sub _mkt_render_why { | |
| 298 | my ($userinfo) = @_; | |
| 299 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 300 | my $body = join('', $tfile->template('cf_why.html', {}, $userinfo)); | |
| 301 | $wrap->render({ userinfo => $userinfo, title => 'Why ContactForge', body => $body }); | |
| 302 | return; | |
| 303 | } |