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