added on local at 2026-07-01 13:47:41
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # WebSTLs - Seller Promotions | |
| 4 | # | |
| 5 | # List page + new/edit form for the seller's own coupons, sales, bundles | |
| 6 | # and flash sales. Posts to promotion_action.cgi for create/update/delete. | |
| 7 | #====================================================================== | |
| 8 | use strict; | |
| 9 | use warnings; | |
| 10 | ||
| 11 | use lib '/var/www/vhosts/3dshawn.com/affiliate.3dshawn.com'; | |
| 12 | use CGI; | |
| 13 | use MODS::Template; | |
| 14 | use MODS::DBConnect; | |
| 15 | use MODS::Login; | |
| 16 | use MODS::AffSoft::Config; | |
| 17 | use MODS::AffSoft::Wrapper; | |
| 18 | use MODS::AffSoft::Promotions; | |
| 19 | ||
| 20 | my $q = CGI->new; | |
| 21 | my $form = $q->Vars; | |
| 22 | my $auth = MODS::Login->new; | |
| 23 | my $wrap = MODS::AffSoft::Wrapper->new; | |
| 24 | my $tfile = MODS::Template->new; | |
| 25 | my $db = MODS::DBConnect->new; | |
| 26 | my $cfg = MODS::AffSoft::Config->new; | |
| 27 | my $promo = MODS::AffSoft::Promotions->new; | |
| 28 | my $DB = $cfg->settings('database_name'); | |
| 29 | ||
| 30 | $|=1; | |
| 31 | my $userinfo = $auth->login_verify(); | |
| 32 | if (!$userinfo) { | |
| 33 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 34 | exit; | |
| 35 | } | |
| 36 | require MODS::AffSoft::Permissions; | |
| 37 | MODS::AffSoft::Permissions->new->require_feature($userinfo, 'view_promotions'); | |
| 38 | my $uid = $userinfo->{user_id}; | |
| 39 | $uid =~ s/[^0-9]//g; | |
| 40 | ||
| 41 | my $tutorial_mode = ($form->{tutorial} && $form->{tutorial} eq '1') ? 1 : 0; | |
| 42 | ||
| 43 | # View mode: list (default) | new | edit | |
| 44 | my $view = ($form->{new} && $form->{new} eq '1') ? 'new' | |
| 45 | : ($form->{edit_id} && $form->{edit_id} =~ /^\d+$/) ? 'edit' | |
| 46 | : 'list'; | |
| 47 | my $edit_id = ($form->{edit_id} || ''); | |
| 48 | $edit_id =~ s/[^0-9]//g; | |
| 49 | ||
| 50 | my $dbh = $db->db_connect(); | |
| 51 | my $schema_ready = $promo->schema_ready($db, $dbh, $DB); | |
| 52 | ||
| 53 | # Find the seller's primary storefront (one per user today). | |
| 54 | my $sf = $db->db_readwrite($dbh, | |
| 55 | qq~SELECT id, name FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~, | |
| 56 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 57 | my $store_id = ($sf && $sf->{id}) ? $sf->{id} : 0; | |
| 58 | my $store_name = ($sf && $sf->{name}) ? $sf->{name} : 'Your storefront'; | |
| 59 | ||
| 60 | # Load list rows (always, used to render the counts + table when view=list). | |
| 61 | my @rows_raw = $schema_ready ? $promo->list_for_seller($db, $dbh, $DB, $uid, 100) : (); | |
| 62 | ||
| 63 | my @rows; | |
| 64 | my $cnt_active = 0; my $cnt_scheduled = 0; my $cnt_paused = 0; my $cnt_expired = 0; | |
| 65 | foreach my $r (@rows_raw) { | |
| 66 | my $effective = $promo->effective_status($r); | |
| 67 | $cnt_active++ if $effective eq 'active'; | |
| 68 | $cnt_scheduled++ if $effective eq 'scheduled'; | |
| 69 | $cnt_paused++ if $effective eq 'paused'; | |
| 70 | $cnt_expired++ if $effective eq 'expired' || $effective eq 'exhausted'; | |
| 71 | ||
| 72 | push @rows, _row_for_template($r, $promo, $db, $dbh, $DB); | |
| 73 | } | |
| 74 | ||
| 75 | # Edit-mode load. | |
| 76 | my %edit_row; | |
| 77 | my @edit_models; | |
| 78 | if ($view eq 'edit' && $edit_id) { | |
| 79 | my $e = $promo->get_for_seller($db, $dbh, $DB, $edit_id, $uid); | |
| 80 | if ($e && $e->{id}) { | |
| 81 | %edit_row = _row_for_edit_form($e); | |
| 82 | @edit_models = $promo->models_for_promo($db, $dbh, $DB, $edit_id); | |
| 83 | } else { | |
| 84 | $view = 'list'; | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | # Available models for the picker (only on new/edit views). | |
| 89 | my @user_models; | |
| 90 | if ($view eq 'new' || $view eq 'edit') { | |
| 91 | @user_models = $db->db_readwrite_multiple($dbh, qq~ | |
| 92 | SELECT id, title, status | |
| 93 | FROM `${DB}`.models | |
| 94 | WHERE user_id='$uid' AND purged_at IS NULL | |
| 95 | ORDER BY title ASC | |
| 96 | LIMIT 500 | |
| 97 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 98 | # Mark which model_ids are linked when editing. | |
| 99 | my %sel = map { $_->{model_id} => 1 } @edit_models; | |
| 100 | foreach my $m (@user_models) { | |
| 101 | $m->{is_selected} = $sel{ $m->{id} } ? 1 : 0; | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | # Flash banner from action handler redirects. | |
| 106 | my %flash; | |
| 107 | if ($form->{ok} eq 'created') { %flash = (kind=>'ok', msg=>'Promotion created.'); } | |
| 108 | elsif ($form->{ok} eq 'updated') { %flash = (kind=>'ok', msg=>'Promotion updated.'); } | |
| 109 | elsif ($form->{ok} eq 'deleted') { %flash = (kind=>'ok', msg=>'Promotion deleted.'); } | |
| 110 | elsif ($form->{ok} eq 'activated') { %flash = (kind=>'ok', msg=>'Promotion is now active.'); } | |
| 111 | elsif ($form->{ok} eq 'paused') { %flash = (kind=>'ok', msg=>'Promotion paused.'); } | |
| 112 | elsif ($form->{err} eq 'bad_input') { %flash = (kind=>'err', msg=>'Required fields missing.'); } | |
| 113 | elsif ($form->{err} eq 'denied') { %flash = (kind=>'err', msg=>'Action not permitted.'); } | |
| 114 | ||
| 115 | $db->db_disconnect($dbh); | |
| 116 | ||
| 117 | my $tvars = { | |
| 118 | user_id => $uid, | |
| 119 | store_id => $store_id, | |
| 120 | store_name => $store_name, | |
| 121 | ||
| 122 | is_list => ($view eq 'list') ? 1 : 0, | |
| 123 | is_new => ($view eq 'new') ? 1 : 0, | |
| 124 | is_edit => ($view eq 'edit') ? 1 : 0, | |
| 125 | ||
| 126 | rows => \@rows, | |
| 127 | has_rows => scalar(@rows) ? 1 : 0, | |
| 128 | cnt_active => $cnt_active, | |
| 129 | cnt_scheduled => $cnt_scheduled, | |
| 130 | cnt_paused => $cnt_paused, | |
| 131 | cnt_expired => $cnt_expired, | |
| 132 | cnt_total => scalar(@rows), | |
| 133 | ||
| 134 | edit => \%edit_row, | |
| 135 | has_edit => %edit_row ? 1 : 0, | |
| 136 | user_models => \@user_models, | |
| 137 | has_user_models => scalar(@user_models) ? 1 : 0, | |
| 138 | ||
| 139 | schema_ready => $schema_ready ? 1 : 0, | |
| 140 | schema_missing => $schema_ready ? 0 : 1, | |
| 141 | ||
| 142 | flash_kind => $flash{kind} || '', | |
| 143 | flash_msg => $flash{msg} || '', | |
| 144 | has_flash => %flash ? 1 : 0, | |
| 145 | ||
| 146 | tutorial_mode => $tutorial_mode, | |
| 147 | not_tutorial_mode => $tutorial_mode ? 0 : 1, | |
| 148 | }; | |
| 149 | ||
| 150 | # Tutorial sample data: only meaningful on the list view. | |
| 151 | if ($tutorial_mode && $view eq 'list') { | |
| 152 | $tvars->{rows} = [ | |
| 153 | { id=>1, name=>'Spring 20 percent off', kind_label=>'Coupon', is_coupon=>1, is_sale=>0, is_bundle=>0, is_flash=>0, | |
| 154 | discount_label=>'20% off', code=>'SPRING20', code_present=>1, public_slug=>'spring20', slug_present=>1, | |
| 155 | status=>'active', status_label=>'Active', is_active=>1, is_paused=>0, is_scheduled=>0, is_expired=>0, is_draft=>0, | |
| 156 | starts_at=>'2026-04-01 00:00:00', ends_at=>'2026-06-01 00:00:00', has_window=>1, | |
| 157 | current_redemptions=>87, max_redemptions=>500, has_cap=>1, cap_label=>'87 of 500 used', | |
| 158 | discount_given_display=>'$1,160.00', edit_url=>'/promotions.cgi?edit_id=1', tutorial_demo=>1 }, | |
| 159 | { id=>2, name=>'Skirmisher bundle special', kind_label=>'Bundle', is_coupon=>0, is_sale=>0, is_bundle=>1, is_flash=>0, | |
| 160 | discount_label=>'Bundle for $39.00', code=>'', code_present=>0, public_slug=>'skirmisher-bundle', slug_present=>1, | |
| 161 | status=>'active', status_label=>'Active', is_active=>1, is_paused=>0, is_scheduled=>0, is_expired=>0, is_draft=>0, | |
| 162 | starts_at=>'2026-05-01 00:00:00', ends_at=>'2026-07-01 00:00:00', has_window=>1, | |
| 163 | current_redemptions=>34, max_redemptions=>0, has_cap=>0, cap_label=>'34 used', | |
| 164 | discount_given_display=>'$612.00', edit_url=>'/promotions.cgi?edit_id=2', tutorial_demo=>1 }, | |
| 165 | { id=>3, name=>'Memorial Day flash sale', kind_label=>'Flash sale', is_coupon=>0, is_sale=>0, is_bundle=>0, is_flash=>1, | |
| 166 | discount_label=>'30% off', code=>'', code_present=>0, public_slug=>'memorial-day', slug_present=>1, | |
| 167 | status=>'scheduled', status_label=>'Scheduled', is_active=>0, is_paused=>0, is_scheduled=>1, is_expired=>0, is_draft=>0, | |
| 168 | starts_at=>'2026-05-23 00:00:00', ends_at=>'2026-05-27 23:59:00', has_window=>1, | |
| 169 | current_redemptions=>0, max_redemptions=>200, has_cap=>1, cap_label=>'0 of 200 used', | |
| 170 | discount_given_display=>'$0.00', edit_url=>'/promotions.cgi?edit_id=3', tutorial_demo=>1 }, | |
| 171 | { id=>4, name=>'First-time buyer 10 off', kind_label=>'Coupon', is_coupon=>1, is_sale=>0, is_bundle=>0, is_flash=>0, | |
| 172 | discount_label=>'$10.00 off', code=>'WELCOME10', code_present=>1, public_slug=>'welcome10', slug_present=>1, | |
| 173 | status=>'active', status_label=>'Active', is_active=>1, is_paused=>0, is_scheduled=>0, is_expired=>0, is_draft=>0, | |
| 174 | starts_at=>'2026-01-01 00:00:00', ends_at=>'', has_window=>0, | |
| 175 | current_redemptions=>142, max_redemptions=>0, has_cap=>0, cap_label=>'142 used', | |
| 176 | discount_given_display=>'$1,420.00', edit_url=>'/promotions.cgi?edit_id=4', tutorial_demo=>1 }, | |
| 177 | { id=>5, name=>'Old test promo', kind_label=>'Sale', is_coupon=>0, is_sale=>1, is_bundle=>0, is_flash=>0, | |
| 178 | discount_label=>'15% off', code=>'', code_present=>0, public_slug=>'', slug_present=>0, | |
| 179 | status=>'expired', status_label=>'Expired', is_active=>0, is_paused=>0, is_scheduled=>0, is_expired=>1, is_draft=>0, | |
| 180 | starts_at=>'2026-03-01 00:00:00', ends_at=>'2026-03-31 23:59:00', has_window=>1, | |
| 181 | current_redemptions=>62, max_redemptions=>0, has_cap=>0, cap_label=>'62 used', | |
| 182 | discount_given_display=>'$540.00', edit_url=>'/promotions.cgi?edit_id=5', tutorial_demo=>1 }, | |
| 183 | ]; | |
| 184 | $tvars->{has_rows} = 1; | |
| 185 | $tvars->{cnt_active} = 3; | |
| 186 | $tvars->{cnt_scheduled} = 1; | |
| 187 | $tvars->{cnt_paused} = 0; | |
| 188 | $tvars->{cnt_expired} = 1; | |
| 189 | $tvars->{cnt_total} = 5; | |
| 190 | $tvars->{schema_ready} = 1; | |
| 191 | $tvars->{schema_missing}= 0; | |
| 192 | } | |
| 193 | ||
| 194 | 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"; | |
| 195 | ||
| 196 | my $body = join('', $tfile->template('webstls_promotions.html', $tvars, $userinfo)); | |
| 197 | ||
| 198 | $wrap->render({ | |
| 199 | userinfo => $userinfo, | |
| 200 | page_key => 'promotions', | |
| 201 | title => 'Promotions', | |
| 202 | body => $body, | |
| 203 | }); | |
| 204 | ||
| 205 | # ----------------------------------------------------------------- helpers | |
| 206 | sub _row_for_template { | |
| 207 | my ($r, $promo, $db, $dbh, $DB) = @_; | |
| 208 | my $eff = $promo->effective_status($r); | |
| 209 | return { | |
| 210 | id => $r->{id}, | |
| 211 | name => _h($r->{name} || ''), | |
| 212 | kind_label => _kind_label($r->{kind}), | |
| 213 | is_coupon => ($r->{kind} eq 'coupon') ? 1 : 0, | |
| 214 | is_sale => ($r->{kind} eq 'sale') ? 1 : 0, | |
| 215 | is_bundle => ($r->{kind} eq 'bundle') ? 1 : 0, | |
| 216 | is_flash => ($r->{kind} eq 'flash_sale') ? 1 : 0, | |
| 217 | discount_label => $promo->discount_label($r), | |
| 218 | code => _h($r->{code} || ''), | |
| 219 | code_present => ($r->{code} && $r->{code} ne '') ? 1 : 0, | |
| 220 | public_slug => _h($r->{public_slug} || ''), | |
| 221 | slug_present => ($r->{public_slug} && $r->{public_slug} ne '') ? 1 : 0, | |
| 222 | status => $eff, | |
| 223 | status_label => $promo->status_label($eff), | |
| 224 | is_active => $eff eq 'active' ? 1 : 0, | |
| 225 | is_paused => $eff eq 'paused' ? 1 : 0, | |
| 226 | is_scheduled => $eff eq 'scheduled' ? 1 : 0, | |
| 227 | is_expired => ($eff eq 'expired' || $eff eq 'exhausted') ? 1 : 0, | |
| 228 | is_draft => $eff eq 'draft' ? 1 : 0, | |
| 229 | starts_at => $r->{starts_at} || '', | |
| 230 | ends_at => $r->{ends_at} || '', | |
| 231 | has_window => (($r->{starts_at} || $r->{ends_at}) ? 1 : 0), | |
| 232 | current_redemptions => $r->{current_redemptions} || $promo->redemption_count($db, $dbh, $DB, $r->{id}), | |
| 233 | max_redemptions => defined $r->{max_redemptions} ? $r->{max_redemptions} : 0, | |
| 234 | has_cap => (defined $r->{max_redemptions} && $r->{max_redemptions} > 0) ? 1 : 0, | |
| 235 | cap_label => _cap_label($r), | |
| 236 | discount_given_display => '$' . sprintf('%.2f', ($promo->total_discount_given_cents($db, $dbh, $DB, $r->{id}) || 0) / 100), | |
| 237 | edit_url => '/promotions.cgi?edit_id=' . $r->{id}, | |
| 238 | }; | |
| 239 | } | |
| 240 | ||
| 241 | sub _row_for_edit_form { | |
| 242 | my ($r) = @_; | |
| 243 | my $pct = ($r->{discount_value_bp} || 0) / 100; | |
| 244 | my $amt = ($r->{discount_value_cents} || 0) / 100; | |
| 245 | my $min = ($r->{minimum_order_cents} || 0) / 100; | |
| 246 | return ( | |
| 247 | id => $r->{id}, | |
| 248 | name => _h($r->{name} || ''), | |
| 249 | description => _h($r->{description} || ''), | |
| 250 | kind => $r->{kind}, | |
| 251 | is_coupon => ($r->{kind} eq 'coupon') ? 1 : 0, | |
| 252 | is_sale => ($r->{kind} eq 'sale') ? 1 : 0, | |
| 253 | is_bundle => ($r->{kind} eq 'bundle') ? 1 : 0, | |
| 254 | is_flash => ($r->{kind} eq 'flash_sale') ? 1 : 0, | |
| 255 | discount_type => $r->{discount_type}, | |
| 256 | is_dtype_pct => ($r->{discount_type} eq 'percent') ? 1 : 0, | |
| 257 | is_dtype_amt => ($r->{discount_type} eq 'fixed_amount') ? 1 : 0, | |
| 258 | is_dtype_bun => ($r->{discount_type} eq 'bundle_price') ? 1 : 0, | |
| 259 | is_dtype_free => ($r->{discount_type} eq 'free') ? 1 : 0, | |
| 260 | discount_value_pct => sprintf('%.0f', $pct), | |
| 261 | discount_value_amt => sprintf('%.2f', $amt), | |
| 262 | minimum_order => sprintf('%.2f', $min), | |
| 263 | code => _h($r->{code} || ''), | |
| 264 | public_slug => _h($r->{public_slug} || ''), | |
| 265 | starts_at_local => _to_local_input($r->{starts_at}), | |
| 266 | ends_at_local => _to_local_input($r->{ends_at}), | |
| 267 | max_redemptions => defined $r->{max_redemptions} ? $r->{max_redemptions} : '', | |
| 268 | max_per_user => defined $r->{max_per_user} ? $r->{max_per_user} : '', | |
| 269 | new_customers_only => $r->{new_customers_only} ? 1 : 0, | |
| 270 | status => $r->{status}, | |
| 271 | status_label => MODS::AffSoft::Promotions->status_label($r->{status}), | |
| 272 | is_status_active => ($r->{status} eq 'active') ? 1 : 0, | |
| 273 | is_status_paused => ($r->{status} eq 'paused') ? 1 : 0, | |
| 274 | is_status_draft => ($r->{status} eq 'draft') ? 1 : 0, | |
| 275 | ); | |
| 276 | } | |
| 277 | ||
| 278 | sub _to_local_input { | |
| 279 | my $ts = shift; | |
| 280 | return '' unless defined $ts && $ts ne ''; | |
| 281 | if ($ts =~ /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/) { | |
| 282 | return "$1T$2"; # HTML datetime-local format | |
| 283 | } | |
| 284 | return ''; | |
| 285 | } | |
| 286 | ||
| 287 | sub _kind_label { | |
| 288 | my $k = shift || ''; | |
| 289 | return 'Coupon' if $k eq 'coupon'; | |
| 290 | return 'Sale' if $k eq 'sale'; | |
| 291 | return 'Bundle' if $k eq 'bundle'; | |
| 292 | return 'Flash sale' if $k eq 'flash_sale'; | |
| 293 | return ucfirst $k; | |
| 294 | } | |
| 295 | ||
| 296 | sub _cap_label { | |
| 297 | my ($r) = @_; | |
| 298 | my $cur = $r->{current_redemptions} || 0; | |
| 299 | if (defined $r->{max_redemptions} && $r->{max_redemptions} > 0) { | |
| 300 | return "$cur of $r->{max_redemptions} used"; | |
| 301 | } | |
| 302 | return "$cur used"; | |
| 303 | } | |
| 304 | ||
| 305 | sub _h { | |
| 306 | my $s = shift; | |
| 307 | $s //= ''; | |
| 308 | $s =~ s/&/&/g; | |
| 309 | $s =~ s/</</g; | |
| 310 | $s =~ s/>/>/g; | |
| 311 | $s =~ s/"/"/g; | |
| 312 | return $s; | |
| 313 | } |