added on local at 2026-07-01 15:03:02
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # ContactForge — signup | |
| 4 | # Page body lives in TEMPLATES/cf_signup.html | |
| 5 | # | |
| 6 | # GET : show the signup form | |
| 7 | # POST : create user, mint session, set cookie, 302 to /dashboard.cgi | |
| 8 | # on failure, re-render the form with an error | |
| 9 | #====================================================================== | |
| 10 | use strict; | |
| 11 | use warnings; | |
| 12 | ||
| 13 | use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com'; | |
| 14 | use CGI; | |
| 15 | use MODS::Template; | |
| 16 | use MODS::DBConnect; | |
| 17 | use MODS::Login; | |
| 18 | use MODS::ContactForge::Config; | |
| 19 | use MODS::ContactForge::Wrapper; | |
| 20 | use MODS::ContactForge::Billing; | |
| 21 | use MODS::ContactForge::EmailVerify; | |
| 22 | ||
| 23 | my $q = CGI->new; | |
| 24 | my $form = $q->Vars; | |
| 25 | my $auth = MODS::Login->new; | |
| 26 | my $wrap = MODS::ContactForge::Wrapper->new; | |
| 27 | my $tfile = MODS::Template->new; | |
| 28 | my $db = MODS::DBConnect->new; | |
| 29 | my $cfg = MODS::ContactForge::Config->new; | |
| 30 | my $bill = MODS::ContactForge::Billing->new; | |
| 31 | my $DB = $cfg->settings('database_name'); | |
| 32 | ||
| 33 | $|=1; | |
| 34 | ||
| 35 | if (my $existing = $auth->login_verify()) { | |
| 36 | print "Status: 302 Found\nLocation: /dashboard.cgi\n\n"; | |
| 37 | exit; | |
| 38 | } | |
| 39 | ||
| 40 | my $error_msg = ''; | |
| 41 | my $email_val = ''; | |
| 42 | my $name_val = ''; | |
| 43 | # Default to the plan slug passed via ?plan= from the pricing cards. | |
| 44 | # Post pricing-v3: only pro|business are valid; coerce anything else to pro. | |
| 45 | my $plan_val = $form->{plan} || $form->{plan_tier} || 'pro'; | |
| 46 | $plan_val =~ s/[^a-z0-9_\-]//gi; | |
| 47 | $plan_val = lc $plan_val; | |
| 48 | $plan_val = 'pro' unless ($plan_val eq 'pro' || $plan_val eq 'business'); | |
| 49 | ||
| 50 | if (($ENV{REQUEST_METHOD} || '') eq 'POST') { | |
| 51 | my $email = $form->{email} || ''; | |
| 52 | my $pass = $form->{password} || ''; | |
| 53 | my $name = $form->{display_name} || ''; | |
| 54 | my $plan = lc($form->{plan_tier} || $form->{plan} || 'pro'); | |
| 55 | $plan =~ s/[^a-z0-9_\-]//g; | |
| 56 | $plan = 'pro' unless ($plan eq 'pro' || $plan eq 'business'); | |
| 57 | ||
| 58 | $email_val = _h($email); | |
| 59 | $name_val = _h($name); | |
| 60 | $plan_val = _h($plan); | |
| 61 | ||
| 62 | if ($email !~ /^[^\@\s]+\@[^\@\s]+\.[^\@\s]+$/) { | |
| 63 | $error_msg = 'Please enter a valid email address.'; | |
| 64 | } elsif (length($pass) < 8) { | |
| 65 | $error_msg = 'Password must be at least 8 characters.'; | |
| 66 | } else { | |
| 67 | my $user_id = $auth->signup($email, $pass, $name, $plan); | |
| 68 | if ($user_id) { | |
| 69 | # Post pricing-v3: every signup starts a 14-day free trial, | |
| 70 | # capped at 250 contacts during the trial. trial_ends_at / | |
| 71 | # trial_contacts_cap are USER-level state; the chosen tier | |
| 72 | # is what they'll be charged after day 14. We also re-pin | |
| 73 | # plan_tier in case auth->signup() left it on the column | |
| 74 | # default (defensive -- harmless if signup already set it). | |
| 75 | eval { | |
| 76 | my $dbh_t = $db->db_connect(); | |
| 77 | my $cap = $cfg->settings('plan_trial_contacts_cap') || 250; | |
| 78 | my $days = $cfg->settings('plan_trial_days') || 14; | |
| 79 | my $uid_q = $user_id; $uid_q =~ s/[^0-9]//g; | |
| 80 | my $cap_q = $cap; $cap_q =~ s/[^0-9]//g; $cap_q = 250 unless $cap_q; | |
| 81 | my $days_q= $days; $days_q =~ s/[^0-9]//g; $days_q = 14 unless $days_q; | |
| 82 | my $plan_safe = $plan; $plan_safe =~ s/[^a-z]//g; | |
| 83 | $db->db_readwrite($dbh_t, qq~ | |
| 84 | UPDATE `${DB}`.users | |
| 85 | SET plan_tier='$plan_safe', | |
| 86 | trial_ends_at=DATE_ADD(NOW(), INTERVAL $days_q DAY), | |
| 87 | trial_contacts_cap=$cap_q | |
| 88 | WHERE id='$uid_q' | |
| 89 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 90 | $db->db_disconnect($dbh_t); | |
| 91 | }; | |
| 92 | ||
| 93 | # Seed sample CRM data so the dashboard isn't empty on first login. | |
| 94 | eval { | |
| 95 | require MODS::ContactForge::Onboarding; | |
| 96 | my $dbh3 = $db->db_connect(); | |
| 97 | MODS::ContactForge::Onboarding->new->seed_sample_data($db, $dbh3, $DB, $user_id); | |
| 98 | $db->db_disconnect($dbh3); | |
| 99 | }; | |
| 100 | ||
| 101 | # Fire-and-forget the verification email. The user is | |
| 102 | # signed in immediately either way (we don't gate the | |
| 103 | # platform behind verification); a bounce or mailer-down | |
| 104 | # condition just means they'll see a "Resend verification" | |
| 105 | # button on /profile.cgi until they confirm. Wrapped in | |
| 106 | # eval so a SMTP outage never blocks signup. | |
| 107 | eval { | |
| 108 | my $dbh2 = $db->db_connect(); | |
| 109 | MODS::ContactForge::EmailVerify->send_for_user($db, $dbh2, $DB, { | |
| 110 | id => $user_id, | |
| 111 | email => $email, | |
| 112 | display_name => $name, | |
| 113 | }); | |
| 114 | $db->db_disconnect($dbh2); | |
| 115 | }; | |
| 116 | ||
| 117 | my ($userinfo, $token) = $auth->login_exec($email, $pass); | |
| 118 | if ($userinfo && $token) { | |
| 119 | print $auth->set_cookie_header($token); | |
| 120 | # Brand-new accounts land on /dashboard.cgi -- the | |
| 121 | # in-app home, with KPIs + tiles. Sample data seeding | |
| 122 | # above ensures the page isn't empty on day zero. | |
| 123 | print "Status: 302 Found\nLocation: /dashboard.cgi\n\n"; | |
| 124 | exit; | |
| 125 | } | |
| 126 | $error_msg = 'Account created — please sign in.'; | |
| 127 | } else { | |
| 128 | $error_msg = 'That email is already in use, or an account error occurred.'; | |
| 129 | } | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | 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"; | |
| 134 | ||
| 135 | # Build the plan <option> list from billing_plans (admin-managed). One | |
| 136 | # entry per unique tier; we prefer the monthly price for the label. | |
| 137 | # Falls back to a built-in set when the schema isn't migrated. | |
| 138 | my @plan_options = _build_plan_options($db, $bill, $plan_val); | |
| 139 | ||
| 140 | my $tvars = { | |
| 141 | error_msg => $error_msg, | |
| 142 | email_val => $email_val, | |
| 143 | name_val => $name_val, | |
| 144 | plan_options => \@plan_options, | |
| 145 | }; | |
| 146 | ||
| 147 | my $body = join('', $tfile->template('cf_signup.html', $tvars, undef)); | |
| 148 | ||
| 149 | ||
| 150 | # === Maintenance lockdown overlay (auto-injected 2026-06-23) === | |
| 151 | # When maintenance_locked=1, render a modal over the login form. | |
| 152 | # X-close stays visible so an admin can sign in. Non-admin login | |
| 153 | # attempts fail and the page reloads, restoring the overlay. | |
| 154 | { | |
| 155 | my $_mcfg = MODS::ContactForge::Config->new; | |
| 156 | if ($_mcfg->settings('maintenance_locked')) { | |
| 157 | my $_mmsg = $_mcfg->settings('maintenance_message') || '<p>The system is in maintenance mode.</p>'; | |
| 158 | my $_overlay = qq{<div id="maint-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,0.88);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px"><div style="position:relative;background:#0f0f0f;border:1px solid rgba(217,119,6,0.6);border-radius:12px;max-width:680px;width:100%;padding:48px 40px;box-shadow:0 30px 90px rgba(0,0,0,0.6),0 0 0 1px rgba(217,119,6,0.15)"><button type="button" aria-label="Close" onclick="document.getElementById('maint-overlay').style.display='none'" style="position:absolute;top:14px;right:14px;width:36px;height:36px;background:transparent;border:1px solid rgba(255,255,255,0.12);color:#aaa;font-size:22px;line-height:1;border-radius:8px;cursor:pointer">×</button>$_mmsg</div><script>document.addEventListener('keydown',function(e){if(e.key==='Escape'){var o=document.getElementById('maint-overlay');if(o)o.style.display='none';}});</script></div>}; | |
| 159 | $body = $_overlay . $body; | |
| 160 | } | |
| 161 | } | |
| 162 | # === End maintenance lockdown overlay === | |
| 163 | $wrap->render({ | |
| 164 | userinfo => undef, | |
| 165 | title => 'Start your trial', | |
| 166 | body => $body, | |
| 167 | }); | |
| 168 | ||
| 169 | #---------------------------------------------------------------------- | |
| 170 | sub _h { | |
| 171 | my $s = shift // ''; | |
| 172 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 173 | $s =~ s/"/"/g; $s =~ s/'/'/g; | |
| 174 | return $s; | |
| 175 | } | |
| 176 | ||
| 177 | # Build the <option> list for the plan dropdown. Reads billing_plans | |
| 178 | # (preferring the monthly row of each tier for the label price), and | |
| 179 | # pre-marks the matching tier with selected_attr. Connection-safe: | |
| 180 | # opens its own connection so signup pages on databases without the | |
| 181 | # billing schema still render via the fallback list. | |
| 182 | sub _build_plan_options { | |
| 183 | my ($db, $bill, $current_tier) = @_; | |
| 184 | $current_tier = '' unless defined $current_tier; | |
| 185 | my @out; | |
| 186 | ||
| 187 | my $dbh = eval { $db->db_connect() }; | |
| 188 | if ($dbh && $bill->schema_ready($db, $dbh, $DB)) { | |
| 189 | my @rows = $bill->list_plans($db, $dbh, $DB); | |
| 190 | my %seen_tier; | |
| 191 | # Prefer monthly row per tier (annual is implicit on the pricing card). | |
| 192 | my @monthly = grep { ($_->{cadence}||'') eq 'monthly' } @rows; | |
| 193 | my @backup = grep { ($_->{cadence}||'') ne 'monthly' } @rows; | |
| 194 | foreach my $r (@monthly, @backup) { | |
| 195 | next if $seen_tier{ $r->{tier} }++; | |
| 196 | my $tier = $r->{tier}; | |
| 197 | my $label = ($r->{display_name} || ucfirst($tier)) | |
| 198 | . ' — ' | |
| 199 | . _signup_price_label($r->{price_cents}) | |
| 200 | . ($r->{tagline} ? (' · ' . $r->{tagline}) : ''); | |
| 201 | push @out, { | |
| 202 | value => $tier, | |
| 203 | label => $label, | |
| 204 | selected_attr => ($tier eq $current_tier) ? ' selected' : '', | |
| 205 | }; | |
| 206 | } | |
| 207 | $db->db_disconnect($dbh); | |
| 208 | return @out if scalar @out; | |
| 209 | } | |
| 210 | $db->db_disconnect($dbh) if $dbh; | |
| 211 | ||
| 212 | # Fallback for un-migrated databases. | |
| 213 | # Post pricing-v3 (2026-06): 2 paid tiers; every signup starts on a | |
| 214 | # 14-day free trial regardless of which tier they pick. | |
| 215 | foreach my $r ( | |
| 216 | { v=>'pro', l=>'Pro — $29/mo, 25k contacts, 5 seats (14-day free trial)' }, | |
| 217 | { v=>'business', l=>'Business — $99/mo, unlimited contacts/seats + API + SSO (14-day free trial)' }, | |
| 218 | ) { | |
| 219 | push @out, { | |
| 220 | value => $r->{v}, | |
| 221 | label => $r->{l}, | |
| 222 | selected_attr => ($r->{v} eq $current_tier) ? ' selected' : '', | |
| 223 | }; | |
| 224 | } | |
| 225 | return @out; | |
| 226 | } | |
| 227 | ||
| 228 | sub _signup_price_label { | |
| 229 | my ($cents) = @_; | |
| 230 | $cents = 0 unless defined $cents; | |
| 231 | return 'free forever' if $cents == 0; | |
| 232 | if ($cents % 100 == 0) { | |
| 233 | return '$' . int($cents / 100) . '/mo'; | |
| 234 | } | |
| 235 | return '$' . sprintf('%.2f', $cents / 100) . '/mo'; | |
| 236 | } |