Diff -- /var/www/vhosts/3dshawn.com/repricer.3dshawn.com/_attic/storefront.cgi
Diff

/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/_attic/storefront.cgi

added on local at 2026-07-01 21:47:35

Added
+1344
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 383dd839dee1
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# RePricer — storefront editor (creator-side)
4#
5# Three modes:
6# 1. User has no storefront → render "name your storefront" form
7# 2. POST to create one → INSERT storefront + default pages, redirect
8# 3. User has a storefront → render the editor with real DB data
9#======================================================================
10use strict;
11use warnings;
12
13use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com';
14use CGI;
15use MODS::Template;
16use MODS::DBConnect;
17use MODS::Login;
18use MODS::RePricer::Config;
19use MODS::RePricer::Wrapper;
20use MODS::RePricer::Themes;
21use MODS::RePricer::Layouts;
22use MODS::RePricer::DefaultContent;
23
24my $q = CGI->new;
25my $form = $q->Vars;
26my $tfile = MODS::Template->new;
27my $db = MODS::DBConnect->new;
28my $auth = MODS::Login->new;
29my $config = MODS::RePricer::Config->new;
30my $wrap = MODS::RePricer::Wrapper->new;
31my $themes = MODS::RePricer::Themes->new;
32my $layouts = MODS::RePricer::Layouts->new;
33my $DB = $config->settings('database_name');
34
35$|=1;
36
37my $userinfo = $auth->login_verify();
38if (!$userinfo) {
39 print "Status: 302 Found\nLocation: /login.cgi\n\n";
40 exit;
41}
42require MODS::RePricer::Permissions;
43MODS::RePricer::Permissions->new->require_feature($userinfo, 'view_storefront');
44
45my $dbh = $db->db_connect();
46
47# ---------- POST: create a new storefront ----------
48my $create_error = '';
49if (($ENV{REQUEST_METHOD} || '') eq 'POST' && ($form->{action} || '') eq 'create') {
50 my $sub = lc($form->{subdomain} || '');
51 $sub =~ s/[^a-z0-9-]//g;
52 $sub = substr($sub, 0, 32);
53
54 my $name = $form->{name} || '';
55 $name =~ s/^\s+|\s+$//g;
56 $name = substr($name, 0, 120);
57 $name = $userinfo->{display_name} || $sub if $name eq '';
58
59 if (length($sub) < 3) {
60 $create_error = 'Subdomain must be at least 3 characters (a-z, 0-9, dash).';
61 } else {
62 my $exists = $db->db_readwrite($dbh,
63 qq~SELECT id FROM `${DB}`.storefronts WHERE subdomain='$sub' LIMIT 1~,
64 $ENV{SCRIPT_NAME}, __LINE__);
65 if ($exists && $exists->{id}) {
66 $create_error = 'That subdomain is already in use. Try another.';
67 } else {
68 my $safe_name = $name;
69 $safe_name =~ s/[\\']/\\$&/g;
70
71 my $r = $db->db_readwrite($dbh, qq~
72 INSERT INTO `${DB}`.storefronts SET
73 user_id = '$userinfo->{user_id}',
74 subdomain = '$sub',
75 name = '$safe_name',
76 custom_domain = NULL,
77 ssl_status = 'none',
78 theme_id = 1,
79 status = 'live'
80 ~, $ENV{SCRIPT_NAME}, __LINE__);
81
82 if (my $sid = $r->{mysql_insertid}) {
83 # Seed three starter pages -- separate SET-form INSERTs
84 # so adding a column to storefront_pages later doesn't
85 # silently shift any of these values into the wrong slot.
86 $db->db_readwrite($dbh, qq~
87 INSERT INTO `${DB}`.storefront_pages SET
88 storefront_id = '$sid',
89 slug = '',
90 page_type = 'home',
91 title = 'Home',
92 layout = '{"hero":"default"}',
93 status = 'published',
94 published_at = NOW()
95 ~, $ENV{SCRIPT_NAME}, __LINE__);
96 # Default page bodies come from MODS::RePricer::DefaultContent
97 # so a single file controls the seed text for every new
98 # storefront. Edit that module to change the placeholder
99 # copy for all future signups.
100 my $defaults = MODS::RePricer::DefaultContent->new;
101 my $catalog_body = $defaults->sql_escape($defaults->collection_body($safe_name));
102 my $about_body = $defaults->sql_escape($defaults->about_body($safe_name));
103 $db->db_readwrite($dbh, qq~
104 INSERT INTO `${DB}`.storefront_pages SET
105 storefront_id = '$sid',
106 slug = 'shop',
107 page_type = 'collection',
108 title = 'Catalog',
109 layout = '{"body":"$catalog_body"}',
110 status = 'published',
111 published_at = NOW()
112 ~, $ENV{SCRIPT_NAME}, __LINE__);
113 $db->db_readwrite($dbh, qq~
114 INSERT INTO `${DB}`.storefront_pages SET
115 storefront_id = '$sid',
116 slug = 'about',
117 page_type = 'about',
118 title = 'About $safe_name',
119 layout = '{"body":"$about_body"}',
120 status = 'published',
121 published_at = NOW()
122 ~, $ENV{SCRIPT_NAME}, __LINE__);
123
124 # Listing Details -- the per-product detail page template.
125 # This is a "system" page seeded for every storefront so
126 # the seller sees it in the Pages admin and knows it
127 # exists. The actual page is template-driven by
128 # listing_details.cgi (one body for all products), so
129 # the row's `layout` body is informational copy
130 # explaining how to preview. The Pages admin's View
131 # button maps this page_type to listing_details.cgi
132 # with the seller's first published model_id.
133 #
134 # page_type='product' (NOT 'product_detail') because the
135 # storefront_pages.page_type column is an ENUM of
136 # ('home','collection','product','about','custom','legal').
137 # 'product_detail' isn't in the enum -- using it silently
138 # coerces to empty string, which we hit during initial
139 # build before catching it.
140 my $details_body = $defaults->sql_escape($defaults->details_body($safe_name));
141 $db->db_readwrite($dbh, qq~
142 INSERT INTO `${DB}`.storefront_pages SET
143 storefront_id = '$sid',
144 slug = 'listing_details',
145 page_type = 'product',
146 title = 'Listing Details',
147 layout = '{"body":"$details_body"}',
148 status = 'published',
149 published_at = NOW()
150 ~, $ENV{SCRIPT_NAME}, __LINE__);
151
152 $db->db_disconnect($dbh);
153 print "Status: 302 Found\nLocation: /storefront.cgi\n\n";
154 exit;
155 }
156 $create_error = 'Could not create storefront. Try again.';
157 }
158 }
159}
160
161# ---------- POST: open/close the storefront ----------
162# Flips storefronts.status between 'live' and 'closed'. 'closed' makes
163# the public store.cgi render a maintenance page instead of the
164# catalog; 'live' resumes normal buyer access. We deliberately do not
165# touch 'draft' or 'suspended' from here -- those are first-time
166# setup / admin-only states respectively.
167if (($ENV{REQUEST_METHOD} || '') eq 'POST' && ($form->{action} || '') eq 'toggle_status') {
168 my $sid = $form->{store_id} || 0;
169 $sid =~ s/[^0-9]//g;
170 my $target = ($form->{target} || '') eq 'live' ? 'live' : 'closed';
171 if ($sid) {
172 my $own = $db->db_readwrite($dbh,
173 qq~SELECT id, status FROM `${DB}`.storefronts
174 WHERE id='$sid' AND user_id='$userinfo->{user_id}' LIMIT 1~,
175 $ENV{SCRIPT_NAME}, __LINE__);
176 if ($own && $own->{id}) {
177 # Only flip when the current state is one of the two we
178 # manage from this UI; suspended/draft stay put so the
179 # seller can't accidentally exit an admin-imposed state.
180 if ($own->{status} eq 'live' || $own->{status} eq 'closed') {
181 $db->db_readwrite($dbh,
182 qq~UPDATE `${DB}`.storefronts SET status='$target' WHERE id='$sid' LIMIT 1~,
183 $ENV{SCRIPT_NAME}, __LINE__);
184 }
185 }
186 }
187 $db->db_disconnect($dbh);
188 my $flag = $target eq 'live' ? 'opened' : 'closed';
189 print "Status: 302 Found\nLocation: /storefront.cgi?$flag=1\n\n";
190 exit;
191}
192
193# ---------- POST: toggle whether a page shows in the storefront nav ----------
194# Some page types (notably page_type='product' for listing_details)
195# need to exist in storefront_pages so store.cgi knows how to render
196# them, but they don't belong in the public top-nav menu. This
197# handler flips storefront_pages.show_in_menu so the seller can
198# choose per-page. Only the owner of the storefront can flip it.
199if (($ENV{REQUEST_METHOD} || '') eq 'POST' && ($form->{action} || '') eq 'toggle_page_menu') {
200 my $pid = $form->{page_id} || 0;
201 $pid =~ s/[^0-9]//g;
202 my $target = ($form->{target} || '') eq '1' ? 1 : 0;
203 my $async = $form->{async} ? 1 : 0;
204 my $ok = 0;
205 if ($pid) {
206 my $own = $db->db_readwrite($dbh, qq~
207 SELECT p.id
208 FROM `${DB}`.storefront_pages p
209 JOIN `${DB}`.storefronts s ON s.id = p.storefront_id
210 WHERE p.id='$pid' AND s.user_id='$userinfo->{user_id}'
211 LIMIT 1
212 ~, $ENV{SCRIPT_NAME}, __LINE__);
213 if ($own && $own->{id}) {
214 $db->db_readwrite($dbh,
215 qq~UPDATE `${DB}`.storefront_pages SET show_in_menu='$target' WHERE id='$pid' LIMIT 1~,
216 $ENV{SCRIPT_NAME}, __LINE__);
217 $ok = 1;
218 }
219 }
220 $db->db_disconnect($dbh);
221 if ($async) {
222 my $ok_str = $ok ? 'true' : 'false';
223 print "Content-Type: application/json\nCache-Control: no-cache\n\n";
224 print qq~{"ok":$ok_str,"in_menu":$target,"page_id":$pid}~;
225 } else {
226 # Fallback for no-JS browsers / scripted clients.
227 print "Status: 302 Found\nLocation: /storefront.cgi?menu_toggled=1\n\n";
228 }
229 exit;
230}
231
232# ---------- POST: duplicate a page + auto-wire an A/B test ----------
233# Click "A/B test" on a page row -> we clone the storefront_pages row
234# into a hidden-slug variant (show_in_menu=0 so it doesn't show in nav),
235# then INSERT a draft ab_tests row with surface=storefront_page whose
236# variants reference the original page id (control) and the new clone
237# (challenger). Seller is redirected straight to the variant's page
238# editor so they can make their changes; from there they hit Start
239# in the Tests tab when ready.
240if (($ENV{REQUEST_METHOD} || '') eq 'POST' && ($form->{action} || '') eq 'duplicate_page_for_test') {
241 my $pid = $form->{page_id} || 0;
242 $pid =~ s/[^0-9]//g;
243 unless ($pid) {
244 $db->db_disconnect($dbh);
245 print "Status: 302 Found\nLocation: /storefront.cgi?err=missing_page\n\n";
246 exit;
247 }
248
249 my $src = $db->db_readwrite($dbh, qq~
250 SELECT p.id, p.storefront_id, p.slug, p.page_type, p.title, p.layout, p.status, s.user_id
251 FROM `${DB}`.storefront_pages p
252 JOIN `${DB}`.storefronts s ON s.id = p.storefront_id
253 WHERE p.id='$pid' AND s.user_id='$userinfo->{user_id}'
254 LIMIT 1
255 ~, $ENV{SCRIPT_NAME}, __LINE__);
256 unless ($src && $src->{id}) {
257 $db->db_disconnect($dbh);
258 print "Status: 302 Found\nLocation: /storefront.cgi?err=not_owner\n\n";
259 exit;
260 }
261
262 # Pick a hidden slug that won't collide with anything the seller
263 # navigates to directly. The "_abv_<id>_<n>" prefix is unlikely to
264 # be typed deliberately and the storefront editor filters it from
265 # the visible Pages list.
266 my $base_slug = '_abv_' . $src->{id} . '_';
267 my $clone_slug;
268 for (my $n = 2; $n < 50; $n++) {
269 my $try = $base_slug . $n;
270 my $hit = $db->db_readwrite($dbh,
271 qq~SELECT id FROM `${DB}`.storefront_pages
272 WHERE storefront_id='$src->{storefront_id}' AND slug='$try' LIMIT 1~,
273 $ENV{SCRIPT_NAME}, __LINE__);
274 unless ($hit && $hit->{id}) { $clone_slug = $try; last; }
275 }
276 $clone_slug ||= $base_slug . time();
277
278 my $sf_id = $src->{storefront_id};
279 my $orig_pid = $src->{id};
280 my $clone_title = ($src->{title} // 'Untitled') . ' (variant B)';
281 for ($clone_slug, $clone_title) { s/\\/\\\\/g; s/'/\\'/g; }
282 my $layout_sql = $src->{layout} // '';
283 $layout_sql =~ s/\\/\\\\/g; $layout_sql =~ s/'/\\'/g;
284 my $page_type_sql = $src->{page_type} || 'custom';
285 $page_type_sql =~ s/[^a-z_]//g;
286
287 # Clone the row. We force status=draft so the variant doesn't go
288 # live until the seller starts the test, and show_in_menu=0 so it
289 # never appears in the storefront's top nav.
290 my $r = $db->db_readwrite($dbh, qq~
291 INSERT INTO `${DB}`.storefront_pages SET
292 storefront_id = '$sf_id',
293 slug = '$clone_slug',
294 page_type = '$page_type_sql',
295 title = '$clone_title',
296 layout = '$layout_sql',
297 status = 'draft',
298 show_in_menu = 0
299 ~, $ENV{SCRIPT_NAME}, __LINE__);
300 my $clone_pid = ($r && $r->{mysql_insertid}) ? $r->{mysql_insertid} : 0;
301
302 if (!$clone_pid) {
303 $db->db_disconnect($dbh);
304 print "Status: 302 Found\nLocation: /storefront.cgi?err=clone_failed\n\n";
305 exit;
306 }
307
308 # Build the variants JSON. Variant A = original, B = clone. We use
309 # the same shape Experiments.pm + optimization_action.cgi write, so
310 # the existing parser picks the row up immediately.
311 my $variants_json =
312 qq~[{"id":"A","name":"Original","traffic_pct":50,"target_model_id":0,~
313 . qq~"changes":{"text":"","image_url":"","layout_id":0,"theme_id":0,"custom_theme_id":0,~
314 . qq~"variant_page_id":$orig_pid,"block_overrides":""}},~
315 . qq~{"id":"B","name":"Variant B","traffic_pct":50,"target_model_id":0,~
316 . qq~"changes":{"text":"","image_url":"","layout_id":0,"theme_id":0,"custom_theme_id":0,~
317 . qq~"variant_page_id":$clone_pid,"block_overrides":""}}]~;
318
319 my $exp_name = 'A/B page test: ' . ($src->{title} // 'Untitled');
320 $exp_name = substr($exp_name, 0, 160);
321 for ($exp_name) { s/\\/\\\\/g; s/'/\\'/g; }
322 my $variants_sql = $variants_json;
323 $variants_sql =~ s/\\/\\\\/g; $variants_sql =~ s/'/\\'/g;
324
325 # Surface column may not exist yet on a stale DB -- mirror the
326 # protective check from optimization_action.cgi.
327 my $hs = $db->db_readwrite($dbh, qq~
328 SELECT COUNT(*) AS n FROM information_schema.columns
329 WHERE table_schema='$DB' AND table_name='ab_tests' AND column_name='surface'
330 ~, $ENV{SCRIPT_NAME}, __LINE__);
331 my $surface_clause = ($hs && $hs->{n}) ? "surface='storefront_page'," : '';
332
333 my $ins = $db->db_readwrite($dbh, qq~
334 INSERT INTO `${DB}`.ab_tests SET
335 storefront_id = '$sf_id',
336 page_id = '$orig_pid',
337 name = '$exp_name',
338 test_type = 'ab',
339 $surface_clause
340 variants = '$variants_sql',
341 primary_metric = 'conversion_rate',
342 status = 'draft',
343 created_at = NOW()
344 ~, $ENV{SCRIPT_NAME}, __LINE__);
345 my $test_id = ($ins && $ins->{mysql_insertid}) ? $ins->{mysql_insertid} : 0;
346
347 $db->db_disconnect($dbh);
348 # Drop the seller on the variant page editor so they can immediately
349 # tweak the challenger copy. The new_test_id lets the storefront
350 # editor flash a confirmation when they bounce back.
351 print "Status: 302 Found\nLocation: /page.cgi?id=$clone_pid&newvariant=$test_id\n\n";
352 exit;
353}
354
355# ---------- POST: create an MVT page-blocks test ----------
356# Seller picks a page + supplies a list of slot names + N alternatives
357# per slot. We build an ab_tests row with surface=page_blocks scoped to
358# that page, and N variants where each variant carries the full
359# block_overrides map. The seller is expected to paste {{slot:NAME}}
360# tokens into their page body via the regular page editor; store.cgi
361# substitutes them at render time.
362if (($ENV{REQUEST_METHOD} || '') eq 'POST' && ($form->{action} || '') eq 'create_mvt_page_test') {
363 my $pid = $form->{mvt_page_id} || 0;
364 $pid =~ s/[^0-9]//g;
365 unless ($pid) {
366 $db->db_disconnect($dbh);
367 print "Status: 302 Found\nLocation: /storefront.cgi?err=missing_page#tests\n\n";
368 exit;
369 }
370 my $own = $db->db_readwrite($dbh, qq~
371 SELECT p.id, p.storefront_id, p.title
372 FROM `${DB}`.storefront_pages p
373 JOIN `${DB}`.storefronts s ON s.id = p.storefront_id
374 WHERE p.id='$pid' AND s.user_id='$userinfo->{user_id}'
375 LIMIT 1
376 ~, $ENV{SCRIPT_NAME}, __LINE__);
377 unless ($own && $own->{id}) {
378 $db->db_disconnect($dbh);
379 print "Status: 302 Found\nLocation: /storefront.cgi?err=not_owner#tests\n\n";
380 exit;
381 }
382
383 # Slot names come in as a comma-separated string -- "hero,cta,about".
384 # 1-8 slots tolerated.
385 my $slots_raw = $form->{mvt_slots} || '';
386 my @slot_names;
387 foreach my $s (split /\s*,\s*/, $slots_raw) {
388 $s = lc $s;
389 $s =~ s/[^a-z0-9_-]//g;
390 $s = substr($s, 0, 60);
391 next unless length $s;
392 push @slot_names, $s;
393 last if scalar(@slot_names) >= 8;
394 }
395 @slot_names = ('hero') unless @slot_names;
396
397 # Variant count: 2-6.
398 my $vn = $form->{mvt_variants} || 3;
399 $vn =~ s/[^0-9]//g;
400 $vn = 3 unless $vn;
401 $vn = 2 if $vn < 2;
402 $vn = 6 if $vn > 6;
403
404 # Build N variants. block_overrides for each is a JSON-shaped string
405 # mapping slot_name -> empty string (seller fills in via the
406 # optimization.cgi edit form). We escape both for JSON and for SQL.
407 my @LETTERS = ('A', 'B', 'C', 'D', 'E', 'F');
408 sub _js_escape { my $s = shift; $s //= ''; $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g; $s =~ s/\n/\\n/g; $s }
409
410 my $pct = int(100 / $vn);
411 my $rem = 100 - ($pct * $vn);
412 my @variant_parts;
413 for (my $i = 0; $i < $vn; $i++) {
414 my $vid = $LETTERS[$i];
415 my $vname = $i == 0 ? 'Control'
416 : ('Variant ' . $vid);
417 my $vpct = $pct + ($i == 0 ? $rem : 0);
418 my $blocks = '{' . join(',', map { qq~"$_":""~ } @slot_names) . '}';
419 # block_overrides field is itself a JSON-escaped string inside
420 # the variants column, so the inner blob's quotes must double-
421 # escape: " in $blocks -> \" -> \\\\\" when written into the
422 # outer JSON. _json_str-style encoder.
423 my $blocks_esc = $blocks;
424 $blocks_esc =~ s/\\/\\\\/g;
425 $blocks_esc =~ s/"/\\"/g;
426 push @variant_parts,
427 qq~{"id":"$vid","name":"~ . _js_escape($vname) . qq~","traffic_pct":$vpct,~
428 . qq~"target_model_id":0,~
429 . qq~"changes":{"text":"","image_url":"","layout_id":0,"theme_id":0,"custom_theme_id":0,~
430 . qq~"variant_page_id":0,"block_overrides":"$blocks_esc"}}~;
431 }
432 my $variants_json = '[' . join(',', @variant_parts) . ']';
433
434 my $exp_name = 'MVT page test: ' . ($own->{title} // 'Untitled');
435 $exp_name = substr($exp_name, 0, 160);
436 for ($exp_name, $variants_json) { s/\\/\\\\/g; s/'/\\'/g; }
437
438 my $hs = $db->db_readwrite($dbh, qq~
439 SELECT COUNT(*) AS n FROM information_schema.columns
440 WHERE table_schema='$DB' AND table_name='ab_tests' AND column_name='surface'
441 ~, $ENV{SCRIPT_NAME}, __LINE__);
442 my $surface_clause = ($hs && $hs->{n}) ? "surface='page_blocks'," : '';
443
444 my $sf_id = $own->{storefront_id};
445 my $ins = $db->db_readwrite($dbh, qq~
446 INSERT INTO `${DB}`.ab_tests SET
447 storefront_id = '$sf_id',
448 page_id = '$pid',
449 name = '$exp_name',
450 test_type = 'mvt',
451 $surface_clause
452 variants = '$variants_json',
453 primary_metric = 'conversion_rate',
454 status = 'draft',
455 created_at = NOW()
456 ~, $ENV{SCRIPT_NAME}, __LINE__);
457 my $test_id = ($ins && $ins->{mysql_insertid}) ? $ins->{mysql_insertid} : 0;
458
459 $db->db_disconnect($dbh);
460 # Hand the seller off to the dedicated slot editor where they can
461 # fill in the actual per-variant content for each slot.
462 my $loc = $test_id ? "/mvt_slots.cgi?id=$test_id" : '/storefront.cgi?err=mvt_create_failed#tests';
463 print "Status: 302 Found\nLocation: $loc\n\n";
464 exit;
465}
466
467# ---------- POST: rename an existing storefront ----------
468if (($ENV{REQUEST_METHOD} || '') eq 'POST' && ($form->{action} || '') eq 'rename') {
469 my $name = $form->{name} || '';
470 $name =~ s/^\s+|\s+$//g;
471 $name = substr($name, 0, 120);
472
473 my $sid = $form->{store_id} || 0;
474 $sid =~ s/[^0-9]//g;
475
476 if ($sid && $name ne '') {
477 my $own = $db->db_readwrite($dbh,
478 qq~SELECT id FROM `${DB}`.storefronts WHERE id='$sid' AND user_id='$userinfo->{user_id}' LIMIT 1~,
479 $ENV{SCRIPT_NAME}, __LINE__);
480 if ($own && $own->{id}) {
481 my $safe = $name;
482 $safe =~ s/[\\']/\\$&/g;
483 $db->db_readwrite($dbh,
484 qq~UPDATE `${DB}`.storefronts SET name='$safe' WHERE id='$sid' LIMIT 1~,
485 $ENV{SCRIPT_NAME}, __LINE__);
486 }
487 }
488
489 $db->db_disconnect($dbh);
490 print "Status: 302 Found\nLocation: /storefront.cgi?renamed=1\n\n";
491 exit;
492}
493
494# ---------- POST: set custom domain ----------
495# Accepts a single domain in the form, normalizes to lowercase and
496# strips any leading "http(s)://" or trailing "/" the user might
497# paste. Validates against a conservative RFC-1035 regex: each label
498# is 1-63 letters/digits/hyphens (not starting or ending with a
499# hyphen), at least two labels, no underscore.
500#
501# Blank submission CLEARS the custom domain (storefronts.custom_domain
502# = NULL) so the seller can fall back to the subdomain.
503#
504# SSL provisioning happens out of band (Plesk / Let's Encrypt). We
505# only persist the desired domain here; the actual cert request still
506# needs an admin action at the hosting layer.
507if (($ENV{REQUEST_METHOD} || '') eq 'POST' && ($form->{action} || '') eq 'set_custom_domain') {
508 my $sid = $form->{store_id} || 0;
509 my $domain = defined $form->{custom_domain} ? $form->{custom_domain} : '';
510 $sid =~ s/[^0-9]//g;
511
512 # Strip http://, https://, leading/trailing whitespace and slashes.
513 $domain =~ s/^\s+|\s+$//g;
514 $domain =~ s{^https?://}{}i;
515 $domain =~ s{/.*$}{};
516 $domain = lc $domain;
517
518 my $redirect_q = '';
519 if ($sid) {
520 my $own = $db->db_readwrite($dbh,
521 qq~SELECT id, custom_domain FROM `${DB}`.storefronts WHERE id='$sid' AND user_id='$userinfo->{user_id}' LIMIT 1~,
522 $ENV{SCRIPT_NAME}, __LINE__);
523 if ($own && $own->{id}) {
524 if ($domain eq '') {
525 # Empty submission -- clear the domain.
526 $db->db_readwrite($dbh,
527 qq~UPDATE `${DB}`.storefronts SET custom_domain=NULL WHERE id='$sid' LIMIT 1~,
528 $ENV{SCRIPT_NAME}, __LINE__);
529 $redirect_q = '?domain_cleared=1#domain';
530 }
531 elsif ($domain =~ /^(?:[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
532 && length($domain) <= 253) {
533 # Uniqueness guard -- a custom_domain must point at exactly
534 # one storefront on the platform.
535 my $taken = $db->db_readwrite($dbh,
536 qq~SELECT id FROM `${DB}`.storefronts WHERE custom_domain='$domain' AND id<>'$sid' LIMIT 1~,
537 $ENV{SCRIPT_NAME}, __LINE__);
538 if ($taken && $taken->{id}) {
539 $redirect_q = '?domain_err=taken#domain';
540 } else {
541 my $safe = $domain; $safe =~ s/[\\']/\\$&/g;
542 $db->db_readwrite($dbh,
543 qq~UPDATE `${DB}`.storefronts SET custom_domain='$safe' WHERE id='$sid' LIMIT 1~,
544 $ENV{SCRIPT_NAME}, __LINE__);
545 $redirect_q = '?domain_saved=1#domain';
546 }
547 }
548 else {
549 $redirect_q = '?domain_err=invalid#domain';
550 }
551 }
552 }
553 $db->db_disconnect($dbh);
554 print "Status: 302 Found\nLocation: /storefront.cgi$redirect_q\n\n";
555 exit;
556}
557
558# ---------- POST: change layout ----------
559if (($ENV{REQUEST_METHOD} || '') eq 'POST' && ($form->{action} || '') eq 'layout') {
560 my $sid = $form->{store_id} || 0;
561 my $lid = $form->{layout_id} || 0;
562 $sid =~ s/[^0-9]//g;
563 $lid =~ s/[^0-9]//g;
564
565 if ($sid && $lid) {
566 # Validate against the registry so we never write a bogus layout_id.
567 my $picked = $layouts->by_id($lid);
568 if ($picked && $picked->{id}) {
569 my $own = $db->db_readwrite($dbh,
570 qq~SELECT id FROM `${DB}`.storefronts WHERE id='$sid' AND user_id='$userinfo->{user_id}' LIMIT 1~,
571 $ENV{SCRIPT_NAME}, __LINE__);
572 if ($own && $own->{id}) {
573 $db->db_readwrite($dbh,
574 qq~UPDATE `${DB}`.storefronts SET layout_id='$picked->{id}' WHERE id='$sid' LIMIT 1~,
575 $ENV{SCRIPT_NAME}, __LINE__);
576 }
577 }
578 }
579
580 $db->db_disconnect($dbh);
581 print "Status: 302 Found\nLocation: /storefront.cgi?layout_saved=1#design\n\n";
582 exit;
583}
584
585# ---------- POST: change theme ----------
586# Applying a built-in theme sets theme_id and clears custom_theme_id.
587# Applying a custom theme sets custom_theme_id (theme_id left intact
588# so the user can flip back without losing their previous built-in
589# selection).
590if (($ENV{REQUEST_METHOD} || '') eq 'POST' && ($form->{action} || '') eq 'theme') {
591 my $sid = $form->{store_id} || 0;
592 my $tid = $form->{theme_id} || 0;
593 my $cid = $form->{custom_theme_id} || 0;
594 $sid =~ s/[^0-9]//g;
595 $tid =~ s/[^0-9]//g;
596 $cid =~ s/[^0-9]//g;
597
598 if ($sid) {
599 my $own = $db->db_readwrite($dbh,
600 qq~SELECT id FROM `${DB}`.storefronts WHERE id='$sid' AND user_id='$userinfo->{user_id}' LIMIT 1~,
601 $ENV{SCRIPT_NAME}, __LINE__);
602 if ($own && $own->{id}) {
603 if ($cid) {
604 # Apply a saved custom theme.
605 my $custom = $themes->custom_by_id($db, $dbh, $DB, $cid);
606 if ($custom && $custom->{id}) {
607 $db->db_readwrite($dbh,
608 qq~UPDATE `${DB}`.storefronts SET custom_theme_id='$cid' WHERE id='$sid' LIMIT 1~,
609 $ENV{SCRIPT_NAME}, __LINE__);
610 }
611 } elsif ($tid) {
612 # Apply a built-in theme. Clear any custom override.
613 my $picked = $themes->by_id($tid);
614 if ($picked && $picked->{id}) {
615 $db->db_readwrite($dbh,
616 qq~UPDATE `${DB}`.storefronts SET theme_id='$picked->{id}', custom_theme_id=NULL WHERE id='$sid' LIMIT 1~,
617 $ENV{SCRIPT_NAME}, __LINE__);
618 }
619 }
620 }
621 }
622
623 $db->db_disconnect($dbh);
624 print "Status: 302 Found\nLocation: /storefront.cgi?theme_saved=1#design\n\n";
625 exit;
626}
627
628# ---------- POST: save (and apply) a custom theme ----------
629# Body fields: store_id, name, plus the 14 color fields. Each is
630# validated against a strict CSS-color regex so we never write
631# garbage to the DB or open an XSS vector through the css_vars()
632# block that gets injected into <style> on every storefront page.
633if ((($ENV{REQUEST_METHOD} || '') eq 'POST') && ($form->{action} || '') eq 'save_custom_theme') {
634 my $sid = $form->{store_id} || 0;
635 $sid =~ s/[^0-9]//g;
636 my $own = $sid ? $db->db_readwrite($dbh,
637 qq~SELECT id FROM `${DB}`.storefronts WHERE id='$sid' AND user_id='$userinfo->{user_id}' LIMIT 1~,
638 $ENV{SCRIPT_NAME}, __LINE__) : undef;
639
640 if ($own && $own->{id}) {
641 # Color values we accept:
642 # - #RGB / #RRGGBB / #RRGGBBAA
643 # - rgba(R,G,B,A) / rgb(R,G,B)
644 # Anything else is rejected and the saved row uses the field's
645 # fallback (= the corresponding value from theme #1).
646 my $default = $themes->by_id(1);
647 my $clean_color = sub {
648 my ($v, $allow_rgba) = @_;
649 $v = '' unless defined $v;
650 $v =~ s/^\s+|\s+$//g;
651 return $v if $v =~ /^#[0-9a-fA-F]{3,8}$/;
652 return $v if $allow_rgba && $v =~ /^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*[0-9.]+\s*)?\)$/;
653 return undef;
654 };
655 my %cols;
656 my @fields = qw(bg bg_2 surface surface_2 border border_2
657 text text_2 text_3
658 accent accent_2 accent_deep);
659 foreach my $f (@fields) {
660 my $val = $clean_color->($form->{"col_$f"}, 0);
661 $cols{$f} = defined $val ? $val : ($default->{$f} || '#000000');
662 }
663 # accent_glow accepts rgba too (it's the soft halo around buttons)
664 my $glow = $clean_color->($form->{col_accent_glow}, 1);
665 $cols{accent_glow} = defined $glow ? $glow : ($default->{accent_glow} || 'rgba(124,58,237,0.35)');
666
667 # Theme name -- up to 80 chars, strip HTML/quotes.
668 my $name = $form->{theme_name} || 'My Custom Theme';
669 $name =~ s/[<>'"\\]/ /g;
670 $name =~ s/^\s+|\s+$//g;
671 $name = substr($name, 0, 80);
672 $name = 'My Custom Theme' unless length $name;
673
674 # Update existing record if the storefront already has one,
675 # otherwise insert a new row. Storefronts can have multiple
676 # custom themes saved -- the form passes existing_id when
677 # the user is editing one rather than creating fresh.
678 my $existing_id = $form->{existing_id} || 0;
679 $existing_id =~ s/[^0-9]//g;
680
681 if ($existing_id) {
682 my $owned = $db->db_readwrite($dbh,
683 qq~SELECT id FROM `${DB}`.storefront_custom_themes
684 WHERE id='$existing_id' AND storefront_id='$sid' LIMIT 1~,
685 $ENV{SCRIPT_NAME}, __LINE__);
686 if ($owned && $owned->{id}) {
687 my $set_sql = join(', ',
688 "name='" . _esc($name) . "'",
689 "bg='" . _esc($cols{bg}) . "'",
690 "bg_2='" . _esc($cols{bg_2}) . "'",
691 "surface='" . _esc($cols{surface}) . "'",
692 "surface_2='" . _esc($cols{surface_2}) . "'",
693 "cborder='" . _esc($cols{border}) . "'",
694 "cborder_2='" . _esc($cols{border_2}) . "'",
695 "ctext='" . _esc($cols{text}) . "'",
696 "ctext_2='" . _esc($cols{text_2}) . "'",
697 "ctext_3='" . _esc($cols{text_3}) . "'",
698 "accent='" . _esc($cols{accent}) . "'",
699 "accent_2='" . _esc($cols{accent_2}) . "'",
700 "accent_deep='" . _esc($cols{accent_deep}) . "'",
701 "accent_glow='" . _esc($cols{accent_glow}) . "'",
702 "updated_at=NOW()",
703 );
704 $db->db_readwrite($dbh,
705 qq~UPDATE `${DB}`.storefront_custom_themes SET $set_sql WHERE id='$existing_id' LIMIT 1~,
706 $ENV{SCRIPT_NAME}, __LINE__);
707 $db->db_readwrite($dbh,
708 qq~UPDATE `${DB}`.storefronts SET custom_theme_id='$existing_id' WHERE id='$sid' LIMIT 1~,
709 $ENV{SCRIPT_NAME}, __LINE__);
710 }
711 } else {
712 $db->db_readwrite($dbh, qq~
713 INSERT INTO `${DB}`.storefront_custom_themes SET
714 storefront_id = '$sid',
715 name = '~ . _esc($name) . qq~',
716 bg = '~ . _esc($cols{bg}) . qq~',
717 bg_2 = '~ . _esc($cols{bg_2}) . qq~',
718 surface = '~ . _esc($cols{surface}) . qq~',
719 surface_2 = '~ . _esc($cols{surface_2}) . qq~',
720 cborder = '~ . _esc($cols{border}) . qq~',
721 cborder_2 = '~ . _esc($cols{border_2}) . qq~',
722 ctext = '~ . _esc($cols{text}) . qq~',
723 ctext_2 = '~ . _esc($cols{text_2}) . qq~',
724 ctext_3 = '~ . _esc($cols{text_3}) . qq~',
725 accent = '~ . _esc($cols{accent}) . qq~',
726 accent_2 = '~ . _esc($cols{accent_2}) . qq~',
727 accent_deep = '~ . _esc($cols{accent_deep}) . qq~',
728 accent_glow = '~ . _esc($cols{accent_glow}) . qq~',
729 created_at = NOW(),
730 updated_at = NOW()
731 ~, $ENV{SCRIPT_NAME}, __LINE__);
732 my $new_id = $db->db_readwrite($dbh,
733 qq~SELECT LAST_INSERT_ID() AS id~, $ENV{SCRIPT_NAME}, __LINE__);
734 if ($new_id && $new_id->{id}) {
735 $db->db_readwrite($dbh,
736 qq~UPDATE `${DB}`.storefronts SET custom_theme_id='$new_id->{id}' WHERE id='$sid' LIMIT 1~,
737 $ENV{SCRIPT_NAME}, __LINE__);
738 }
739 }
740 }
741
742 $db->db_disconnect($dbh);
743 print "Status: 302 Found\nLocation: /storefront.cgi?theme_saved=1#design\n\n";
744 exit;
745}
746
747# ---------- POST: delete a saved custom theme ----------
748# Removes the row from storefront_custom_themes. If the row being deleted
749# is the storefront's currently-active theme, also clears
750# storefronts.custom_theme_id so the storefront falls back to its built-in
751# theme_id (or the default theme) instead of trying to render a NULL row.
752if ((($ENV{REQUEST_METHOD} || '') eq 'POST') && ($form->{action} || '') eq 'delete_custom_theme') {
753 my $sid = $form->{store_id} || 0;
754 my $ctid = $form->{custom_theme_id} || 0;
755 $sid =~ s/[^0-9]//g;
756 $ctid =~ s/[^0-9]//g;
757
758 # Ownership check: the storefront must belong to this user AND the
759 # theme row must belong to that storefront. Two predicates so a
760 # mismatched-but-owned theme can't be deleted from a different store.
761 my $own = ($sid && $ctid) ? $db->db_readwrite($dbh, qq~
762 SELECT ct.id
763 FROM `${DB}`.storefront_custom_themes ct
764 JOIN `${DB}`.storefronts s ON s.id = ct.storefront_id
765 WHERE ct.id='$ctid' AND ct.storefront_id='$sid'
766 AND s.user_id='$userinfo->{user_id}'
767 LIMIT 1
768 ~, $ENV{SCRIPT_NAME}, __LINE__) : undef;
769
770 if ($own && $own->{id}) {
771 $db->db_readwrite($dbh,
772 qq~UPDATE `${DB}`.storefronts SET custom_theme_id=NULL
773 WHERE id='$sid' AND custom_theme_id='$ctid' LIMIT 1~,
774 $ENV{SCRIPT_NAME}, __LINE__);
775 $db->db_readwrite($dbh,
776 qq~DELETE FROM `${DB}`.storefront_custom_themes WHERE id='$ctid' LIMIT 1~,
777 $ENV{SCRIPT_NAME}, __LINE__);
778 }
779
780 $db->db_disconnect($dbh);
781 print "Status: 302 Found\nLocation: /storefront.cgi?theme_deleted=1#design\n\n";
782 exit;
783}
784
785# ---------- POST: delete a non-system storefront page ----------
786# System pages (Home, the default Catalog at slug='shop', About, and the
787# Listing Details product template) are protected -- deleting them
788# breaks the storefront's nav. Any OTHER page (extra collections, custom
789# pages, legal pages, etc.) is fair game.
790if ((($ENV{REQUEST_METHOD} || '') eq 'POST') && ($form->{action} || '') eq 'delete_page') {
791 my $pid = $form->{page_id} || 0;
792 $pid =~ s/[^0-9]//g;
793
794 my $row = $pid ? $db->db_readwrite($dbh, qq~
795 SELECT p.id, p.slug, p.page_type, p.storefront_id
796 FROM `${DB}`.storefront_pages p
797 JOIN `${DB}`.storefronts s ON s.id = p.storefront_id
798 WHERE p.id='$pid' AND s.user_id='$userinfo->{user_id}'
799 LIMIT 1
800 ~, $ENV{SCRIPT_NAME}, __LINE__) : undef;
801
802 if ($row && $row->{id}) {
803 my $slug = $row->{slug} // '';
804 my $type = $row->{page_type} // '';
805 # Refuse to delete the 4 system-default rows. The Catalog
806 # (slug='shop') is the protected collection; other collections
807 # (the user-created kind) can go.
808 my $is_system = ($type eq 'home')
809 || ($type eq 'about')
810 || ($type eq 'product')
811 || ($type eq 'collection' && $slug eq 'shop')
812 ? 1 : 0;
813 unless ($is_system) {
814 $db->db_readwrite($dbh,
815 qq~DELETE FROM `${DB}`.storefront_pages WHERE id='$pid' LIMIT 1~,
816 $ENV{SCRIPT_NAME}, __LINE__);
817 }
818 }
819
820 $db->db_disconnect($dbh);
821 print "Status: 302 Found\nLocation: /storefront.cgi?page_deleted=1\n\n";
822 exit;
823}
824
825# ---------- Look up the creator's storefront (first one for now) ----------
826my $store = $db->db_readwrite($dbh,
827 qq~SELECT * FROM `${DB}`.storefronts WHERE user_id='$userinfo->{user_id}' ORDER BY id LIMIT 1~,
828 $ENV{SCRIPT_NAME}, __LINE__);
829
830print "Content-Type: text/html; charset=utf-8\nCache-Control: no-cache\n\n";
831
832# ---------- Mode A: no storefront yet → onboarding form ----------
833if (!$store || !$store->{id}) {
834 my $tvars = {
835 suggested_sub => _suggest_subdomain($userinfo->{display_name}, $userinfo->{email}),
836 display_name => $userinfo->{display_name} || '',
837 create_error => $create_error,
838 };
839 my $body = join('', $tfile->template('repricer_storefront_create.html', $tvars, $userinfo));
840
841 $db->db_disconnect($dbh);
842 $wrap->render({
843 userinfo => $userinfo,
844 page_key => 'storefront',
845 title => 'Create your storefront',
846 body => $body,
847 });
848 exit;
849}
850
851# ---------- Mode B: storefront exists → editor ----------
852# We pull every page incl. A/B variant clones (slug LIKE '_abv_%'), then
853# split them apart: the visible Pages tab only shows seller-authored
854# rows, while the Tests tab shows the variants alongside their parent
855# A/B test row. Sort home first, then by slug so the variant rows
856# (slug starts with underscore) cluster at the end if anything leaks.
857my @pages_all = $db->db_readwrite_multiple($dbh,
858 qq~SELECT * FROM `${DB}`.storefront_pages WHERE storefront_id='$store->{id}' ORDER BY page_type='home' DESC, slug ASC~,
859 $ENV{SCRIPT_NAME}, __LINE__);
860my @pages = grep { ($_->{slug} // '') !~ /^_abv_/ } @pages_all;
861my %page_by_id = map { $_->{id} => $_ } @pages_all;
862
863# Load every ab_tests row for this storefront so the Tests tab can list
864# them with lifecycle actions. Storefront-page tests are the headline,
865# but we surface everything else too (element/layout/theme tests) since
866# the seller already lives here.
867my @all_tests = $db->db_readwrite_multiple($dbh, qq~
868 SELECT id, name, test_type, surface, status, primary_metric,
869 page_id, traffic_seen, confidence_score,
870 start_date, end_date, created_at, variants
871 FROM `${DB}`.ab_tests
872 WHERE storefront_id='$store->{id}'
873 ORDER BY (status='running') DESC, (status='draft') DESC, created_at DESC
874~, $ENV{SCRIPT_NAME}, __LINE__);
875
876# Build per-test display rows. For storefront_page tests we resolve
877# each variant's variant_page_id into a {title, slug, status} so the
878# Tests tab can show seller-recognizable labels instead of bare ids.
879require MODS::RePricer::Experiments;
880my $exh_for_tests = MODS::RePricer::Experiments->new;
881my @test_rows;
882foreach my $t (@all_tests) {
883 my $status = $t->{status} || 'draft';
884 my $is_page = (($t->{surface} || '') eq 'storefront_page') ? 1 : 0;
885 my @vs = $exh_for_tests->_parse_variants($t->{variants} || '');
886
887 my @variant_display;
888 foreach my $v (@vs) {
889 my $vpid = $v->{variant_page_id} || 0;
890 my $p = $vpid ? $page_by_id{$vpid} : undef;
891 push @variant_display, {
892 id => $v->{id} || '',
893 name => $v->{name} || '',
894 traffic_pct => $v->{traffic_pct} || 0,
895 page_title => $p ? ($p->{title} || ('Page #' . $p->{id})) : '',
896 page_slug => $p ? ($p->{slug} || '') : '',
897 page_id => $vpid,
898 edit_url => $vpid ? "/page.cgi?id=$vpid" : '',
899 view_url => $vpid ? ('/store.cgi?id=' . $store->{id} . '&page=' . ($p ? ($p->{slug} || '') : '')) : '',
900 has_page => $vpid ? 1 : 0,
901 };
902 }
903
904 my $base_page = $t->{page_id} ? $page_by_id{$t->{page_id}} : undef;
905
906 push @test_rows, {
907 id => $t->{id},
908 name => $t->{name} || '',
909 test_type => uc($t->{test_type} || 'AB'),
910 surface => $t->{surface} || '',
911 surface_label => $is_page ? 'A/B between pages'
912 : (($t->{surface} || '') eq 'page_blocks') ? 'MVT on page blocks'
913 : (($t->{surface} || '') eq 'layout') ? 'Layout swap'
914 : (($t->{surface} || '') eq 'theme') ? 'Theme swap'
915 : (($t->{surface} || '') eq 'layout_and_theme') ? 'Layout AND theme'
916 : ($t->{surface} || ''),
917 is_page_test => $is_page,
918 base_page_title => $base_page ? ($base_page->{title} || '') : '',
919 base_page_slug => $base_page ? ($base_page->{slug} || '') : '',
920 status => $status,
921 status_label => ucfirst($status),
922 is_draft => $status eq 'draft' ? 1 : 0,
923 is_running => $status eq 'running' ? 1 : 0,
924 is_paused => $status eq 'paused' ? 1 : 0,
925 is_complete => $status eq 'complete' ? 1 : 0,
926 is_aborted => $status eq 'aborted' ? 1 : 0,
927 traffic_seen => $t->{traffic_seen} || 0,
928 variants => \@variant_display,
929 variant_count => scalar @variant_display,
930 detail_url => '/optimization.cgi?id=' . $t->{id},
931 edit_url => (($t->{surface} || '') eq 'page_blocks')
932 ? '/mvt_slots.cgi?id=' . $t->{id}
933 : '/optimization.cgi?edit=' . $t->{id},
934 is_block_test => (($t->{surface} || '') eq 'page_blocks') ? 1 : 0,
935 created_at => $t->{created_at} || '',
936 };
937}
938
939# For per-row "A/B test" buttons on the Pages tab: which pages already
940# have a running or draft storefront_page test? Lets us label the
941# button "Running A/B test" instead of "A/B test" for those.
942my %page_has_test;
943foreach my $t (@all_tests) {
944 next unless ($t->{surface} || '') eq 'storefront_page';
945 next unless $t->{page_id};
946 next if ($t->{status} || '') eq 'aborted'
947 || ($t->{status} || '') eq 'complete';
948 $page_has_test{$t->{page_id}} = $t->{status} || 'draft';
949}
950
951# First published model id for this storefront -- used by the Pages
952# admin's View button on the page_type='product_detail' row, so a
953# click previews listing_details.cgi against a REAL product instead
954# of a stub. Falls back to 0 (View link becomes a no-op) when the
955# seller has no published listings yet.
956my $first_model = $db->db_readwrite($dbh, qq~
957 SELECT sl.model_id
958 FROM `${DB}`.storefront_listings sl
959 JOIN `${DB}`.models m ON m.id = sl.model_id
960 WHERE sl.storefront_id='$store->{id}'
961 AND sl.visible = 1
962 AND m.status = 'published'
963 AND m.purged_at IS NULL
964 ORDER BY sl.sort_order ASC, m.created_at DESC
965 LIMIT 1
966~, $ENV{SCRIPT_NAME}, __LINE__);
967my $sample_model_id = ($first_model && $first_model->{model_id}) ? $first_model->{model_id} : 0;
968
969my $orders_today = $db->db_readwrite($dbh,
970 qq~SELECT COUNT(*) AS n, COALESCE(SUM(total_amount_cents),0) AS rev_cents
971 FROM `${DB}`.orders
972 WHERE storefront_id='$store->{id}' AND status='paid' AND DATE(paid_at)=CURDATE()~,
973 $ENV{SCRIPT_NAME}, __LINE__) || { n => 0, rev_cents => 0 };
974
975my $listing_count = $db->db_readwrite($dbh,
976 qq~SELECT COUNT(*) AS n FROM `${DB}`.storefront_listings WHERE storefront_id='$store->{id}' AND visible=1~,
977 $ENV{SCRIPT_NAME}, __LINE__) || { n => 0 };
978
979my $store_url = $store->{custom_domain} || ($store->{subdomain} . '.repricer.com');
980my $store_name = $store->{name} || $store->{subdomain};
981
982my $custom_domain = $store->{custom_domain} // '';
983my $has_custom = ($custom_domain ne '') ? 1 : 0;
984my $rev_cents = $orders_today->{rev_cents} || 0;
985my $store_id = $store->{id};
986
987# Resolve the current theme first so it can be passed to layout
988# thumbnails (each layout previews in the user's selected theme).
989# A storefront with custom_theme_id set uses the custom colors;
990# otherwise the built-in theme_id wins.
991my $current_theme_id = $store->{theme_id} || 1;
992my $current_theme = $themes->resolve_for_storefront($store, $db, $dbh, $DB);
993my $using_custom_theme = ($store->{custom_theme_id} ? 1 : 0);
994
995# Load this storefront's saved custom themes for the builder UI -- the
996# user can have multiple saved themes and switch between them.
997my @custom_themes_raw = $db->db_readwrite_multiple($dbh, qq~
998 SELECT id, name, bg, bg_2, surface, surface_2,
999 cborder AS cborder, cborder_2 AS cborder_2,
1000 ctext AS ctext, ctext_2 AS ctext_2, ctext_3 AS ctext_3,
1001 accent, accent_2, accent_deep, accent_glow,
1002 updated_at
1003 FROM `${DB}`.storefront_custom_themes
1004 WHERE storefront_id='$store->{id}'
1005 ORDER BY updated_at DESC
1006~, $ENV{SCRIPT_NAME}, __LINE__);
1007
1008# Layout list with per-row presentation precomputed.
1009my $current_layout_id = $store->{layout_id} || 1;
1010my $current_layout = $layouts->by_id($current_layout_id);
1011my @layout_rows;
1012foreach my $L (@{ $layouts->all }) {
1013 my $is_active = ($L->{id} == $current_layout->{id}) ? 1 : 0;
1014 push @layout_rows, {
1015 id => $L->{id},
1016 slug => $L->{slug},
1017 name => $L->{name},
1018 tagline => $L->{tagline},
1019 best_for => $L->{best_for},
1020 store_id => $store_id,
1021 thumbnail_html => $layouts->thumbnail_html($L, $current_theme),
1022 preview_url => "/store.cgi?id=$store_id&preview_layout=$L->{id}",
1023 is_active => $is_active,
1024 active_badge => $is_active
1025 ? '<span class="pill success" style="margin-left:8px">Active</span>'
1026 : '',
1027 card_class => $is_active ? 'layout-card layout-card-active' : 'layout-card',
1028 button_label => $is_active ? 'Current layout' : 'Use this layout',
1029 button_class => $is_active ? 'btn btn-secondary btn-sm' : 'btn btn-primary btn-sm',
1030 button_disabled => $is_active ? 'disabled' : '',
1031 };
1032}
1033
1034# Theme list with per-row presentation precomputed (active flag,
1035# preview swatches as inline CSS gradients). Done in Perl because
1036# MODS::Template can't run [if:] inside [loop:].
1037my @theme_rows;
1038foreach my $t (@{ $themes->all }) {
1039 my $is_active = ($t->{id} == $current_theme->{id}) ? 1 : 0;
1040 push @theme_rows, {
1041 id => $t->{id},
1042 slug => $t->{slug},
1043 name => $t->{name},
1044 tagline => $t->{tagline},
1045 # store_id repeated per row because outer-scope vars (incl.
1046 # $tvars.store_id) don't resolve inside MODS::Template's [loop:].
1047 store_id => $store_id,
1048 # Mini-storefront mockup built from this theme's colors. Replaces
1049 # the old plain gradient + 3 swatches so creators can see what
1050 # the storefront actually looks like in this theme.
1051 thumbnail_html => $themes->thumbnail_html($t),
1052 # Live preview URL — opens the buyer storefront in a new tab
1053 # with this theme applied for rendering only (no DB write).
1054 preview_url => "/store.cgi?id=$store_id&preview_theme=$t->{id}",
1055 is_active => $is_active,
1056 active_badge => $is_active
1057 ? '<span class="pill success" style="margin-left:8px">Active</span>'
1058 : '',
1059 card_class => $is_active ? 'theme-card theme-card-active' : 'theme-card',
1060 button_label => $is_active ? 'Current theme' : 'Use this theme',
1061 button_class => $is_active ? 'btn btn-secondary btn-sm' : 'btn btn-primary btn-sm',
1062 button_disabled => $is_active ? 'disabled' : '',
1063 };
1064}
1065
1066# Build presentational rows for the user's saved custom themes so the
1067# template can render them in the same picker grid as built-ins.
1068my @custom_theme_rows;
1069foreach my $ct (@custom_themes_raw) {
1070 # Build a synthetic theme hash so we can reuse $themes->thumbnail_html.
1071 my %tlook = (
1072 bg => $ct->{bg},
1073 bg_2 => $ct->{bg_2},
1074 surface => $ct->{surface},
1075 surface_2 => $ct->{surface_2},
1076 border => $ct->{cborder},
1077 border_2 => $ct->{cborder_2},
1078 text => $ct->{ctext},
1079 text_2 => $ct->{ctext_2},
1080 text_3 => $ct->{ctext_3},
1081 accent => $ct->{accent},
1082 accent_2 => $ct->{accent_2},
1083 accent_deep => $ct->{accent_deep},
1084 accent_glow => $ct->{accent_glow},
1085 );
1086 my $is_active = ($using_custom_theme && $store->{custom_theme_id} == $ct->{id}) ? 1 : 0;
1087 # status_badge is either the green "Active" pill (label only -- no
1088 # action needed) OR a blue "Inactive" pill that's actually a submit
1089 # button. Clicking it posts action=theme and switches this storefront
1090 # to the chosen custom theme. Mirrors the Active pill's position so
1091 # both states sit in the same spot, and the user has one fewer button
1092 # to dig through to activate a saved theme.
1093 push @custom_theme_rows, {
1094 id => $ct->{id},
1095 name => $ct->{name},
1096 tagline => 'Your custom theme',
1097 store_id => $store_id,
1098 thumbnail_html => $themes->thumbnail_html(\%tlook),
1099 preview_url => "/store.cgi?id=$store_id&preview_custom_theme=$ct->{id}",
1100 is_active => $is_active,
1101 active_badge => $is_active
1102 ? '<span class="pill success" style="margin-left:8px">Active</span>'
1103 : '<form method="POST" action="/storefront.cgi" style="margin:0;display:inline-block" title="Click to apply this theme to your storefront">'
1104 . '<input type="hidden" name="action" value="theme">'
1105 . '<input type="hidden" name="store_id" value="' . $store_id . '">'
1106 . '<input type="hidden" name="custom_theme_id" value="' . $ct->{id} . '">'
1107 . '<button type="submit" class="pill pill-inactive" style="margin-left:8px;cursor:pointer;font-family:inherit">Inactive</button>'
1108 . '</form>',
1109 card_class => $is_active ? 'theme-card theme-card-active' : 'theme-card',
1110 button_label => $is_active ? 'Current theme' : 'Use this theme',
1111 button_class => $is_active ? 'btn btn-secondary btn-sm' : 'btn btn-primary btn-sm',
1112 button_disabled => $is_active ? 'disabled' : '',
1113 # Raw color JSON so the builder can pre-load this theme for edits
1114 col_bg => $ct->{bg},
1115 col_bg_2 => $ct->{bg_2},
1116 col_surface => $ct->{surface},
1117 col_surface_2 => $ct->{surface_2},
1118 col_border => $ct->{cborder},
1119 col_border_2 => $ct->{cborder_2},
1120 col_text => $ct->{ctext},
1121 col_text_2 => $ct->{ctext_2},
1122 col_text_3 => $ct->{ctext_3},
1123 col_accent => $ct->{accent},
1124 col_accent_2 => $ct->{accent_2},
1125 col_accent_deep => $ct->{accent_deep},
1126 col_accent_glow => $ct->{accent_glow},
1127 };
1128}
1129
1130# Seed values for the "Create new" builder. Pre-populated with the
1131# user's currently-active theme (built-in or custom) so they're
1132# starting from a coherent palette rather than a blank slate.
1133my %builder_seed = (
1134 bg => $current_theme->{bg},
1135 bg_2 => $current_theme->{bg_2},
1136 surface => $current_theme->{surface},
1137 surface_2 => $current_theme->{surface_2},
1138 border => $current_theme->{border},
1139 border_2 => $current_theme->{border_2},
1140 text => $current_theme->{text},
1141 text_2 => $current_theme->{text_2},
1142 text_3 => $current_theme->{text_3},
1143 accent => $current_theme->{accent},
1144 accent_2 => $current_theme->{accent_2},
1145 accent_deep => $current_theme->{accent_deep},
1146 accent_glow => $current_theme->{accent_glow},
1147);
1148
1149# ---------- Stripe Connect state -----------------------------------
1150# Three states the template renders against:
1151# not_started: no stripe_account_id yet -> show "Connect Stripe" CTA
1152# pending : account_id present, onboarded_at null -> "Finish onboarding"
1153# connected : both present -> green pill + "Manage payouts" link
1154my $stripe_state = 'not_started';
1155$stripe_state = 'pending' if $store->{stripe_account_id};
1156$stripe_state = 'connected' if $store->{stripe_account_id} && $store->{stripe_onboarded_at};
1157my $stripe_connect_url = "/stripe_connect.cgi?storefront_id=$store_id";
1158my $stripe_flash = '';
1159$stripe_flash = 'connected' if ($form->{stripe_connected} // '') eq '1';
1160$stripe_flash = 'pending' if ($form->{stripe_pending} // '') eq '1';
1161my $stripe_error = $form->{stripe_error} // '';
1162
1163my $tvars = {
1164 store_id => $store_id,
1165 store_name => $store_name,
1166 store_subdomain => $store->{subdomain},
1167 store_custom => $custom_domain,
1168 store_url => $store_url,
1169 store_status => $store->{status} || 'live',
1170 is_store_live => (($store->{status} || 'live') eq 'live') ? 1 : 0,
1171 is_store_closed => (($store->{status} || '') eq 'closed') ? 1 : 0,
1172 is_store_other => ((($store->{status} || '') ne 'live') && (($store->{status} || '') ne 'closed')) ? 1 : 0,
1173 stripe_state => $stripe_state,
1174 stripe_is_connected => $stripe_state eq 'connected' ? 1 : 0,
1175 stripe_is_pending => $stripe_state eq 'pending' ? 1 : 0,
1176 stripe_is_not_started => $stripe_state eq 'not_started' ? 1 : 0,
1177 stripe_connect_url => $stripe_connect_url,
1178 stripe_just_connected => $stripe_flash eq 'connected' ? 1 : 0,
1179 stripe_just_pending => $stripe_flash eq 'pending' ? 1 : 0,
1180 stripe_error => $stripe_error,
1181 has_stripe_error => $stripe_error ne '' ? 1 : 0,
1182 has_custom_domain => $has_custom,
1183 no_custom_domain => $has_custom ? 0 : 1,
1184 ssl_status => $store->{ssl_status} || 'none',
1185 theme_label => $current_theme->{name},
1186 theme_tagline => $current_theme->{tagline},
1187 theme_swatch_css => "background: linear-gradient(135deg, $current_theme->{accent} 0%, $current_theme->{accent_2} 100%);",
1188 themes => \@theme_rows,
1189 custom_themes => \@custom_theme_rows,
1190 has_custom_themes => scalar(@custom_theme_rows) ? 1 : 0,
1191 using_custom_theme => $using_custom_theme,
1192 # Builder seed values for each color field (HTML5 <input type=color>
1193 # accepts only 7-char #RRGGBB hex, so we lossy-trim alpha here for
1194 # the picker UI; the actual save still accepts full hex + rgba).
1195 seed_bg => _seed_hex($builder_seed{bg}),
1196 seed_bg_2 => _seed_hex($builder_seed{bg_2}),
1197 seed_surface => _seed_hex($builder_seed{surface}),
1198 seed_surface_2 => _seed_hex($builder_seed{surface_2}),
1199 seed_border => _seed_hex($builder_seed{border}),
1200 seed_border_2 => _seed_hex($builder_seed{border_2}),
1201 seed_text => _seed_hex($builder_seed{text}),
1202 seed_text_2 => _seed_hex($builder_seed{text_2}),
1203 seed_text_3 => _seed_hex($builder_seed{text_3}),
1204 seed_accent => _seed_hex($builder_seed{accent}),
1205 seed_accent_2 => _seed_hex($builder_seed{accent_2}),
1206 seed_accent_deep => _seed_hex($builder_seed{accent_deep}),
1207 seed_accent_glow => $builder_seed{accent_glow},
1208 theme_saved_flash => (($form->{theme_saved} // '') eq '1') ? 1 : 0,
1209 layout_label => $current_layout->{name},
1210 layout_tagline => $current_layout->{tagline},
1211 layouts => \@layout_rows,
1212 layout_saved_flash => (($form->{layout_saved} // '') eq '1') ? 1 : 0,
1213 renamed_flash => (($form->{renamed} // '') eq '1') ? 1 : 0,
1214 domain_saved_flash => (($form->{domain_saved} // '') eq '1') ? 1 : 0,
1215 domain_cleared_flash => (($form->{domain_cleared} // '') eq '1') ? 1 : 0,
1216 domain_err_taken => (($form->{domain_err} // '') eq 'taken') ? 1 : 0,
1217 domain_err_invalid => (($form->{domain_err} // '') eq 'invalid') ? 1 : 0,
1218 listing_count => $listing_count->{n} || 0,
1219 orders_today_n => $orders_today->{n} || 0,
1220 revenue_today => '$' . sprintf('%.2f', $rev_cents / 100),
1221
1222 test_rows => \@test_rows,
1223 has_tests => scalar(@test_rows) ? 1 : 0,
1224 test_count => scalar(@test_rows),
1225 page_test_new_url => '/storefront.cgi#tests',
1226
1227 pages => [
1228 map +{
1229 id => $_->{id} || 0,
1230 slug_display => (defined $_->{slug} && $_->{slug} ne '') ? '/' . $_->{slug} : '/',
1231 slug_query => $_->{slug} // '',
1232 title => $_->{title} // '',
1233 # menu_label_display: what shows up in the storefront's
1234 # header nav for this page. Falls back to the page title
1235 # when the seller hasn't set a custom short label.
1236 menu_label_display => (defined $_->{menu_label} && length $_->{menu_label})
1237 ? $_->{menu_label}
1238 : ($_->{title} // ''),
1239 has_custom_menu_label => (defined $_->{menu_label} && length $_->{menu_label}) ? 1 : 0,
1240 page_type => $_->{page_type} // '',
1241 status => $_->{status} // 'draft',
1242 updated_rel => _humanize_time($_->{updated_at} // ''),
1243 # A/B test indicator for this page (running / draft / none).
1244 has_test => $page_has_test{$_->{id} || 0} ? 1 : 0,
1245 test_status => $page_has_test{$_->{id} || 0} || '',
1246 test_status_label => ($page_has_test{$_->{id} || 0} || '') eq 'running' ? 'Running'
1247 : ($page_has_test{$_->{id} || 0} || '') eq 'draft' ? 'Draft'
1248 : ($page_has_test{$_->{id} || 0} || '') eq 'paused' ? 'Paused'
1249 : '',
1250 # Pre-rendered status pill — keeps [if:] out of the loop body
1251 # (MODS::Template generates malformed Perl for [if:] inside [loop:]).
1252 status_pill => (($_->{status} // '') eq 'published')
1253 ? '<span class="pill success">Published</span>'
1254 : '<span class="pill warning">Draft</span>',
1255 # Precomputed View URL.
1256 # Special case: page_type='product' is template-driven by
1257 # listing_details.cgi, not by store.cgi's page renderer.
1258 # Point the View button at a real product so clicking it
1259 # previews the actual detail page (not the placeholder body
1260 # stored in storefront_pages). When the seller has no
1261 # published products yet, fall back to the page body so
1262 # they still see the informational copy.
1263 view_url => (($_->{page_type} // '') eq 'product'
1264 && $sample_model_id)
1265 ? "/listing_details.cgi?id=$sample_model_id"
1266 : '/store.cgi?id=' . $store_id
1267 . '&page=' . ($_->{slug} // ''),
1268 # Deletable if NOT one of the 4 system defaults seeded on
1269 # storefront creation. Keep this rule mirrored in the
1270 # delete_page POST handler above so server-side enforcement
1271 # stays authoritative even if the template is edited.
1272 is_deletable => ((($_->{page_type} // '') eq 'home')
1273 || (($_->{page_type} // '') eq 'about')
1274 || (($_->{page_type} // '') eq 'product')
1275 || ((($_->{page_type} // '') eq 'collection')
1276 && (($_->{slug} // '') eq 'shop')))
1277 ? 0 : 1,
1278 # Menu-visibility toggle. 'home' is never in nav (the
1279 # brand link covers it) so we hide the toggle entirely
1280 # for home rows. Everything else can flip.
1281 in_menu => ($_->{show_in_menu} // 1) ? 1 : 0,
1282 in_menu_target => ($_->{show_in_menu} // 1) ? '0' : '1',
1283 menu_toggle_show => (($_->{page_type} // '') eq 'home') ? 0 : 1,
1284 }, @pages
1285 ],
1286};
1287
1288my $body = join('', $tfile->template('repricer_storefront.html', $tvars, $userinfo));
1289
1290$db->db_disconnect($dbh);
1291
1292$wrap->render({
1293 userinfo => $userinfo,
1294 page_key => 'storefront',
1295 title => 'Storefront',
1296 body => $body,
1297});
1298
1299exit;
1300
1301#======================================================================
1302# Helpers
1303#======================================================================
1304
1305sub _esc {
1306 my $s = shift; $s = '' unless defined $s;
1307 $s =~ s/\\/\\\\/g; $s =~ s/'/''/g;
1308 return $s;
1309}
1310
1311# Trim a color string to the #RRGGBB form HTML5 <input type=color>
1312# accepts. Anything we can't trim cleanly falls back to #000000 so
1313# the picker still renders without a JS error.
1314sub _seed_hex {
1315 my $v = shift; $v = '' unless defined $v;
1316 return $v if $v =~ /^#[0-9a-fA-F]{6}$/;
1317 return '#' . substr($1, 0, 6) if $v =~ /^#([0-9a-fA-F]{6})[0-9a-fA-F]{2}$/; # strip alpha from #RRGGBBAA
1318 if ($v =~ /^#([0-9a-fA-F]{3})$/) {
1319 my $h = $1;
1320 my @c = split //, $h;
1321 return '#' . $c[0] . $c[0] . $c[1] . $c[1] . $c[2] . $c[2];
1322 }
1323 return '#000000';
1324}
1325
1326sub _suggest_subdomain {
1327 my ($name, $email) = @_;
1328 my $base = lc($name || (split /\@/, $email)[0] || 'mystore');
1329 $base =~ s/[^a-z0-9]//g;
1330 $base = substr($base, 0, 24);
1331 $base = 'mystore' if length($base) < 3;
1332 return $base;
1333}
1334
1335sub _humanize_time {
1336 my ($ts) = @_;
1337 return '' unless $ts;
1338 my ($d) = $ts =~ /^(\d{4}-\d{2}-\d{2})/;
1339 return '' unless $d;
1340 my @t = localtime();
1341 my $today = sprintf('%04d-%02d-%02d', $t[5]+1900, $t[4]+1, $t[3]);
1342 return 'today' if $d eq $today;
1343 return $d;
1344}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help