Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/pricing.cgi
Diff

/var/www/vhosts/3dshawn.com/crm.3dshawn.com/pricing.cgi

added on local at 2026-07-01 15:03:00

Added
+262
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 41c3b8fdb894
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# ContactForge - Pricing page (logged-out marketing)
4#
5# Pulls live plan rows from billing_plans so the same data drives this
6# page, the landing-page pricing block, and the per-feature gate in
7# settings.cgi. Falls back to a built-in 4-plan list if the billing
8# schema hasn't been migrated yet so the marketing page can still
9# render on a fresh install.
10#======================================================================
11use strict;
12use warnings;
13
14use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com';
15use CGI;
16use MODS::Template;
17use MODS::DBConnect;
18use MODS::Login;
19use MODS::ContactForge::Config;
20use MODS::ContactForge::Wrapper;
21use MODS::ContactForge::Billing;
22
23use MODS::ContactForge::NoOp;
24my $q = CGI->new;
25my $auth = MODS::Login->new;
26my $wrap = MODS::ContactForge::Wrapper->new;
27my $tfile = MODS::Template->new;
28my $db = MODS::DBConnect->new;
29my $cfg = MODS::ContactForge::Config->new;
30my $bill = MODS::ContactForge::Billing->new;
31my $DB = $cfg->settings('database_name');
32
33$|=1;
34my $userinfo = $auth->login_verify();
35
36my $dbh = $db->db_connect();
37my @plans = _build_plan_cards($db, $dbh, $DB, $bill);
38
39# Active plan-target promotion (if any) flows into every pricing card
40# below + into the hero banner so visitors see the deal before they
41# pick a tier. Falls back to "no promo" when there isn't an active row.
42my $promo_obj = MODS::ContactForge::NoOp->new;
43my $active_promo = $promo_obj->active_plan_promo($db, $dbh, $DB);
44my $promo_code = ($active_promo && $active_promo->{code}) ? $active_promo->{code} : '';
45my $promo_label = $active_promo ? $promo_obj->marketing_label($active_promo) : '';
46my $promo_name = ($active_promo && $active_promo->{name}) ? $active_promo->{name} : '';
47
48# Per-card "with code WELCOME20 get 20% off your first invoice" line --
49# pre-baked here so the template just emits a single field per card.
50# Only the plans that match the promo's eligibility set get the badge;
51# an empty eligibility set means "every paid plan", so all paid cards
52# light up. Free is always excluded since there's nothing to discount.
53if ($active_promo) {
54 my @eligible = $promo_obj->eligible_plan_ids($db, $dbh, $DB, $active_promo->{id});
55 my %eligible_set = map { $_ => 1 } @eligible;
56 my $all_paid = !scalar(@eligible);
57 foreach my $c (@plans) {
58 next if $c->{is_free};
59 next unless $all_paid || $eligible_set{ $c->{id} || 0 };
60 $c->{promo_active} = 1;
61 $c->{promo_code} = $promo_code;
62 $c->{promo_label} = $promo_label;
63 $c->{promo_card_line} = $promo_label . ' on your first invoice with code <strong>' . $promo_code . '</strong>';
64 }
65}
66
67$db->db_disconnect($dbh);
68
69print "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";
70
71my $tvars = {
72 plans => \@plans,
73 has_plans => scalar(@plans) ? 1 : 0,
74 has_promo => $active_promo ? 1 : 0,
75 promo_code => $promo_code,
76 promo_label => $promo_label,
77 promo_name => $promo_name,
78};
79my $body = join('', $tfile->template('cf_pricing.html', $tvars, $userinfo));
80$wrap->render({ userinfo => $userinfo, title => 'Pricing', body => $body });
81
82#----------------------------------------------------------------------
83# Each card needs: tier, display_name, price_display, period_label,
84# tagline, cta_label, cta_href, is_featured, and a list of feature
85# bullets (name + included flag).
86sub _build_plan_cards {
87 my ($db, $dbh, $DB, $bill) = @_;
88 return _fallback_plans() unless $bill->schema_ready($db, $dbh, $DB);
89
90 my $entitle_ready = $bill->plan_features_ready($db, $dbh, $DB);
91 my @catalog = $bill->feature_catalog;
92 my %catalog_by_key = map { $_->{key} => $_ } @catalog;
93
94 # Post-consolidation: one row per tier. Monthly price is the
95 # headline; yearly comes from the same row via the yearly_* fields
96 # (compute_yearly_price_cents resolves percent/dollars/explicit).
97 my @rows = $bill->list_plans($db, $dbh, $DB);
98 return _fallback_plans() unless scalar @rows;
99
100 my @out;
101 foreach my $r (@rows) {
102 my @bullets = _parse_marketing_features($r->{features});
103 if (!scalar @bullets && $entitle_ready) {
104 my $set = $bill->plan_feature_set($db, $dbh, $DB, $r->{id});
105 foreach my $f (@catalog) {
106 push @bullets, {
107 name => $f->{name} . ' &mdash; ' . $f->{blurb},
108 included => $set->{ $f->{key} } ? 1 : 0,
109 };
110 }
111 }
112
113 my $yearly_cents = $bill->compute_yearly_price_cents($r);
114 my $annual_note = '';
115 my $has_annual_note = 0;
116 if ($yearly_cents > 0) {
117 # Compute the contact-facing "savings" tail. Months-free is
118 # only meaningful when the yearly price is below 12 months
119 # of the monthly price.
120 my $monthly_12 = int($r->{price_cents} || 0) * 12;
121 my $savings = $monthly_12 - $yearly_cents;
122 my $months_free = $monthly_12 > 0 ? int($savings * 12 / $monthly_12) : 0;
123 my $tail = '';
124 if ($months_free >= 2) {
125 $tail = " ($months_free months free)";
126 } elsif ($savings > 0) {
127 $tail = ' (save ' . _fmt_price_card($savings, $r->{currency}) . ')';
128 }
129 $annual_note = 'or ' . _fmt_price_card($yearly_cents, $r->{currency}) . ' / yr' . $tail;
130 $has_annual_note = 1;
131 } elsif (!$r->{price_cents}) {
132 $annual_note = 'forever &middot; no card required';
133 $has_annual_note = 1;
134 }
135
136 my $is_enterprise = $r->{is_enterprise} ? 1 : 0;
137 push @out, {
138 id => $r->{id},
139 tier => $r->{tier},
140 display_name => $r->{display_name} || ucfirst($r->{tier} || ''),
141 tagline => $r->{tagline} || '',
142 has_tagline => $r->{tagline} ? 1 : 0,
143 price_display => $is_enterprise ? 'Contact us'
144 : _fmt_price_card($r->{price_cents}, $r->{currency}),
145 is_free => 0,
146 is_enterprise => $is_enterprise,
147 not_enterprise => $is_enterprise ? 0 : 1,
148 period_label => $is_enterprise ? '' : 'mo',
149 annual_note => $is_enterprise ? '' : $annual_note,
150 has_annual_note => $is_enterprise ? 0 : $has_annual_note,
151 cta_label => $r->{cta_label}
152 || ($is_enterprise ? 'Contact sales' : 'Start 14-day free trial'),
153 cta_href => $is_enterprise
154 ? 'mailto:sales@3dshawn.com?subject=ContactForge%20Enterprise%20inquiry'
155 : ('signup.cgi?plan=' . _safe_slug($r->{tier})),
156 is_featured => $r->{is_featured} ? 1 : 0,
157 not_featured => $r->{is_featured} ? 0 : 1,
158 bullets_html => _render_bullets(\@bullets),
159 };
160 }
161 return @out;
162}
163
164# Pre-render bullet <li> HTML in Perl so the template can emit it as
165# a single variable -- side-steps the engine's nested-loop quirks.
166sub _render_bullets {
167 my ($aref) = @_;
168 my $html = '';
169 foreach my $b (@{ $aref || [] }) {
170 my $name = defined $b->{name} ? $b->{name} : '';
171 if ($b->{included}) {
172 $html .= '<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">'
173 . '<polyline points="20 6 9 17 4 12"/></svg> ' . $name . '</li>';
174 } else {
175 $html .= '<li class="dim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">'
176 . '<line x1="6" y1="6" x2="18" y2="18"/><line x1="6" y1="18" x2="18" y2="6"/></svg> '
177 . $name . '</li>';
178 }
179 }
180 return $html;
181}
182
183sub _parse_marketing_features {
184 my ($raw) = @_;
185 return () unless defined $raw && $raw =~ /\S/;
186 # Minimal JSON: [{"name":"X","included":true},...]. We control the
187 # writes, so a tolerant regex parse is enough -- no JSON dep needed.
188 my @out;
189 while ($raw =~ m/\{\s*"name"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"included"\s*:\s*(true|false|0|1)\s*\}/gi) {
190 my $name = $1; my $inc = lc $2;
191 $name =~ s/\\"/"/g; $name =~ s/\\\\/\\/g;
192 push @out, {
193 name => $name,
194 included => ($inc eq 'true' || $inc eq '1') ? 1 : 0,
195 };
196 }
197 return @out;
198}
199
200sub _fmt_price_card {
201 my ($cents, $currency) = @_;
202 $cents = 0 unless defined $cents;
203 # Plain '$'; the value is interpolated as data at eval time, NOT
204 # re-parsed as template syntax, so no \-escape needed.
205 if ($cents % 100 == 0) {
206 return '$' . int($cents / 100);
207 }
208 return '$' . sprintf('%.2f', $cents / 100);
209}
210
211sub _safe_slug {
212 my $s = lc(shift || '');
213 $s =~ s/[^a-z0-9_\-]//g;
214 return $s;
215}
216
217# Fresh-install fallback used when billing tables aren't migrated yet.
218# Mirrors the post pricing-v4 shape: 2 paid tiers + Enterprise "Contact us".
219sub _fallback_plans {
220 return (
221 { tier=>'pro', display_name=>'Pro',
222 tagline=>'Everything a growing sales team needs.', has_tagline=>1,
223 price_display=>'$29', is_free=>0, period_label=>'mo',
224 is_enterprise=>0, not_enterprise=>1,
225 annual_note=>'or $290 / yr (2 months free)', has_annual_note=>1,
226 cta_label=>'Start 14-day free trial', cta_href=>'signup.cgi?plan=pro',
227 is_featured=>1, not_featured=>0,
228 bullets_html=>_render_bullets([
229 { name=>'Up to 25,000 contacts', included=>1 },
230 { name=>'5 team seats', included=>1 },
231 { name=>'Unlimited pipelines', included=>1 },
232 { name=>'5,000 campaign sends / month', included=>1 },
233 { name=>'Connect your own SMTP (SES, Postmark, SendGrid, ...)', included=>1 },
234 { name=>'All third-party integrations (Gmail, Outlook, Zapier, ...)', included=>1 },
235 { name=>'Automation workflows', included=>1 },
236 { name=>'Shared team inbox', included=>1 },
237 { name=>'API access', included=>0 },
238 { name=>'Custom roles + permissions', included=>0 },
239 { name=>'Audit log export', included=>0 },
240 { name=>'SSO', included=>0 },
241 ]) },
242 { tier=>'business', display_name=>'Business',
243 tagline=>'For growing sales teams that need real scale.', has_tagline=>1,
244 price_display=>'$99', is_free=>0, period_label=>'mo',
245 is_enterprise=>0, not_enterprise=>1,
246 annual_note=>'or $990 / yr (2 months free)', has_annual_note=>1,
247 cta_label=>'Start 14-day free trial', cta_href=>'signup.cgi?plan=business',
248 is_featured=>0, not_featured=>1,
249 bullets_html=>_render_bullets([
250 { name=>'<strong>Everything in Pro</strong>', included=>1 },
251 { name=>'Up to 100,000 contacts', included=>1 },
252 { name=>'Up to 25 team seats', included=>1 },
253 { name=>'50,000 campaign sends / month', included=>1 },
254 { name=>'Per-seat overage if you grow ($20/seat)', included=>1 },
255 { name=>'API access', included=>1 },
256 { name=>'Custom roles + permissions', included=>1 },
257 { name=>'Audit log export', included=>1 },
258 { name=>'SSO ready (SAML)', included=>1 },
259 { name=>'Priority support (same business day)', included=>1 },
260 ]) },
261 );
262}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help