added on local at 2026-07-01 21:47:18
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer — signup | |
| 4 | # Page body lives in TEMPLATES/repricer_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/repricer.3dshawn.com'; | |
| 14 | use CGI; | |
| 15 | use MODS::Template; | |
| 16 | use MODS::DBConnect; | |
| 17 | use MODS::Login; | |
| 18 | use MODS::RePricer::Config; | |
| 19 | use MODS::RePricer::Wrapper; | |
| 20 | use MODS::RePricer::Billing; | |
| 21 | use MODS::RePricer::EmailVerify; | |
| 22 | ||
| 23 | my $q = CGI->new; | |
| 24 | my $form = $q->Vars; | |
| 25 | my $auth = MODS::Login->new; | |
| 26 | my $wrap = MODS::RePricer::Wrapper->new; | |
| 27 | my $tfile = MODS::Template->new; | |
| 28 | my $db = MODS::DBConnect->new; | |
| 29 | my $cfg = MODS::RePricer::Config->new; | |
| 30 | my $bill = MODS::RePricer::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} || 'pro'; | |
| 53 | ||
| 54 | # Plan v3: only pro/business/scale are valid tiers. Coerce anything | |
| 55 | # else (a legacy slug from a stale form cache, or a tampered POST) | |
| 56 | # into the default Pro plan so signup never errors out on a now- | |
| 57 | # removed ENUM value. | |
| 58 | my %tier_ok = map { $_ => 1 } qw(pro business scale); | |
| 59 | $plan = 'pro' unless $tier_ok{$plan}; | |
| 60 | ||
| 61 | $email_val = _h($email); | |
| 62 | $name_val = _h($name); | |
| 63 | $plan_val = _h($plan); | |
| 64 | ||
| 65 | if ($email !~ /^[^\@\s]+\@[^\@\s]+\.[^\@\s]+$/) { | |
| 66 | $error_msg = 'Please enter a valid email address.'; | |
| 67 | } elsif (length($pass) < 8) { | |
| 68 | $error_msg = 'Password must be at least 8 characters.'; | |
| 69 | } else { | |
| 70 | my $user_id = $auth->signup($email, $pass, $name, $plan); | |
| 71 | if ($user_id) { | |
| 72 | # ---- Start the 14-day trial ----------------------------- | |
| 73 | # Every new account starts in trial regardless of which paid | |
| 74 | # tier they picked. Cap SKUs at 10 during trial; the chosen | |
| 75 | # tier's normal marketplace defaults apply so the seller can | |
| 76 | # see exactly what that plan unlocks. The card-required gate | |
| 77 | # is enforced when trial_ends_at <= NOW() by the billing | |
| 78 | # worker / dashboard upgrade banner -- NOT here. Wrapped in | |
| 79 | # eval so a transient DB hiccup never blocks the signup | |
| 80 | # (worst case: defaults apply, admin can repair). | |
| 81 | eval { | |
| 82 | my $marketplaces = | |
| 83 | $plan eq 'scale' ? 'amazon,walmart,ebay' : | |
| 84 | $plan eq 'business' ? 'amazon,walmart' : | |
| 85 | 'amazon'; | |
| 86 | my $uid = $user_id; $uid =~ s/[^0-9]//g; | |
| 87 | my $dbh_t = $db->db_connect(); | |
| 88 | $db->db_readwrite($dbh_t, qq~ | |
| 89 | UPDATE ${DB}.users | |
| 90 | SET trial_ends_at = DATE_ADD(NOW(), INTERVAL 14 DAY), | |
| 91 | trial_sku_cap = 10, | |
| 92 | marketplaces_enabled = '$marketplaces' | |
| 93 | WHERE id='$uid' | |
| 94 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 95 | $db->db_disconnect($dbh_t); | |
| 96 | }; | |
| 97 | ||
| 98 | # Seed 3 sample products + a sample rule so the dashboard isn't empty. | |
| 99 | eval { | |
| 100 | require MODS::RePricer::Onboarding; | |
| 101 | my $dbh3 = $db->db_connect(); | |
| 102 | MODS::RePricer::Onboarding->new->seed_sample_data($db, $dbh3, $DB, $user_id); | |
| 103 | $db->db_disconnect($dbh3); | |
| 104 | }; | |
| 105 | ||
| 106 | # Fire-and-forget the verification email. The user is | |
| 107 | # signed in immediately either way (we don't gate the | |
| 108 | # platform behind verification); a bounce or mailer-down | |
| 109 | # condition just means they'll see a "Resend verification" | |
| 110 | # button on /settings.cgi until they confirm. Wrapped in | |
| 111 | # eval so a SMTP outage never blocks signup. | |
| 112 | eval { | |
| 113 | my $dbh2 = $db->db_connect(); | |
| 114 | MODS::RePricer::EmailVerify->send_for_user($db, $dbh2, $DB, { | |
| 115 | id => $user_id, | |
| 116 | email => $email, | |
| 117 | display_name => $name, | |
| 118 | }); | |
| 119 | $db->db_disconnect($dbh2); | |
| 120 | }; | |
| 121 | ||
| 122 | my ($userinfo, $token) = $auth->login_exec($email, $pass); | |
| 123 | if ($userinfo && $token) { | |
| 124 | print $auth->set_cookie_header($token); | |
| 125 | # Brand-new accounts land on /dashboard.cgi -- the | |
| 126 | # getting-started console with checklist + plan summary. | |
| 127 | # The dashboard is the right home AFTER they're set up, | |
| 128 | # not on day zero. | |
| 129 | print "Status: 302 Found\nLocation: /dashboard.cgi\n\n"; | |
| 130 | exit; | |
| 131 | } | |
| 132 | $error_msg = 'Account created — please sign in.'; | |
| 133 | } else { | |
| 134 | $error_msg = 'That email is already in use, or an account error occurred.'; | |
| 135 | } | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | 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"; | |
| 140 | ||
| 141 | # Build the plan <option> list from billing_plans (admin-managed). One | |
| 142 | # entry per unique tier; we prefer the monthly price for the label. | |
| 143 | # Falls back to a built-in set when the schema isn't migrated. | |
| 144 | my @plan_options = _build_plan_options($db, $bill, $plan_val); | |
| 145 | ||
| 146 | my $tvars = { | |
| 147 | error_msg => $error_msg, | |
| 148 | email_val => $email_val, | |
| 149 | name_val => $name_val, | |
| 150 | plan_options => \@plan_options, | |
| 151 | }; | |
| 152 | ||
| 153 | my $body = join('', $tfile->template('repricer_signup.html', $tvars, undef)); | |
| 154 | ||
| 155 | ||
| 156 | # === Maintenance lockdown overlay (auto-injected 2026-06-23) === | |
| 157 | # When maintenance_locked=1, render a modal over the login form. | |
| 158 | # X-close stays visible so an admin can sign in. Non-admin login | |
| 159 | # attempts fail and the page reloads, restoring the overlay. | |
| 160 | { | |
| 161 | my $_mcfg = MODS::RePricer::Config->new; | |
| 162 | if ($_mcfg->settings('maintenance_locked')) { | |
| 163 | my $_mmsg = $_mcfg->settings('maintenance_message') || '<p>The system is in maintenance mode.</p>'; | |
| 164 | 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>}; | |
| 165 | $body = $_overlay . $body; | |
| 166 | } | |
| 167 | } | |
| 168 | # === End maintenance lockdown overlay === | |
| 169 | $wrap->render({ | |
| 170 | userinfo => undef, | |
| 171 | title => 'Start your trial', | |
| 172 | body => $body, | |
| 173 | }); | |
| 174 | ||
| 175 | #---------------------------------------------------------------------- | |
| 176 | sub _h { | |
| 177 | my $s = shift // ''; | |
| 178 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 179 | $s =~ s/"/"/g; $s =~ s/'/'/g; | |
| 180 | return $s; | |
| 181 | } | |
| 182 | ||
| 183 | # Build the <option> list for the plan dropdown. Reads billing_plans | |
| 184 | # (preferring the monthly row of each tier for the label price), and | |
| 185 | # pre-marks the matching tier with selected_attr. Connection-safe: | |
| 186 | # opens its own connection so signup pages on databases without the | |
| 187 | # billing schema still render via the fallback list. | |
| 188 | sub _build_plan_options { | |
| 189 | my ($db, $bill, $current_tier) = @_; | |
| 190 | $current_tier = '' unless defined $current_tier; | |
| 191 | my @out; | |
| 192 | ||
| 193 | my $dbh = eval { $db->db_connect() }; | |
| 194 | if ($dbh && $bill->schema_ready($db, $dbh, $DB)) { | |
| 195 | my @rows = $bill->list_plans($db, $dbh, $DB); | |
| 196 | my %seen_tier; | |
| 197 | # Prefer monthly row per tier (annual is implicit on the pricing card). | |
| 198 | my @monthly = grep { ($_->{cadence}||'') eq 'monthly' } @rows; | |
| 199 | my @backup = grep { ($_->{cadence}||'') ne 'monthly' } @rows; | |
| 200 | foreach my $r (@monthly, @backup) { | |
| 201 | next if $seen_tier{ $r->{tier} }++; | |
| 202 | my $tier = $r->{tier}; | |
| 203 | my $label = ($r->{display_name} || ucfirst($tier)) | |
| 204 | . ' — ' | |
| 205 | . _signup_price_label($r->{price_cents}) | |
| 206 | . ($r->{tagline} ? (' · ' . $r->{tagline}) : ''); | |
| 207 | push @out, { | |
| 208 | value => $tier, | |
| 209 | label => $label, | |
| 210 | selected_attr => ($tier eq $current_tier) ? ' selected' : '', | |
| 211 | }; | |
| 212 | } | |
| 213 | $db->db_disconnect($dbh); | |
| 214 | return @out if scalar @out; | |
| 215 | } | |
| 216 | $db->db_disconnect($dbh) if $dbh; | |
| 217 | ||
| 218 | # Fallback for un-migrated databases. | |
| 219 | foreach my $r ( | |
| 220 | { v=>'pro', l=>'Pro — $49/mo · 1,000 SKUs, Amazon' }, | |
| 221 | { v=>'business', l=>'Business — $149/mo · 10,000 SKUs, Amazon + Walmart' }, | |
| 222 | { v=>'scale', l=>'Scale — $349/mo · Unlimited, all marketplaces, SLA' }, | |
| 223 | ) { | |
| 224 | push @out, { | |
| 225 | value => $r->{v}, | |
| 226 | label => $r->{l}, | |
| 227 | selected_attr => ($r->{v} eq $current_tier) ? ' selected' : '', | |
| 228 | }; | |
| 229 | } | |
| 230 | return @out; | |
| 231 | } | |
| 232 | ||
| 233 | sub _signup_price_label { | |
| 234 | my ($cents) = @_; | |
| 235 | $cents = 0 unless defined $cents; | |
| 236 | return 'free forever' if $cents == 0; | |
| 237 | if ($cents % 100 == 0) { | |
| 238 | return '$' . int($cents / 100) . '/mo'; | |
| 239 | } | |
| 240 | return '$' . sprintf('%.2f', $cents / 100) . '/mo'; | |
| 241 | } |