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

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

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

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