added on WebSTLs (webstls.com) at 2026-07-01 22:26:47
| 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 | #====================================================================== | |
| 15 | use strict; | |
| 16 | use warnings; | |
| 17 | ||
| 18 | use lib '/var/www/vhosts/webstls.com/httpdocs'; | |
| 19 | use CGI; | |
| 20 | use MODS::Template; | |
| 21 | use MODS::DBConnect; | |
| 22 | use MODS::Login; | |
| 23 | use MODS::WebSTLs::Config; | |
| 24 | use MODS::WebSTLs::Wrapper; | |
| 25 | use MODS::WebSTLs::Themes; | |
| 26 | use MODS::WebSTLs::Layouts; | |
| 27 | use MODS::WebSTLs::DefaultContent; | |
| 28 | use MODS::WebSTLs::SEO; | |
| 29 | ||
| 30 | my $q = CGI->new; | |
| 31 | my $form = $q->Vars; | |
| 32 | my $tfile = MODS::Template->new; | |
| 33 | my $db = MODS::DBConnect->new; | |
| 34 | my $auth = MODS::Login->new; | |
| 35 | my $config = MODS::WebSTLs::Config->new; | |
| 36 | my $wrap = MODS::WebSTLs::Wrapper->new; | |
| 37 | my $themes = MODS::WebSTLs::Themes->new; | |
| 38 | my $layouts = MODS::WebSTLs::Layouts->new; | |
| 39 | my $DB = $config->settings('database_name'); | |
| 40 | ||
| 41 | $|=1; | |
| 42 | ||
| 43 | my $userinfo = $auth->login_verify(); | |
| 44 | if (!$userinfo) { | |
| 45 | print "Status: 302 Found\nLocation: /login.cgi\n\n"; | |
| 46 | exit; | |
| 47 | } | |
| 48 | require 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. | |
| 53 | my $entry = ($ENV{SCRIPT_NAME} || '') =~ m{/([^/]+)\.cgi$} ? $1 : 'storefront'; | |
| 54 | if ($entry eq 'storefront_seo') { | |
| 55 | _sfr_handle_storefront_seo(); | |
| 56 | exit; | |
| 57 | } | |
| 58 | ||
| 59 | MODS::WebSTLs::Permissions->new->require_feature($userinfo, 'view_storefront'); | |
| 60 | ||
| 61 | my $dbh = $db->db_connect(); | |
| 62 | ||
| 63 | # ---------- POST: create a new storefront ---------- | |
| 64 | my $create_error = ''; | |
| 65 | if (($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. | |
| 191 | if (($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. | |
| 223 | if (($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. | |
| 264 | if (($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. | |
| 386 | if (($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 ---------- | |
| 492 | if (($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. | |
| 531 | if (($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 ---------- | |
| 583 | if (($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). | |
| 614 | if (($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. | |
| 657 | if ((($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. | |
| 776 | if ((($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. | |
| 814 | if ((($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) ---------- | |
| 850 | my $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 | ||
| 854 | 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"; | |
| 855 | ||
| 856 | # ---------- Mode A: no storefront yet → onboarding form ---------- | |
| 857 | if (!$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. | |
| 881 | my @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__); | |
| 884 | my @pages = grep { ($_->{slug} // '') !~ /^_abv_/ } @pages_all; | |
| 885 | my %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. | |
| 891 | my @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. | |
| 903 | require MODS::WebSTLs::Experiments; | |
| 904 | my $exh_for_tests = MODS::WebSTLs::Experiments->new; | |
| 905 | my @test_rows; | |
| 906 | foreach 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. | |
| 966 | my %page_has_test; | |
| 967 | foreach 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. | |
| 980 | my $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__); | |
| 991 | my $sample_model_id = ($first_model && $first_model->{model_id}) ? $first_model->{model_id} : 0; | |
| 992 | ||
| 993 | my $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 | ||
| 999 | my $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 | ||
| 1003 | my $store_url = $store->{custom_domain} || ($store->{subdomain} . '.webstls.com'); | |
| 1004 | my $store_name = $store->{name} || $store->{subdomain}; | |
| 1005 | ||
| 1006 | my $custom_domain = $store->{custom_domain} // ''; | |
| 1007 | my $has_custom = ($custom_domain ne '') ? 1 : 0; | |
| 1008 | my $rev_cents = $orders_today->{rev_cents} || 0; | |
| 1009 | my $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. | |
| 1015 | my $current_theme_id = $store->{theme_id} || 1; | |
| 1016 | my $current_theme = $themes->resolve_for_storefront($store, $db, $dbh, $DB); | |
| 1017 | my $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. | |
| 1021 | my @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. | |
| 1033 | my $current_layout_id = $store->{layout_id} || 1; | |
| 1034 | my $current_layout = $layouts->by_id($current_layout_id); | |
| 1035 | my @layout_rows; | |
| 1036 | foreach 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:]. | |
| 1061 | my @theme_rows; | |
| 1062 | foreach 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. | |
| 1092 | my @custom_theme_rows; | |
| 1093 | foreach 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. | |
| 1157 | my %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 | |
| 1178 | my $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}; | |
| 1181 | my $stripe_connect_url = "/stripe_connect.cgi?storefront_id=$store_id"; | |
| 1182 | my $stripe_flash = ''; | |
| 1183 | $stripe_flash = 'connected' if ($form->{stripe_connected} // '') eq '1'; | |
| 1184 | $stripe_flash = 'pending' if ($form->{stripe_pending} // '') eq '1'; | |
| 1185 | my $stripe_error = $form->{stripe_error} // ''; | |
| 1186 | ||
| 1187 | my $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 | ||
| 1312 | my $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 | ||
| 1323 | exit; | |
| 1324 | ||
| 1325 | #====================================================================== | |
| 1326 | # Helpers | |
| 1327 | #====================================================================== | |
| 1328 | ||
| 1329 | sub _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. | |
| 1338 | sub _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 | ||
| 1350 | sub _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 | ||
| 1359 | sub _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 | #====================================================================== | |
| 1378 | sub _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 | ||
| 1475 | sub _sfr_h { | |
| 1476 | my $s = shift; $s //= ''; | |
| 1477 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; $s =~ s/"/"/g; | |
| 1478 | return $s; | |
| 1479 | } |