added on local at 2026-07-01 13:47:42
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # WebSTLs — signup | |
| 4 | # Page body lives in TEMPLATES/webstls_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/affiliate.3dshawn.com'; | |
| 14 | use CGI; | |
| 15 | use MODS::Template; | |
| 16 | use MODS::DBConnect; | |
| 17 | use MODS::Login; | |
| 18 | use MODS::AffSoft::Config; | |
| 19 | use MODS::AffSoft::Wrapper; | |
| 20 | use MODS::AffSoft::Billing; | |
| 21 | use MODS::AffSoft::EmailVerify; | |
| 22 | ||
| 23 | my $q = CGI->new; | |
| 24 | my $form = $q->Vars; | |
| 25 | my $auth = MODS::Login->new; | |
| 26 | my $wrap = MODS::AffSoft::Wrapper->new; | |
| 27 | my $tfile = MODS::Template->new; | |
| 28 | my $db = MODS::DBConnect->new; | |
| 29 | my $cfg = MODS::AffSoft::Config->new; | |
| 30 | my $bill = MODS::AffSoft::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 | my $plan_val = $form->{plan} || $form->{plan_tier} || 'pro'; | |
| 45 | $plan_val =~ s/[^a-z0-9_\-]//gi; | |
| 46 | $plan_val = lc $plan_val; | |
| 47 | ||
| 48 | if (($ENV{REQUEST_METHOD} || '') eq 'POST') { | |
| 49 | my $email = $form->{email} || ''; | |
| 50 | my $pass = $form->{password} || ''; | |
| 51 | my $name = $form->{display_name} || ''; | |
| 52 | my $plan = $form->{plan_tier} || 'free'; | |
| 53 | ||
| 54 | $email_val = _h($email); | |
| 55 | $name_val = _h($name); | |
| 56 | $plan_val = _h($plan); | |
| 57 | ||
| 58 | if ($email !~ /^[^\@\s]+\@[^\@\s]+\.[^\@\s]+$/) { | |
| 59 | $error_msg = 'Please enter a valid email address.'; | |
| 60 | } elsif (length($pass) < 8) { | |
| 61 | $error_msg = 'Password must be at least 8 characters.'; | |
| 62 | } else { | |
| 63 | my $user_id = $auth->signup($email, $pass, $name, $plan); | |
| 64 | if ($user_id) { | |
| 65 | # Seed a sample affiliate program so the dashboard isn't empty. | |
| 66 | eval { | |
| 67 | require MODS::AffSoft::Onboarding; | |
| 68 | my $dbh3 = $db->db_connect(); | |
| 69 | MODS::AffSoft::Onboarding->new->seed_sample_data($db, $dbh3, $DB, $user_id); | |
| 70 | $db->db_disconnect($dbh3); | |
| 71 | }; | |
| 72 | ||
| 73 | # Fire-and-forget the verification email. The user is | |
| 74 | # signed in immediately either way (we don't gate the | |
| 75 | # platform behind verification); a bounce or mailer-down | |
| 76 | # condition just means they'll see a "Resend verification" | |
| 77 | # button on /profile.cgi until they confirm. Wrapped in | |
| 78 | # eval so a SMTP outage never blocks signup. | |
| 79 | eval { | |
| 80 | my $dbh2 = $db->db_connect(); | |
| 81 | MODS::AffSoft::EmailVerify->send_for_user($db, $dbh2, $DB, { | |
| 82 | id => $user_id, | |
| 83 | email => $email, | |
| 84 | display_name => $name, | |
| 85 | }); | |
| 86 | $db->db_disconnect($dbh2); | |
| 87 | }; | |
| 88 | ||
| 89 | my ($userinfo, $token) = $auth->login_exec($email, $pass); | |
| 90 | if ($userinfo && $token) { | |
| 91 | print $auth->set_cookie_header($token); | |
| 92 | # Brand-new accounts land on /dashboard.cgi -- the | |
| 93 | # in-app landing with KPIs, programs, and activity. | |
| 94 | print "Status: 302 Found\nLocation: /dashboard.cgi\n\n"; | |
| 95 | exit; | |
| 96 | } | |
| 97 | $error_msg = 'Account created — please sign in.'; | |
| 98 | } else { | |
| 99 | $error_msg = 'That email is already in use, or an account error occurred.'; | |
| 100 | } | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | 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"; | |
| 105 | ||
| 106 | # Build the plan <option> list from billing_plans (admin-managed). One | |
| 107 | # entry per unique tier; we prefer the monthly price for the label. | |
| 108 | # Falls back to a built-in set when the schema isn't migrated. | |
| 109 | my @plan_options = _build_plan_options($db, $bill, $plan_val); | |
| 110 | ||
| 111 | my $tvars = { | |
| 112 | error_msg => $error_msg, | |
| 113 | email_val => $email_val, | |
| 114 | name_val => $name_val, | |
| 115 | plan_options => \@plan_options, | |
| 116 | }; | |
| 117 | ||
| 118 | my $body = join('', $tfile->template('webstls_signup.html', $tvars, undef)); | |
| 119 | ||
| 120 | ||
| 121 | # === Maintenance lockdown overlay (auto-injected 2026-06-23) === | |
| 122 | # When maintenance_locked=1, render a modal over the login form. | |
| 123 | # X-close stays visible so an admin can sign in. Non-admin login | |
| 124 | # attempts fail and the page reloads, restoring the overlay. | |
| 125 | { | |
| 126 | my $_mcfg = MODS::AffSoft::Config->new; | |
| 127 | if ($_mcfg->settings('maintenance_locked')) { | |
| 128 | my $_mmsg = $_mcfg->settings('maintenance_message') || '<p>The system is in maintenance mode.</p>'; | |
| 129 | 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>}; | |
| 130 | $body = $_overlay . $body; | |
| 131 | } | |
| 132 | } | |
| 133 | # === End maintenance lockdown overlay === | |
| 134 | $wrap->render({ | |
| 135 | userinfo => undef, | |
| 136 | title => 'Start your trial', | |
| 137 | body => $body, | |
| 138 | }); | |
| 139 | ||
| 140 | #---------------------------------------------------------------------- | |
| 141 | sub _h { | |
| 142 | my $s = shift // ''; | |
| 143 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 144 | $s =~ s/"/"/g; $s =~ s/'/'/g; | |
| 145 | return $s; | |
| 146 | } | |
| 147 | ||
| 148 | # Build the <option> list for the plan dropdown. Reads billing_plans | |
| 149 | # (preferring the monthly row of each tier for the label price), and | |
| 150 | # pre-marks the matching tier with selected_attr. Connection-safe: | |
| 151 | # opens its own connection so signup pages on databases without the | |
| 152 | # billing schema still render via the fallback list. | |
| 153 | sub _build_plan_options { | |
| 154 | my ($db, $bill, $current_tier) = @_; | |
| 155 | $current_tier = '' unless defined $current_tier; | |
| 156 | my @out; | |
| 157 | ||
| 158 | my $dbh = eval { $db->db_connect() }; | |
| 159 | if ($dbh && $bill->schema_ready($db, $dbh, $DB)) { | |
| 160 | my @rows = $bill->list_plans($db, $dbh, $DB); | |
| 161 | my %seen_tier; | |
| 162 | # Prefer monthly row per tier (annual is implicit on the pricing card). | |
| 163 | my @monthly = grep { ($_->{cadence}||'') eq 'monthly' } @rows; | |
| 164 | my @backup = grep { ($_->{cadence}||'') ne 'monthly' } @rows; | |
| 165 | foreach my $r (@monthly, @backup) { | |
| 166 | next if $seen_tier{ $r->{tier} }++; | |
| 167 | my $tier = $r->{tier}; | |
| 168 | my $label = ($r->{display_name} || ucfirst($tier)) | |
| 169 | . ' — ' | |
| 170 | . _signup_price_label($r->{price_cents}) | |
| 171 | . ($r->{tagline} ? (' · ' . $r->{tagline}) : ''); | |
| 172 | push @out, { | |
| 173 | value => $tier, | |
| 174 | label => $label, | |
| 175 | selected_attr => ($tier eq $current_tier) ? ' selected' : '', | |
| 176 | }; | |
| 177 | } | |
| 178 | $db->db_disconnect($dbh); | |
| 179 | return @out if scalar @out; | |
| 180 | } | |
| 181 | $db->db_disconnect($dbh) if $dbh; | |
| 182 | ||
| 183 | # Fallback for un-migrated databases. Must match pricing.cgi cards + | |
| 184 | # index.cgi pricing block + comparison-table claims so prospects see | |
| 185 | # the same offer everywhere ([[feedback-pricing-packaging-sync]]). | |
| 186 | foreach my $r ( | |
| 187 | { v=>'free', l=>'Free — 1 program, 5 affiliates, manual payouts' }, | |
| 188 | { v=>'starter', l=>'Starter — $9/mo, 3 programs, 50 affiliates' }, | |
| 189 | { v=>'pro', l=>'Pro — $29/mo, 10 programs, 500 affiliates, Stripe Connect payouts' }, | |
| 190 | { v=>'studio', l=>'Studio — $99/mo, unlimited programs & affiliates, team seats' }, | |
| 191 | ) { | |
| 192 | push @out, { | |
| 193 | value => $r->{v}, | |
| 194 | label => $r->{l}, | |
| 195 | selected_attr => ($r->{v} eq $current_tier) ? ' selected' : '', | |
| 196 | }; | |
| 197 | } | |
| 198 | return @out; | |
| 199 | } | |
| 200 | ||
| 201 | sub _signup_price_label { | |
| 202 | my ($cents) = @_; | |
| 203 | $cents = 0 unless defined $cents; | |
| 204 | return 'free forever' if $cents == 0; | |
| 205 | if ($cents % 100 == 0) { | |
| 206 | return '$' . int($cents / 100) . '/mo'; | |
| 207 | } | |
| 208 | return '$' . sprintf('%.2f', $cents / 100) . '/mo'; | |
| 209 | } |