Diff -- /var/www/vhosts/webstls.com/httpdocs/promotions.cgi
Diff

/var/www/vhosts/webstls.com/httpdocs/promotions.cgi

added on WebSTLs (webstls.com) at 2026-07-01 22:26:45

Added
+503
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 999cbb023b4e
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# 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#======================================================================
8use strict;
9use warnings;
10
11use lib '/var/www/vhosts/webstls.com/httpdocs';
12use CGI;
13use MODS::Template;
14use MODS::DBConnect;
15use MODS::Login;
16use MODS::WebSTLs::Config;
17use MODS::WebSTLs::Wrapper;
18use MODS::WebSTLs::Promotions;
19
20my $q = CGI->new;
21my $form = $q->Vars;
22my $auth = MODS::Login->new;
23my $wrap = MODS::WebSTLs::Wrapper->new;
24my $tfile = MODS::Template->new;
25my $db = MODS::DBConnect->new;
26my $cfg = MODS::WebSTLs::Config->new;
27my $promo = MODS::WebSTLs::Promotions->new;
28my $DB = $cfg->settings('database_name');
29
30$|=1;
31my $userinfo = $auth->login_verify();
32if (!$userinfo) {
33 print "Status: 302 Found\nLocation: /login.cgi\n\n";
34 exit;
35}
36require MODS::WebSTLs::Permissions;
37
38my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'promotions';
39
40if ($entry eq 'promotion_action') {
41 MODS::WebSTLs::Permissions->new->require_feature($userinfo, 'edit_promotions');
42 _handle_action($q, $form, $userinfo);
43 exit;
44}
45
46MODS::WebSTLs::Permissions->new->require_feature($userinfo, 'view_promotions');
47my $uid = $userinfo->{user_id};
48$uid =~ s/[^0-9]//g;
49
50my $tutorial_mode = ($form->{tutorial} && $form->{tutorial} eq '1') ? 1 : 0;
51
52# View mode: list (default) | new | edit
53my $view = ($form->{new} && $form->{new} eq '1') ? 'new'
54 : ($form->{edit_id} && $form->{edit_id} =~ /^\d+$/) ? 'edit'
55 : 'list';
56my $edit_id = ($form->{edit_id} || '');
57$edit_id =~ s/[^0-9]//g;
58
59my $dbh = $db->db_connect();
60my $schema_ready = $promo->schema_ready($db, $dbh, $DB);
61
62# Find the seller's primary storefront (one per user today).
63my $sf = $db->db_readwrite($dbh,
64 qq~SELECT id, name FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~,
65 $ENV{SCRIPT_NAME}, __LINE__);
66my $store_id = ($sf && $sf->{id}) ? $sf->{id} : 0;
67my $store_name = ($sf && $sf->{name}) ? $sf->{name} : 'Your storefront';
68
69# Load list rows (always, used to render the counts + table when view=list).
70my @rows_raw = $schema_ready ? $promo->list_for_seller($db, $dbh, $DB, $uid, 100) : ();
71
72my @rows;
73my $cnt_active = 0; my $cnt_scheduled = 0; my $cnt_paused = 0; my $cnt_expired = 0;
74foreach my $r (@rows_raw) {
75 my $effective = $promo->effective_status($r);
76 $cnt_active++ if $effective eq 'active';
77 $cnt_scheduled++ if $effective eq 'scheduled';
78 $cnt_paused++ if $effective eq 'paused';
79 $cnt_expired++ if $effective eq 'expired' || $effective eq 'exhausted';
80
81 push @rows, _row_for_template($r, $promo, $db, $dbh, $DB);
82}
83
84# Edit-mode load.
85my %edit_row;
86my @edit_models;
87if ($view eq 'edit' && $edit_id) {
88 my $e = $promo->get_for_seller($db, $dbh, $DB, $edit_id, $uid);
89 if ($e && $e->{id}) {
90 %edit_row = _row_for_edit_form($e);
91 @edit_models = $promo->models_for_promo($db, $dbh, $DB, $edit_id);
92 } else {
93 $view = 'list';
94 }
95}
96
97# Available models for the picker (only on new/edit views).
98my @user_models;
99if ($view eq 'new' || $view eq 'edit') {
100 @user_models = $db->db_readwrite_multiple($dbh, qq~
101 SELECT id, title, status
102 FROM `${DB}`.models
103 WHERE user_id='$uid' AND purged_at IS NULL
104 ORDER BY title ASC
105 LIMIT 500
106 ~, $ENV{SCRIPT_NAME}, __LINE__);
107 # Mark which model_ids are linked when editing.
108 my %sel = map { $_->{model_id} => 1 } @edit_models;
109 foreach my $m (@user_models) {
110 $m->{is_selected} = $sel{ $m->{id} } ? 1 : 0;
111 }
112}
113
114# Flash banner from action handler redirects.
115my %flash;
116if ($form->{ok} eq 'created') { %flash = (kind=>'ok', msg=>'Promotion created.'); }
117elsif ($form->{ok} eq 'updated') { %flash = (kind=>'ok', msg=>'Promotion updated.'); }
118elsif ($form->{ok} eq 'deleted') { %flash = (kind=>'ok', msg=>'Promotion deleted.'); }
119elsif ($form->{ok} eq 'activated') { %flash = (kind=>'ok', msg=>'Promotion is now active.'); }
120elsif ($form->{ok} eq 'paused') { %flash = (kind=>'ok', msg=>'Promotion paused.'); }
121elsif ($form->{err} eq 'bad_input') { %flash = (kind=>'err', msg=>'Required fields missing.'); }
122elsif ($form->{err} eq 'denied') { %flash = (kind=>'err', msg=>'Action not permitted.'); }
123
124$db->db_disconnect($dbh);
125
126my $tvars = {
127 user_id => $uid,
128 store_id => $store_id,
129 store_name => $store_name,
130
131 is_list => ($view eq 'list') ? 1 : 0,
132 is_new => ($view eq 'new') ? 1 : 0,
133 is_edit => ($view eq 'edit') ? 1 : 0,
134
135 rows => \@rows,
136 has_rows => scalar(@rows) ? 1 : 0,
137 cnt_active => $cnt_active,
138 cnt_scheduled => $cnt_scheduled,
139 cnt_paused => $cnt_paused,
140 cnt_expired => $cnt_expired,
141 cnt_total => scalar(@rows),
142
143 edit => \%edit_row,
144 has_edit => %edit_row ? 1 : 0,
145 user_models => \@user_models,
146 has_user_models => scalar(@user_models) ? 1 : 0,
147
148 schema_ready => $schema_ready ? 1 : 0,
149 schema_missing => $schema_ready ? 0 : 1,
150
151 flash_kind => $flash{kind} || '',
152 flash_msg => $flash{msg} || '',
153 has_flash => %flash ? 1 : 0,
154
155 tutorial_mode => $tutorial_mode,
156 not_tutorial_mode => $tutorial_mode ? 0 : 1,
157};
158
159# Tutorial sample data: only meaningful on the list view.
160if ($tutorial_mode && $view eq 'list') {
161 $tvars->{rows} = [
162 { id=>1, name=>'Spring 20 percent off', kind_label=>'Coupon', is_coupon=>1, is_sale=>0, is_bundle=>0, is_flash=>0,
163 discount_label=>'20% off', code=>'SPRING20', code_present=>1, public_slug=>'spring20', slug_present=>1,
164 status=>'active', status_label=>'Active', is_active=>1, is_paused=>0, is_scheduled=>0, is_expired=>0, is_draft=>0,
165 starts_at=>'2026-04-01 00:00:00', ends_at=>'2026-06-01 00:00:00', has_window=>1,
166 current_redemptions=>87, max_redemptions=>500, has_cap=>1, cap_label=>'87 of 500 used',
167 discount_given_display=>'$1,160.00', edit_url=>'/promotions.cgi?edit_id=1', tutorial_demo=>1 },
168 { id=>2, name=>'Skirmisher bundle special', kind_label=>'Bundle', is_coupon=>0, is_sale=>0, is_bundle=>1, is_flash=>0,
169 discount_label=>'Bundle for $39.00', code=>'', code_present=>0, public_slug=>'skirmisher-bundle', slug_present=>1,
170 status=>'active', status_label=>'Active', is_active=>1, is_paused=>0, is_scheduled=>0, is_expired=>0, is_draft=>0,
171 starts_at=>'2026-05-01 00:00:00', ends_at=>'2026-07-01 00:00:00', has_window=>1,
172 current_redemptions=>34, max_redemptions=>0, has_cap=>0, cap_label=>'34 used',
173 discount_given_display=>'$612.00', edit_url=>'/promotions.cgi?edit_id=2', tutorial_demo=>1 },
174 { id=>3, name=>'Memorial Day flash sale', kind_label=>'Flash sale', is_coupon=>0, is_sale=>0, is_bundle=>0, is_flash=>1,
175 discount_label=>'30% off', code=>'', code_present=>0, public_slug=>'memorial-day', slug_present=>1,
176 status=>'scheduled', status_label=>'Scheduled', is_active=>0, is_paused=>0, is_scheduled=>1, is_expired=>0, is_draft=>0,
177 starts_at=>'2026-05-23 00:00:00', ends_at=>'2026-05-27 23:59:00', has_window=>1,
178 current_redemptions=>0, max_redemptions=>200, has_cap=>1, cap_label=>'0 of 200 used',
179 discount_given_display=>'$0.00', edit_url=>'/promotions.cgi?edit_id=3', tutorial_demo=>1 },
180 { id=>4, name=>'First-time buyer 10 off', kind_label=>'Coupon', is_coupon=>1, is_sale=>0, is_bundle=>0, is_flash=>0,
181 discount_label=>'$10.00 off', code=>'WELCOME10', code_present=>1, public_slug=>'welcome10', slug_present=>1,
182 status=>'active', status_label=>'Active', is_active=>1, is_paused=>0, is_scheduled=>0, is_expired=>0, is_draft=>0,
183 starts_at=>'2026-01-01 00:00:00', ends_at=>'', has_window=>0,
184 current_redemptions=>142, max_redemptions=>0, has_cap=>0, cap_label=>'142 used',
185 discount_given_display=>'$1,420.00', edit_url=>'/promotions.cgi?edit_id=4', tutorial_demo=>1 },
186 { id=>5, name=>'Old test promo', kind_label=>'Sale', is_coupon=>0, is_sale=>1, is_bundle=>0, is_flash=>0,
187 discount_label=>'15% off', code=>'', code_present=>0, public_slug=>'', slug_present=>0,
188 status=>'expired', status_label=>'Expired', is_active=>0, is_paused=>0, is_scheduled=>0, is_expired=>1, is_draft=>0,
189 starts_at=>'2026-03-01 00:00:00', ends_at=>'2026-03-31 23:59:00', has_window=>1,
190 current_redemptions=>62, max_redemptions=>0, has_cap=>0, cap_label=>'62 used',
191 discount_given_display=>'$540.00', edit_url=>'/promotions.cgi?edit_id=5', tutorial_demo=>1 },
192 ];
193 $tvars->{has_rows} = 1;
194 $tvars->{cnt_active} = 3;
195 $tvars->{cnt_scheduled} = 1;
196 $tvars->{cnt_paused} = 0;
197 $tvars->{cnt_expired} = 1;
198 $tvars->{cnt_total} = 5;
199 $tvars->{schema_ready} = 1;
200 $tvars->{schema_missing}= 0;
201}
202
203print "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";
204
205my $body = join('', $tfile->template('webstls_promotions.html', $tvars, $userinfo));
206
207$wrap->render({
208 userinfo => $userinfo,
209 page_key => 'promotions',
210 title => 'Promotions',
211 body => $body,
212});
213
214# ----------------------------------------------------------------- helpers
215sub _row_for_template {
216 my ($r, $promo, $db, $dbh, $DB) = @_;
217 my $eff = $promo->effective_status($r);
218 return {
219 id => $r->{id},
220 name => _h($r->{name} || ''),
221 kind_label => _kind_label($r->{kind}),
222 is_coupon => ($r->{kind} eq 'coupon') ? 1 : 0,
223 is_sale => ($r->{kind} eq 'sale') ? 1 : 0,
224 is_bundle => ($r->{kind} eq 'bundle') ? 1 : 0,
225 is_flash => ($r->{kind} eq 'flash_sale') ? 1 : 0,
226 discount_label => $promo->discount_label($r),
227 code => _h($r->{code} || ''),
228 code_present => ($r->{code} && $r->{code} ne '') ? 1 : 0,
229 public_slug => _h($r->{public_slug} || ''),
230 slug_present => ($r->{public_slug} && $r->{public_slug} ne '') ? 1 : 0,
231 status => $eff,
232 status_label => $promo->status_label($eff),
233 is_active => $eff eq 'active' ? 1 : 0,
234 is_paused => $eff eq 'paused' ? 1 : 0,
235 is_scheduled => $eff eq 'scheduled' ? 1 : 0,
236 is_expired => ($eff eq 'expired' || $eff eq 'exhausted') ? 1 : 0,
237 is_draft => $eff eq 'draft' ? 1 : 0,
238 starts_at => $r->{starts_at} || '',
239 ends_at => $r->{ends_at} || '',
240 has_window => (($r->{starts_at} || $r->{ends_at}) ? 1 : 0),
241 current_redemptions => $r->{current_redemptions} || $promo->redemption_count($db, $dbh, $DB, $r->{id}),
242 max_redemptions => defined $r->{max_redemptions} ? $r->{max_redemptions} : 0,
243 has_cap => (defined $r->{max_redemptions} && $r->{max_redemptions} > 0) ? 1 : 0,
244 cap_label => _cap_label($r),
245 discount_given_display => '$' . sprintf('%.2f', ($promo->total_discount_given_cents($db, $dbh, $DB, $r->{id}) || 0) / 100),
246 edit_url => '/promotions.cgi?edit_id=' . $r->{id},
247 };
248}
249
250sub _row_for_edit_form {
251 my ($r) = @_;
252 my $pct = ($r->{discount_value_bp} || 0) / 100;
253 my $amt = ($r->{discount_value_cents} || 0) / 100;
254 my $min = ($r->{minimum_order_cents} || 0) / 100;
255 return (
256 id => $r->{id},
257 name => _h($r->{name} || ''),
258 description => _h($r->{description} || ''),
259 kind => $r->{kind},
260 is_coupon => ($r->{kind} eq 'coupon') ? 1 : 0,
261 is_sale => ($r->{kind} eq 'sale') ? 1 : 0,
262 is_bundle => ($r->{kind} eq 'bundle') ? 1 : 0,
263 is_flash => ($r->{kind} eq 'flash_sale') ? 1 : 0,
264 discount_type => $r->{discount_type},
265 is_dtype_pct => ($r->{discount_type} eq 'percent') ? 1 : 0,
266 is_dtype_amt => ($r->{discount_type} eq 'fixed_amount') ? 1 : 0,
267 is_dtype_bun => ($r->{discount_type} eq 'bundle_price') ? 1 : 0,
268 is_dtype_free => ($r->{discount_type} eq 'free') ? 1 : 0,
269 discount_value_pct => sprintf('%.0f', $pct),
270 discount_value_amt => sprintf('%.2f', $amt),
271 minimum_order => sprintf('%.2f', $min),
272 code => _h($r->{code} || ''),
273 public_slug => _h($r->{public_slug} || ''),
274 starts_at_local => _to_local_input($r->{starts_at}),
275 ends_at_local => _to_local_input($r->{ends_at}),
276 max_redemptions => defined $r->{max_redemptions} ? $r->{max_redemptions} : '',
277 max_per_user => defined $r->{max_per_user} ? $r->{max_per_user} : '',
278 new_customers_only => $r->{new_customers_only} ? 1 : 0,
279 status => $r->{status},
280 status_label => MODS::WebSTLs::Promotions->status_label($r->{status}),
281 is_status_active => ($r->{status} eq 'active') ? 1 : 0,
282 is_status_paused => ($r->{status} eq 'paused') ? 1 : 0,
283 is_status_draft => ($r->{status} eq 'draft') ? 1 : 0,
284 );
285}
286
287sub _to_local_input {
288 my $ts = shift;
289 return '' unless defined $ts && $ts ne '';
290 if ($ts =~ /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})/) {
291 return "$1T$2"; # HTML datetime-local format
292 }
293 return '';
294}
295
296sub _kind_label {
297 my $k = shift || '';
298 return 'Coupon' if $k eq 'coupon';
299 return 'Sale' if $k eq 'sale';
300 return 'Bundle' if $k eq 'bundle';
301 return 'Flash sale' if $k eq 'flash_sale';
302 return ucfirst $k;
303}
304
305sub _cap_label {
306 my ($r) = @_;
307 my $cur = $r->{current_redemptions} || 0;
308 if (defined $r->{max_redemptions} && $r->{max_redemptions} > 0) {
309 return "$cur of $r->{max_redemptions} used";
310 }
311 return "$cur used";
312}
313
314sub _h {
315 my $s = shift;
316 $s //= '';
317 $s =~ s/&/&/g;
318 $s =~ s/</&lt;/g;
319 $s =~ s/>/&gt;/g;
320 $s =~ s/"/&quot;/g;
321 return $s;
322}
323
324#======================================================================
325# promotion_action entry: seller create/update/delete/activate/pause/
326# archive. require_feature('edit_promotions') applied at dispatch above.
327#======================================================================
328sub _handle_action {
329 my ($q, $form, $userinfo) = @_;
330
331 my $uid = $userinfo->{user_id};
332 $uid =~ s/[^0-9]//g;
333
334 my $act = defined $form->{act} ? $form->{act} : '';
335
336 my $dbh = $db->db_connect();
337 unless ($promo->schema_ready($db, $dbh, $DB)) {
338 $db->db_disconnect($dbh);
339 _act_redirect('/promotions.cgi?err=schema');
340 return;
341 }
342
343 # Find the seller's primary storefront -- new rows are attached to it.
344 my $sf = $db->db_readwrite($dbh,
345 qq~SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' ORDER BY id LIMIT 1~,
346 $ENV{SCRIPT_NAME}, __LINE__);
347 my $store_id = ($sf && $sf->{id}) ? $sf->{id} : 0;
348
349 if ($act eq 'create') {
350 unless ($store_id) {
351 $db->db_disconnect($dbh);
352 _act_redirect('/promotions.cgi?err=no_storefront');
353 return;
354 }
355 my $new_id = $promo->create($db, $dbh, $DB,
356 scope => 'storefront',
357 storefront_id => $store_id,
358 kind => $form->{kind},
359 name => $form->{name},
360 description => $form->{description},
361 discount_type => $form->{discount_type},
362 discount_value_bp => _act_percent_to_bp($form->{discount_value_pct}),
363 discount_value_cents => _act_dollars_to_cents($form->{discount_value_amt}),
364 minimum_order_cents => _act_dollars_to_cents($form->{minimum_order}),
365 code => $form->{code},
366 public_slug => $form->{public_slug},
367 starts_at => $form->{starts_at_local},
368 ends_at => $form->{ends_at_local},
369 max_redemptions => $form->{max_redemptions},
370 max_per_user => $form->{max_per_user},
371 new_customers_only => $form->{new_customers_only},
372 status => $form->{publish_now} ? 'active' : 'draft',
373 created_by_user_id => $uid,
374 );
375 unless ($new_id) {
376 $db->db_disconnect($dbh);
377 _act_redirect('/promotions.cgi?err=bad_input');
378 return;
379 }
380 _act_attach_models($promo, $db, $dbh, $DB, $new_id, $form);
381 $db->db_disconnect($dbh);
382 _act_redirect('/promotions.cgi?ok=created');
383 return;
384 }
385
386 if ($act eq 'update') {
387 my $id = $form->{id}; $id =~ s/[^0-9]//g;
388 my $row = $id ? $promo->get_for_seller($db, $dbh, $DB, $id, $uid) : {};
389 unless ($row && $row->{id}) {
390 $db->db_disconnect($dbh);
391 _act_redirect('/promotions.cgi?err=denied');
392 return;
393 }
394 my $ok = $promo->update($db, $dbh, $DB, $id,
395 scope => 'storefront',
396 storefront_id => $store_id,
397 kind => $form->{kind},
398 name => $form->{name},
399 description => $form->{description},
400 discount_type => $form->{discount_type},
401 discount_value_bp => _act_percent_to_bp($form->{discount_value_pct}),
402 discount_value_cents => _act_dollars_to_cents($form->{discount_value_amt}),
403 minimum_order_cents => _act_dollars_to_cents($form->{minimum_order}),
404 code => $form->{code},
405 public_slug => $form->{public_slug},
406 starts_at => $form->{starts_at_local},
407 ends_at => $form->{ends_at_local},
408 max_redemptions => $form->{max_redemptions},
409 max_per_user => $form->{max_per_user},
410 new_customers_only => $form->{new_customers_only},
411 );
412 unless ($ok) {
413 $db->db_disconnect($dbh);
414 _act_redirect('/promotions.cgi?err=bad_input');
415 return;
416 }
417 # Refresh model attachments.
418 $promo->clear_models($db, $dbh, $DB, $id);
419 _act_attach_models($promo, $db, $dbh, $DB, $id, $form);
420
421 $db->db_disconnect($dbh);
422 _act_redirect('/promotions.cgi?ok=updated');
423 return;
424 }
425
426 if ($act eq 'activate' || $act eq 'pause' || $act eq 'archive') {
427 my $id = $form->{id}; $id =~ s/[^0-9]//g;
428 my $row = $id ? $promo->get_for_seller($db, $dbh, $DB, $id, $uid) : {};
429 unless ($row && $row->{id}) {
430 $db->db_disconnect($dbh);
431 _act_redirect('/promotions.cgi?err=denied');
432 return;
433 }
434 my $new_status =
435 $act eq 'activate' ? 'active' :
436 $act eq 'pause' ? 'paused' :
437 'archived';
438 $promo->set_status($db, $dbh, $DB, $id, $new_status);
439 $db->db_disconnect($dbh);
440 _act_redirect('/promotions.cgi?ok=' . ($act eq 'activate' ? 'activated' : $act eq 'pause' ? 'paused' : 'updated'));
441 return;
442 }
443
444 if ($act eq 'delete') {
445 my $id = $form->{id}; $id =~ s/[^0-9]//g;
446 my $row = $id ? $promo->get_for_seller($db, $dbh, $DB, $id, $uid) : {};
447 unless ($row && $row->{id}) {
448 $db->db_disconnect($dbh);
449 _act_redirect('/promotions.cgi?err=denied');
450 return;
451 }
452 $promo->delete_promo($db, $dbh, $DB, $id);
453 $db->db_disconnect($dbh);
454 _act_redirect('/promotions.cgi?ok=deleted');
455 return;
456 }
457
458 $db->db_disconnect($dbh);
459 _act_redirect('/promotions.cgi?err=unknown_act');
460}
461
462# ------------------------------------------------------------- helpers (action)
463sub _act_redirect {
464 my $url = shift;
465 print "Status: 302 Found\nLocation: $url\n\n";
466}
467
468sub _act_percent_to_bp {
469 my $v = shift;
470 return 0 unless defined $v && $v ne '';
471 $v =~ s/[^0-9.]//g;
472 return 0 unless length $v;
473 my $bp = int($v * 100);
474 $bp = 0 if $bp < 0;
475 $bp = 10000 if $bp > 10000;
476 return $bp;
477}
478
479sub _act_dollars_to_cents {
480 my $v = shift;
481 return 0 unless defined $v && $v ne '';
482 $v =~ s/[^0-9.]//g;
483 return 0 unless length $v;
484 return int($v * 100 + 0.5);
485}
486
487# Pull the selected model ids out of the form (checkbox names like
488# model_id[]). Attach each at the requested role.
489sub _act_attach_models {
490 my ($promo, $db, $dbh, $DB, $promo_id, $form) = @_;
491 # CGI may store multi-valued fields as "\0"-joined strings.
492 my @ids;
493 my $raw = $form->{'model_ids'};
494 if (defined $raw && $raw ne '') {
495 @ids = split(/\0/, $raw);
496 }
497 foreach my $mid (@ids) {
498 $mid =~ s/[^0-9]//g;
499 next unless $mid;
500 my $role = $form->{"role_$mid"} || 'target';
501 $promo->add_model($db, $dbh, $DB, $promo_id, $mid, $role);
502 }
503}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help