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

/var/www/vhosts/3dshawn.com/shop.3dshawn.com/storefront.cgi

added on local at 2026-07-01 22:09:45

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