added on local at 2026-07-01 21:47:35
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer — public storefront (buyer-facing) | |
| 4 | # | |
| 5 | # Resolves a storefront and renders the customer experience. Works | |
| 6 | # three ways: | |
| 7 | # | |
| 8 | # /store.cgi → demo (sample-data storefront) | |
| 9 | # /store.cgi?id=42 → storefront with id=42 | |
| 10 | # /store.cgi?subdomain=NAME → storefront WHERE subdomain=NAME | |
| 11 | # /store.cgi?host=NAME.example.com → storefront WHERE custom_domain=… | |
| 12 | # | |
| 13 | # When no real storefront matches, falls back to demo content so | |
| 14 | # creators can see the buyer view before they have data populated. | |
| 15 | # No login required — storefronts are public by design. | |
| 16 | #====================================================================== | |
| 17 | use strict; | |
| 18 | use warnings; | |
| 19 | ||
| 20 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 21 | use CGI; | |
| 22 | use MODS::Template; | |
| 23 | use MODS::DBConnect; | |
| 24 | use MODS::Login; | |
| 25 | use MODS::RePricer::Config; | |
| 26 | use MODS::RePricer::Wrapper; | |
| 27 | use MODS::RePricer::Themes; | |
| 28 | use MODS::RePricer::Layouts; | |
| 29 | use MODS::RePricer::Cart; | |
| 30 | use MODS::RePricer::Experiments; | |
| 31 | use MODS::RePricer::BuyerAuth; | |
| 32 | ||
| 33 | my $q = CGI->new; | |
| 34 | my $form = $q->Vars; | |
| 35 | my $tfile = MODS::Template->new; | |
| 36 | my $db = MODS::DBConnect->new; | |
| 37 | my $auth = MODS::Login->new; | |
| 38 | my $config = MODS::RePricer::Config->new; | |
| 39 | my $wrap = MODS::RePricer::Wrapper->new; | |
| 40 | my $themes = MODS::RePricer::Themes->new; | |
| 41 | my $layouts = MODS::RePricer::Layouts->new; | |
| 42 | my $cart = MODS::RePricer::Cart->new; | |
| 43 | ||
| 44 | my $DB = $config->settings('database_name'); | |
| 45 | ||
| 46 | $|=1; | |
| 47 | ||
| 48 | # Optional creator session — used to show a "you're previewing your own | |
| 49 | # store" badge. Buyers visit without one. | |
| 50 | my $userinfo = $auth->login_verify(); | |
| 51 | ||
| 52 | # Visitor cookie for experiment bucketing. Minted on first visit and | |
| 53 | # kept for 90 days; SHA1 hash of this token is what we store in | |
| 54 | # ab_test_events.visitor_hash. Cookie has to be set BEFORE any body | |
| 55 | # output (we print Content-Type below) so we run this up front. | |
| 56 | my $ex_helper = MODS::RePricer::Experiments->new; | |
| 57 | my $visitor_token = $ex_helper->read_visitor($q); | |
| 58 | my $visitor_cookie = ''; | |
| 59 | unless ($visitor_token) { | |
| 60 | ($visitor_token, $visitor_cookie) = $ex_helper->mint_visitor; | |
| 61 | } | |
| 62 | ||
| 63 | my $tvars = _resolve_storefront($form, $userinfo); | |
| 64 | ||
| 65 | print "Content-Type: text/html; charset=utf-8\n"; | |
| 66 | print "Set-Cookie: $visitor_cookie\n" if $visitor_cookie; | |
| 67 | print "Cache-Control: no-cache\n\n"; | |
| 68 | ||
| 69 | # Buyer-facing pages don't use the creator sidebar. Render the marketing | |
| 70 | # shell (which we re-use here) by passing userinfo => undef. The actual | |
| 71 | # storefront body comes from the layout-specific template chosen above | |
| 72 | # in _resolve_storefront() and passed in as $tvars->{layout_template}. | |
| 73 | my $layout_template = $tvars->{layout_template} || 'store_layouts/catalog.html'; | |
| 74 | my $body = join('', $tfile->template($layout_template, $tvars, undef)); | |
| 75 | ||
| 76 | # Universal collection-page products grid. When a seller's page has | |
| 77 | # page_type='collection' (e.g. the default "Catalog" page at /shop) | |
| 78 | # we want it to render every published product below the page body, | |
| 79 | # even on layouts whose specific-page branch only shows title + body. | |
| 80 | # Without this injection, clicking "Catalog" in the header just | |
| 81 | # loaded an empty page with the title and pagination but no products. | |
| 82 | # Layouts that render the grid natively (catalog.html does, via | |
| 83 | # [if:$is_collection_page]) mark it with id="store-collection-products" | |
| 84 | # so this injection no-ops. | |
| 85 | if ($tvars->{is_collection_page} | |
| 86 | && $tvars->{has_products} | |
| 87 | && $body !~ /id=["']store-collection-products["']/) { | |
| 88 | my $grid = qq~ | |
| 89 | <section id="store-collection-products" style="padding:20px 32px 60px;max-width:1480px;margin:0 auto"> | |
| 90 | <div class="model-grid">~; | |
| 91 | foreach my $p (@{ $tvars->{products} }) { | |
| 92 | my $pid = $p->{id} + 0; | |
| 93 | # $p->{title} already contains pre-rendered HTML (e.g. a leading | |
| 94 | # "<span class='pill'>Digital</span>" tag injected by the product | |
| 95 | # builder). HTML-escaping it again would surface the raw tags as | |
| 96 | # text on the page, so pass it through verbatim -- this matches | |
| 97 | # how catalog.html and the other layouts render $loop1.title. | |
| 98 | # The img alt= attribute does need an escaped, tag-stripped | |
| 99 | # variant so an HTML attribute can hold it safely. | |
| 100 | my $ptitle = defined $p->{title} ? $p->{title} : ''; | |
| 101 | my $pprice = defined $p->{price} ? $p->{price} : ''; | |
| 102 | my $phero = defined $p->{hero_image} ? $p->{hero_image} : ''; | |
| 103 | my $prating = defined $p->{rating_n} ? $p->{rating_n} : 0; | |
| 104 | my $palt = $ptitle; | |
| 105 | $palt =~ s/<[^>]+>//g; # strip tags | |
| 106 | for ($palt) { s/&/&/g; s/"/"/g; s/</</g; s/>/>/g; } | |
| 107 | $grid .= qq~ | |
| 108 | <a href="/listing_details.cgi?id=$pid" class="model-card" style="text-decoration:none"> | |
| 109 | <div class="model-thumb"><img src="$phero" alt="$palt" loading="lazy"></div> | |
| 110 | <div class="model-info"> | |
| 111 | <div class="model-name">$ptitle</div> | |
| 112 | <div class="model-row"> | |
| 113 | <span class="model-price">$pprice</span> | |
| 114 | <span class="text-dim text-xs">$prating reviews</span> | |
| 115 | </div> | |
| 116 | </div> | |
| 117 | </a>~; | |
| 118 | } | |
| 119 | $grid .= "\n </div>\n</section>\n"; | |
| 120 | # Splice in just after the page body wrapper so it sits right | |
| 121 | # below the page title + body content. | |
| 122 | if ($body =~ m{</div>\s*</section>}s) { | |
| 123 | $body =~ s{(</div>\s*</section>)}{$1$grid}s; | |
| 124 | } else { | |
| 125 | $body .= $grid; | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | # Universal pagination injection. Every storefront layout (current | |
| 130 | # and future) gets pagination at the bottom of its body if the layout | |
| 131 | # itself didn't already place the $pagination_html token. The | |
| 132 | # id="store-pagination" marker tells us whether the layout opted in. | |
| 133 | # This way new themes need zero work to support paginated lists. | |
| 134 | # | |
| 135 | # Only inject on pages that actually show a paginated product list: | |
| 136 | # - home / default catalog view (no specific page selected), OR | |
| 137 | # - a collection-type page (page_type='collection', e.g. /shop) | |
| 138 | # An about, product detail, or generic custom page should NOT get | |
| 139 | # product pagination underneath its body content. | |
| 140 | my $allow_pagination = !$tvars->{is_specific_page} | |
| 141 | || $tvars->{is_collection_page}; | |
| 142 | if ($tvars->{pagination_html} | |
| 143 | && $tvars->{pagination_html} ne '' | |
| 144 | && $allow_pagination | |
| 145 | && $body !~ /id=["']store-pagination["']/) { | |
| 146 | $body .= $tvars->{pagination_html}; | |
| 147 | } | |
| 148 | ||
| 149 | # Seller category chips fallback: each layout's header is expected to | |
| 150 | # include $store_categories_html (built up in _resolve_storefront). If | |
| 151 | # a layout was authored before that tvar existed -- or omitted it on | |
| 152 | # purpose -- splice the chip strip in immediately after the first | |
| 153 | # </header> so chips never go missing on any layout. Marker check | |
| 154 | # avoids double-injection when the layout did include the tvar. | |
| 155 | if (!$form->{editor_live} && !$form->{heatmap_view} | |
| 156 | && $tvars->{store_id} && !$tvars->{is_demo} | |
| 157 | && $tvars->{store_categories_html} | |
| 158 | && $body !~ /id=["']store-categories["']/) { | |
| 159 | my $cats = $tvars->{store_categories_html}; | |
| 160 | if ($body =~ /<\/header>/i) { | |
| 161 | $body =~ s{(</header>)}{$1\n$cats}i; | |
| 162 | } else { | |
| 163 | $body = $cats . $body; | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 167 | # Universal storefront discovery widgets -- bundles section, | |
| 168 | # subscription plans section. Injected outside the layout so they | |
| 169 | # render on every theme without per-template edits. Same opt-out | |
| 170 | # pattern (id sentinel) so a layout that natively renders these | |
| 171 | # inline can suppress the injected copy. | |
| 172 | # (Seller-category chips moved into the unified store-topbar above.) | |
| 173 | if (!$form->{editor_live} && !$form->{heatmap_view} | |
| 174 | && $tvars->{store_id} && !$tvars->{is_demo} | |
| 175 | && $body !~ /id=["']store-discovery["']/) { | |
| 176 | my $sid_d = $tvars->{store_id}; $sid_d =~ s/[^0-9]//g; | |
| 177 | my $disc_dbh = $db->db_connect(); | |
| 178 | my $disc_html = ''; | |
| 179 | ||
| 180 | # ---- Bundles + subscription tiles (below the catalog, before footer) ---- | |
| 181 | my $have_bun = 0; | |
| 182 | { | |
| 183 | my $r = $db->db_readwrite($disc_dbh, qq~ | |
| 184 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 185 | WHERE table_schema='$DB' AND table_name='bundles' | |
| 186 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 187 | $have_bun = 1 if $r && $r->{n}; | |
| 188 | } | |
| 189 | my $have_sub = 0; | |
| 190 | { | |
| 191 | my $r = $db->db_readwrite($disc_dbh, qq~ | |
| 192 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 193 | WHERE table_schema='$DB' AND table_name='subscription_plans' | |
| 194 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 195 | $have_sub = 1 if $r && $r->{n}; | |
| 196 | } | |
| 197 | ||
| 198 | my $sections_html = ''; | |
| 199 | ||
| 200 | if ($have_bun) { | |
| 201 | # Owner sees their own draft bundles too (with a DRAFT badge) so | |
| 202 | # they can preview before publishing. Buyers see published only. | |
| 203 | my $bundle_owner_uid = ($userinfo && $userinfo->{user_id}) ? ($userinfo->{user_id} + 0) : 0; | |
| 204 | my $bundle_owner_check = $db->db_readwrite($disc_dbh, qq~ | |
| 205 | SELECT user_id FROM `${DB}`.storefronts WHERE id='$sid_d' LIMIT 1 | |
| 206 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 207 | my $is_owner_bun = ($bundle_owner_check | |
| 208 | && $bundle_owner_check->{user_id} | |
| 209 | && $bundle_owner_uid | |
| 210 | && $bundle_owner_check->{user_id} eq $bundle_owner_uid) ? 1 : 0; | |
| 211 | my $bundle_status_filter = $is_owner_bun | |
| 212 | ? qq~status IN ('published','draft')~ | |
| 213 | : qq~status='published'~; | |
| 214 | ||
| 215 | my @brows = $db->db_readwrite_multiple($disc_dbh, qq~ | |
| 216 | SELECT id, title, tagline, product_kind, base_price_cents, | |
| 217 | physical_price_cents, hero_image_url, status | |
| 218 | FROM `${DB}`.bundles | |
| 219 | WHERE storefront_id='$sid_d' AND $bundle_status_filter | |
| 220 | ORDER BY id DESC LIMIT 12 | |
| 221 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 222 | if (@brows) { | |
| 223 | $sections_html .= qq~ | |
| 224 | <section id="store-discovery-bundles" style="padding:40px 24px;max-width:1200px;margin:0 auto;color:var(--col-text,#e6e9ef)"> | |
| 225 | <h2 style="font-family:var(--font-display,inherit);font-size:24px;margin:0 0 14px">Bundles</h2> | |
| 226 | <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px">~; | |
| 227 | foreach my $b (@brows) { | |
| 228 | my $kind = $b->{product_kind} || 'digital'; | |
| 229 | my $cents = ($kind eq 'physical') | |
| 230 | ? ($b->{physical_price_cents} || 0) | |
| 231 | : ($b->{base_price_cents} || 0); | |
| 232 | my $price = '$' . sprintf('%.2f', $cents / 100); | |
| 233 | my $title = $b->{title} || ''; $title =~ s/&/&/g; $title =~ s/</</g; $title =~ s/>/>/g; | |
| 234 | my $tag = $b->{tagline} || ''; $tag =~ s/&/&/g; $tag =~ s/</</g; $tag =~ s/>/>/g; | |
| 235 | my $bid = $b->{id}; | |
| 236 | my $draft_badge = (($b->{status} || '') eq 'draft') | |
| 237 | ? qq~<span style="font-size:9px;letter-spacing:1px;text-transform:uppercase;padding:2px 6px;border-radius:4px;background:rgba(251,191,36,0.18);color:#fbbf24;font-weight:700;margin-left:6px">Draft</span>~ | |
| 238 | : ''; | |
| 239 | $sections_html .= qq~ | |
| 240 | <div style="padding:14px;border:1px solid var(--col-border,rgba(255,255,255,0.10));border-radius:12px;background:var(--col-surface-1,rgba(255,255,255,0.04))"> | |
| 241 | <div style="display:flex;justify-content:space-between;align-items:center;gap:8px"> | |
| 242 | <span style="font-size:10px;letter-spacing:1px;text-transform:uppercase;padding:2px 8px;border-radius:4px;background:rgba(168,85,247,0.18);color:#c084fc;font-weight:700">Bundle · \U$kind\E</span>$draft_badge | |
| 243 | <span style="font-family:var(--font-mono,monospace);font-weight:700;color:var(--col-accent-bright,#60a5fa)">$price</span> | |
| 244 | </div> | |
| 245 | <div style="font-size:15px;font-weight:700;margin:8px 0 4px">$title</div> | |
| 246 | <div style="font-size:12px;color:var(--col-text-2,#a0a4b0);min-height:18px">$tag</div> | |
| 247 | <button type="button" class="we-add-to-cart" data-bundle-id="$bid" data-sid="$sid_d" style="margin-top:10px;width:100%;padding:8px 14px;border:0;border-radius:8px;background:linear-gradient(135deg,#3b82f6,#7c3aed);color:#fff;font-weight:700;font-size:12px;cursor:pointer">Add bundle to cart</button> | |
| 248 | </div>~; | |
| 249 | } | |
| 250 | $sections_html .= "\n </div>\n</section>\n"; | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | if ($have_sub) { | |
| 255 | my @prows = $db->db_readwrite_multiple($disc_dbh, qq~ | |
| 256 | SELECT id, name, tagline, kind, price_cents, cadence_days, quota_per_period | |
| 257 | FROM `${DB}`.subscription_plans | |
| 258 | WHERE storefront_id='$sid_d' AND status='active' | |
| 259 | ORDER BY price_cents ASC LIMIT 12 | |
| 260 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 261 | if (@prows) { | |
| 262 | $sections_html .= qq~ | |
| 263 | <section id="store-discovery-plans" style="padding:40px 24px;max-width:1200px;margin:0 auto;color:var(--col-text,#e6e9ef)"> | |
| 264 | <h2 style="font-family:var(--font-display,inherit);font-size:24px;margin:0 0 14px">Subscribe</h2> | |
| 265 | <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px">~; | |
| 266 | foreach my $p (@prows) { | |
| 267 | my $name = $p->{name} || ''; $name =~ s/&/&/g; $name =~ s/</</g; $name =~ s/>/>/g; | |
| 268 | my $tag = $p->{tagline} || ''; $tag =~ s/&/&/g; $tag =~ s/</</g; $tag =~ s/>/>/g; | |
| 269 | my $price = '$' . sprintf('%.2f', ($p->{price_cents} || 0) / 100); | |
| 270 | my $cad = int($p->{cadence_days} || 30); | |
| 271 | my $qty = int($p->{quota_per_period} || 0); | |
| 272 | my $kind_label = ($p->{kind} || '') eq 'ship_monthly' ? 'Ship monthly' : 'Download quota'; | |
| 273 | my $pid = $p->{id}; | |
| 274 | $sections_html .= qq~ | |
| 275 | <div style="padding:18px;border:1px solid var(--col-border,rgba(255,255,255,0.10));border-radius:12px;background:var(--col-surface-1,rgba(255,255,255,0.04))"> | |
| 276 | <div style="font-size:10px;letter-spacing:1px;text-transform:uppercase;padding:2px 8px;border-radius:4px;background:rgba(34,197,94,0.18);color:#86efac;font-weight:700;display:inline-block">$kind_label</div> | |
| 277 | <div style="font-family:var(--font-display,inherit);font-size:18px;font-weight:700;margin:10px 0 4px">$name</div> | |
| 278 | <div style="font-size:12px;color:var(--col-text-2,#a0a4b0);min-height:18px;margin-bottom:10px">$tag</div> | |
| 279 | <div style="font-family:var(--font-mono,monospace);font-size:24px;font-weight:800;color:var(--col-accent-bright,#60a5fa)">$price <span style="font-size:11px;font-weight:400;color:var(--col-text-3,#777)">/ $cad days</span></div> | |
| 280 | <div style="font-size:11px;color:var(--col-text-3,#777);margin:6px 0 14px">$qty item(s) per period</div> | |
| 281 | <a href="/subscribe.cgi?plan=$pid" style="display:block;text-align:center;padding:10px 14px;border-radius:8px;background:linear-gradient(135deg,#10b981,#22c55e);color:#fff;font-weight:700;font-size:13px;text-decoration:none">Subscribe</a> | |
| 282 | </div>~; | |
| 283 | } | |
| 284 | $sections_html .= "\n </div>\n</section>\n"; | |
| 285 | } | |
| 286 | } | |
| 287 | ||
| 288 | $db->db_disconnect($disc_dbh); | |
| 289 | ||
| 290 | if ($disc_html || $sections_html) { | |
| 291 | # Sentinel wrapper so the opt-out check above sees a marker. | |
| 292 | # Inject chips at TOP of body, sections at BOTTOM. | |
| 293 | my $bundle_script = qq~ | |
| 294 | <script id="store-discovery"> | |
| 295 | /* Bundle add-to-cart shim -- mirror the per-listing handler but | |
| 296 | POST bundle_id instead of model_id. */ | |
| 297 | (function(){ | |
| 298 | document.addEventListener('click', function(e){ | |
| 299 | var btn = e.target.closest('button.we-add-to-cart[data-bundle-id]'); | |
| 300 | if (!btn) return; | |
| 301 | e.preventDefault(); | |
| 302 | var bid = btn.getAttribute('data-bundle-id'); | |
| 303 | var sid = btn.getAttribute('data-sid'); | |
| 304 | if (!bid || !sid) return; | |
| 305 | var orig = btn.textContent; | |
| 306 | btn.disabled = true; btn.textContent = 'Adding...'; | |
| 307 | var fd = new FormData(); | |
| 308 | fd.append('bundle_id', bid); | |
| 309 | fd.append('storefront_id', sid); | |
| 310 | fetch('/cart_add.cgi', { method:'POST', body:fd, credentials:'same-origin' }) | |
| 311 | .then(function(r){ return r.json(); }) | |
| 312 | .then(function(j){ | |
| 313 | if (!j || !j.success) throw new Error(j && j.error || 'add failed'); | |
| 314 | btn.textContent = 'Added \\u2713'; | |
| 315 | setTimeout(function(){ btn.disabled=false; btn.textContent=orig; }, 1500); | |
| 316 | }) | |
| 317 | .catch(function(err){ | |
| 318 | btn.disabled = false; btn.textContent = orig; | |
| 319 | if (typeof appToast === 'function') { appToast({ message: 'Bundle add failed: '+err.message, type:'error' }); } | |
| 320 | else { alert('Bundle add failed: '+err.message); } | |
| 321 | }); | |
| 322 | }); | |
| 323 | })(); | |
| 324 | </script>~; | |
| 325 | $body = $disc_html . $body . $sections_html . $bundle_script; | |
| 326 | } | |
| 327 | } | |
| 328 | ||
| 329 | # Editor-mode body injection: if the rendered layout did not already | |
| 330 | # include a we-page-body div (typical for the HOME page, where the | |
| 331 | # layout renders hero + catalog + about instead of a generic body), | |
| 332 | # splice an editable section in just before the footer. This is what | |
| 333 | # lets the creator click-and-type on the right pane of the page editor | |
| 334 | # even when their layout doesn't have a body slot natively. | |
| 335 | if ($form->{editor_live} && $tvars->{is_demo} == 0 | |
| 336 | && $body !~ /id=["']we-page-body["']/) { | |
| 337 | my $epid = $form->{editor_page_id} || ''; | |
| 338 | $epid =~ s/[^0-9]//g; | |
| 339 | my $injected_body = ''; | |
| 340 | if ($epid) { | |
| 341 | my $dbh2 = $db->db_connect(); | |
| 342 | my $epage = $db->db_readwrite($dbh2, | |
| 343 | qq~SELECT layout FROM `${DB}`.storefront_pages | |
| 344 | WHERE id='$epid' LIMIT 1~, | |
| 345 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 346 | $db->db_disconnect($dbh2); | |
| 347 | if ($epage && defined $epage->{layout}) { | |
| 348 | $injected_body = _extract_body_from_layout($epage->{layout}); | |
| 349 | } | |
| 350 | } | |
| 351 | my $section = qq~ | |
| 352 | <section style="padding:64px 32px;border-top:1px solid var(--col-border);background:var(--col-bg)"> | |
| 353 | <div style="max-width:1100px;margin:0 auto"> | |
| 354 | <div style="font-family:var(--font-mono,ui-monospace,monospace);font-size:11px;letter-spacing:3px;text-transform:uppercase;color:var(--col-accent-bright);font-weight:700;margin-bottom:14px">Page body</div> | |
| 355 | <div id="we-page-body" class="we-page-body" style="font-size:16px;line-height:1.7;color:var(--col-text);min-height:120px">$injected_body</div> | |
| 356 | </div> | |
| 357 | </section>~; | |
| 358 | # Inject before the first <footer ...>. If no footer, append. | |
| 359 | if ($body =~ /<footer\b/i) { | |
| 360 | $body =~ s|<footer\b|$section<footer|i; | |
| 361 | } else { | |
| 362 | $body .= $section; | |
| 363 | } | |
| 364 | } | |
| 365 | ||
| 366 | # Live-preview hook for the page editor. When ?editor_live=1 is on the | |
| 367 | # URL AND the storefront is owned by the logged-in user, append a tiny | |
| 368 | # script that listens for postMessage from the editor's parent window | |
| 369 | # and swaps the body content live -- no reload per keystroke. | |
| 370 | if ($form->{editor_live} && $tvars->{is_demo} == 0) { | |
| 371 | my $sid_int = $tvars->{store_id} || 0; | |
| 372 | $sid_int =~ s/[^0-9]//g; | |
| 373 | my $is_owner_check = ($userinfo && $userinfo->{user_id}) ? 1 : 0; | |
| 374 | if ($is_owner_check && $sid_int) { | |
| 375 | $body .= q~ | |
| 376 | <style> | |
| 377 | /* Visual editing surface: a clear dashed outline so the creator sees | |
| 378 | what's editable. The chrome (header, hero, footer) stays untouched. */ | |
| 379 | #we-page-body { | |
| 380 | outline: 2px dashed rgba(124,58,237,0.55); | |
| 381 | outline-offset: 6px; | |
| 382 | border-radius: 4px; | |
| 383 | min-height: 80px; | |
| 384 | cursor: text; | |
| 385 | transition: outline-color 0.15s, background 0.15s; | |
| 386 | } | |
| 387 | #we-page-body:hover { outline-color: rgba(124,58,237,0.85); } | |
| 388 | #we-page-body:focus { outline-color: #c084fc; outline-style: solid; } | |
| 389 | #we-page-body:empty::before { | |
| 390 | content: 'Click here to start writing. Drag in images, paste screenshots, or use the toolbar on the left.'; | |
| 391 | color: rgba(255,255,255,0.4); | |
| 392 | pointer-events: none; | |
| 393 | display: block; | |
| 394 | padding: 12px 0; | |
| 395 | font-style: italic; | |
| 396 | } | |
| 397 | #we-page-body.we-drop { background: rgba(124,58,237,0.10); } | |
| 398 | ||
| 399 | /* When editing, the catalog/spotlight layouts' overlapping floating | |
| 400 | cards make individual images hard to see and click. Unstack them | |
| 401 | into a clean vertical column with no overlap so every hero image | |
| 402 | is fully visible and reachable for replace / drag-to-reposition. */ | |
| 403 | .float-card, | |
| 404 | .float-card.fc-1, | |
| 405 | .float-card.fc-2, | |
| 406 | .float-card.fc-3 { | |
| 407 | position: relative !important; | |
| 408 | top: auto !important; bottom: auto !important; | |
| 409 | left: auto !important; right: auto !important; | |
| 410 | width: 100% !important; | |
| 411 | max-width: 360px; | |
| 412 | height: 220px !important; | |
| 413 | margin: 0 auto 14px; | |
| 414 | animation: none !important; | |
| 415 | } | |
| 416 | /* The cards' parent has a fixed 480px height in the catalog layout. | |
| 417 | Reset it so the unstacked column can grow naturally. */ | |
| 418 | .float-card:first-child { margin-top: 0; } | |
| 419 | section [style*="height:480px"], | |
| 420 | section [style*="height: 480px"] { height: auto !important; min-height: 0 !important; } | |
| 421 | </style> | |
| 422 | <script> | |
| 423 | (function(){ | |
| 424 | var area = document.getElementById('we-page-body'); | |
| 425 | if (!area) return; | |
| 426 | ||
| 427 | area.setAttribute('contenteditable', 'true'); | |
| 428 | area.setAttribute('spellcheck', 'true'); | |
| 429 | ||
| 430 | // Notify parent of body changes so the form's hidden input stays in | |
| 431 | // sync. The save flow on the parent reads from that input on submit. | |
| 432 | function reportChange() { | |
| 433 | if (window.parent && window.parent !== window) { | |
| 434 | window.parent.postMessage({ type: 'we-body-changed', html: area.innerHTML }, '*'); | |
| 435 | } | |
| 436 | } | |
| 437 | area.addEventListener('input', reportChange); | |
| 438 | ||
| 439 | // Receive commands FROM the parent (toolbar buttons in left pane). | |
| 440 | window.addEventListener('message', function(e) { | |
| 441 | if (!e || !e.data || typeof e.data !== 'object') return; | |
| 442 | var d = e.data; | |
| 443 | ||
| 444 | if (d.type === 'we-set-body') { | |
| 445 | area.innerHTML = d.html || ''; | |
| 446 | return; | |
| 447 | } | |
| 448 | if (d.type === 'we-exec-command') { | |
| 449 | area.focus(); | |
| 450 | try { document.execCommand(d.command, false, d.value || null); } catch (err) {} | |
| 451 | reportChange(); | |
| 452 | return; | |
| 453 | } | |
| 454 | if (d.type === 'we-insert-html') { | |
| 455 | area.focus(); | |
| 456 | var ok = false; | |
| 457 | try { ok = document.execCommand('insertHTML', false, d.html); } catch (err) {} | |
| 458 | if (!ok) { | |
| 459 | var sel = window.getSelection(); | |
| 460 | if (sel && sel.rangeCount) { | |
| 461 | var range = sel.getRangeAt(0); | |
| 462 | range.deleteContents(); | |
| 463 | var tmp = document.createElement('div'); | |
| 464 | tmp.innerHTML = d.html; | |
| 465 | var frag = document.createDocumentFragment(); | |
| 466 | while (tmp.firstChild) frag.appendChild(tmp.firstChild); | |
| 467 | range.insertNode(frag); | |
| 468 | } else { | |
| 469 | area.insertAdjacentHTML('beforeend', d.html); | |
| 470 | } | |
| 471 | } | |
| 472 | reportChange(); | |
| 473 | return; | |
| 474 | } | |
| 475 | if (d.type === 'we-query-state') { | |
| 476 | // Parent asked which formatting buttons should appear active. | |
| 477 | var states = {}; | |
| 478 | ['bold','italic','underline','strikeThrough'].forEach(function(c){ | |
| 479 | try { states[c] = document.queryCommandState(c); } catch (e) {} | |
| 480 | }); | |
| 481 | if (window.parent && window.parent !== window) { | |
| 482 | window.parent.postMessage({ type: 'we-state', states: states }, '*'); | |
| 483 | } | |
| 484 | return; | |
| 485 | } | |
| 486 | }); | |
| 487 | ||
| 488 | // Selection changes in the iframe -> tell the parent so the toolbar | |
| 489 | // can highlight Bold/Italic/etc. correctly. | |
| 490 | document.addEventListener('selectionchange', function() { | |
| 491 | var sel = window.getSelection(); | |
| 492 | if (!sel || !area.contains(sel.anchorNode)) return; | |
| 493 | var states = {}; | |
| 494 | ['bold','italic','underline','strikeThrough'].forEach(function(c){ | |
| 495 | try { states[c] = document.queryCommandState(c); } catch (e) {} | |
| 496 | }); | |
| 497 | if (window.parent && window.parent !== window) { | |
| 498 | window.parent.postMessage({ type: 'we-state', states: states }, '*'); | |
| 499 | } | |
| 500 | }); | |
| 501 | ||
| 502 | // Drag/drop and paste -> upload directly from the iframe (same origin | |
| 503 | // so fetch works fine). Insert at cursor and report change. | |
| 504 | ['dragenter','dragover'].forEach(function(ev){ | |
| 505 | area.addEventListener(ev, function(e){ | |
| 506 | var t = e.dataTransfer; | |
| 507 | if (t && t.types && Array.prototype.indexOf.call(t.types,'Files') !== -1) { | |
| 508 | e.preventDefault(); | |
| 509 | area.classList.add('we-drop'); | |
| 510 | } | |
| 511 | }); | |
| 512 | }); | |
| 513 | ['dragleave','drop'].forEach(function(ev){ | |
| 514 | area.addEventListener(ev, function(){ area.classList.remove('we-drop'); }); | |
| 515 | }); | |
| 516 | area.addEventListener('drop', function(e){ | |
| 517 | var files = e.dataTransfer && e.dataTransfer.files; | |
| 518 | if (!files || !files.length) return; | |
| 519 | e.preventDefault(); | |
| 520 | Array.prototype.slice.call(files).forEach(function(f){ | |
| 521 | if (/^image\//.test(f.type)) uploadImage(f); | |
| 522 | }); | |
| 523 | }); | |
| 524 | area.addEventListener('paste', function(e){ | |
| 525 | var items = e.clipboardData && e.clipboardData.items; | |
| 526 | if (!items) return; | |
| 527 | for (var i = 0; i < items.length; i++) { | |
| 528 | if (items[i].kind === 'file' && /^image\//.test(items[i].type)) { | |
| 529 | e.preventDefault(); | |
| 530 | uploadImage(items[i].getAsFile()); | |
| 531 | return; | |
| 532 | } | |
| 533 | } | |
| 534 | }); | |
| 535 | function uploadImage(file) { | |
| 536 | if (window.parent && window.parent !== window) { | |
| 537 | window.parent.postMessage({ type: 'we-upload-progress', message: 'Uploading ' + (file.name || 'image') + '...' }, '*'); | |
| 538 | } | |
| 539 | var fd = new FormData(); | |
| 540 | fd.append('image', file); | |
| 541 | fetch('/upload_image.cgi', { method: 'POST', body: fd, credentials: 'same-origin' }) | |
| 542 | .then(function(r){ return r.json(); }) | |
| 543 | .then(function(data){ | |
| 544 | if (window.parent && window.parent !== window) { | |
| 545 | window.parent.postMessage({ type: 'we-upload-done', success: !!(data && data.success), error: data && data.error }, '*'); | |
| 546 | } | |
| 547 | if (data && data.success && data.url) { | |
| 548 | area.focus(); | |
| 549 | var safeAlt = (file.name || '').replace(/&/g,'&').replace(/"/g,'"'); | |
| 550 | var html = '<img src="' + data.url + '" alt="' + safeAlt + '"><br>'; | |
| 551 | try { document.execCommand('insertHTML', false, html); } catch (e) { | |
| 552 | area.insertAdjacentHTML('beforeend', html); | |
| 553 | } | |
| 554 | reportChange(); | |
| 555 | } | |
| 556 | }) | |
| 557 | .catch(function(err){ | |
| 558 | if (window.parent && window.parent !== window) { | |
| 559 | window.parent.postMessage({ type: 'we-upload-done', success: false, error: err.message }, '*'); | |
| 560 | } | |
| 561 | }); | |
| 562 | } | |
| 563 | ||
| 564 | // Tell parent we're ready so the initial body can be pushed in. | |
| 565 | if (window.parent && window.parent !== window) { | |
| 566 | window.parent.postMessage({ type: 'we-preview-ready', html: area.innerHTML }, '*'); | |
| 567 | } | |
| 568 | })(); | |
| 569 | ||
| 570 | // ==================================================================== | |
| 571 | // While the storefront is in editor mode, block all link navigation | |
| 572 | // inside the iframe. Otherwise clicking the brand-mark anchor (or any | |
| 573 | // other <a href>) would navigate the iframe away from the editor URL, | |
| 574 | // which currently appears as the page editor nesting itself. | |
| 575 | // Real navigation works again the moment the creator hits "View as | |
| 576 | // buyer" in the top right of the editor panel. | |
| 577 | // ==================================================================== | |
| 578 | document.addEventListener('click', function(e) { | |
| 579 | var a = e.target.closest('a[href]'); | |
| 580 | if (!a) return; | |
| 581 | // Allow clicks that would open in a new tab/window (target=_blank, | |
| 582 | // middle-click, ctrl-click) -- those are explicit user intent. | |
| 583 | if (a.target === '_blank' || e.ctrlKey || e.metaKey || e.button !== 0) return; | |
| 584 | // If the click is meant for an in-place editor (a click-to-edit text | |
| 585 | // span nested inside the anchor, like a CTA button label), block | |
| 586 | // navigation but DO NOT stop propagation -- the text-edit handler | |
| 587 | // in bubble phase still needs to receive this click. | |
| 588 | if (e.target.closest('[data-we-editable]')) { | |
| 589 | e.preventDefault(); | |
| 590 | return; | |
| 591 | } | |
| 592 | e.preventDefault(); | |
| 593 | e.stopPropagation(); | |
| 594 | }, true); | |
| 595 | ||
| 596 | // ==================================================================== | |
| 597 | // Inline field editing for [data-we-editable] elements. | |
| 598 | // Text fields become contenteditable on click and save on blur. | |
| 599 | // Image fields open a file picker, upload, and save the new URL. | |
| 600 | // ==================================================================== | |
| 601 | (function(){ | |
| 602 | var EDIT_STYLE = document.createElement('style'); | |
| 603 | EDIT_STYLE.textContent = '' | |
| 604 | + '[data-we-editable] { cursor: text; outline-offset: 4px; transition: outline-color 0.15s, background 0.15s; }' | |
| 605 | + '[data-we-editable]:hover { outline: 2px dashed rgba(124,58,237,0.65); }' | |
| 606 | + '[data-we-editable][contenteditable="true"]:focus { outline: 2px solid #c084fc; background: rgba(124,58,237,0.05); }' | |
| 607 | + '[data-we-editable="image"] { cursor: grab; position: relative; user-select: none; }' | |
| 608 | + '[data-we-editable="image"]:hover { outline: 2px dashed rgba(124,58,237,0.65); }' | |
| 609 | + '[data-we-editable="image"].we-dragging { cursor: grabbing !important; outline: 2px solid #c084fc; }' | |
| 610 | /* Re-enable text selection on click-to-edit children that live */ | |
| 611 | /* inside an image-editable container (e.g. CTA labels, title, */ | |
| 612 | /* price nested in the spotlight hero). Without this they inherit */ | |
| 613 | /* user-select:none and the caret cannot land. */ | |
| 614 | + '[data-we-editable="image"] [data-we-editable="text"] { user-select: text; cursor: text; }' | |
| 615 | + '[data-we-editable="image"] [data-we-editable="text"]:hover { outline: 2px dashed rgba(124,58,237,0.65); }' | |
| 616 | + '[data-we-editable="image"]::after { content: "Click to replace . Drag to reposition"; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.75); color: #fff; padding: 6px 14px; border-radius: 6px; font-size: 12px; font-family: ui-monospace, monospace; letter-spacing: 1px; text-transform: uppercase; opacity: 0; transition: opacity 0.15s; pointer-events: none; white-space: nowrap; }' | |
| 617 | + '[data-we-editable="image"]:hover::after { opacity: 1; }' | |
| 618 | + '[data-we-editable="image"].we-dragging::after { opacity: 0; }' | |
| 619 | + '.we-field-saving { opacity: 0.6; }' | |
| 620 | + '.we-field-saved { box-shadow: 0 0 0 2px #10b981 inset; }' | |
| 621 | /* Secondary-tile "Feature" button. Hidden by default, fades in on */ | |
| 622 | /* hover of the tile. Click promotes that model to the spotlight. */ | |
| 623 | + '.we-tile { position: relative; }' | |
| 624 | + '.we-feature-btn { position: absolute; top: 8px; right: 8px; z-index: 5; background: rgba(0,0,0,0.78); color: #fff; border: 1px solid rgba(255,255,255,0.18); border-radius: 6px; padding: 5px 10px; font-size: 11px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; cursor: pointer; opacity: 0; transition: opacity 0.15s, transform 0.15s, background 0.15s; font-family: ui-monospace, monospace; }' | |
| 625 | + '.we-tile:hover .we-feature-btn { opacity: 1; }' | |
| 626 | + '.we-feature-btn:hover { background: #7c3aed; border-color: #a78bfa; transform: scale(1.04); }'; | |
| 627 | document.head.appendChild(EDIT_STYLE); | |
| 628 | ||
| 629 | // Save the given field to the server. Returns a promise. | |
| 630 | // Reads as text first so an empty/HTML response surfaces a real | |
| 631 | // error instead of a bare "Unexpected end of JSON input". | |
| 632 | // The "el" arg lets us pick the right endpoint based on the element's | |
| 633 | // data-we-target attribute: "model" routes to /update_model_field.cgi | |
| 634 | // with the model id, anything else hits /update_storefront_field.cgi. | |
| 635 | function saveField(field, value, el) { | |
| 636 | var target = (el && el.getAttribute && el.getAttribute('data-we-target')) || 'storefront'; | |
| 637 | var url, modelId; | |
| 638 | if (target === 'model') { | |
| 639 | url = '/update_model_field.cgi'; | |
| 640 | modelId = el.getAttribute('data-we-target-id') || ''; | |
| 641 | if (!modelId) { | |
| 642 | return Promise.reject(new Error('saveField: model target missing data-we-target-id')); | |
| 643 | } | |
| 644 | } else { | |
| 645 | url = '/update_storefront_field.cgi'; | |
| 646 | } | |
| 647 | var fd = new FormData(); | |
| 648 | fd.append('field', field); | |
| 649 | fd.append('value', value); | |
| 650 | if (modelId) fd.append('model_id', modelId); | |
| 651 | return fetch(url, { | |
| 652 | method: 'POST', body: fd, credentials: 'same-origin' | |
| 653 | }).then(function(r){ | |
| 654 | return r.text().then(function(body){ | |
| 655 | try { return JSON.parse(body); } | |
| 656 | catch (e) { | |
| 657 | var snippet = body ? body.replace(/<[^>]*>/g,' ').replace(/\s+/g,' ').trim().slice(0, 200) : '(empty body)'; | |
| 658 | throw new Error('saveField: server returned ' + r.status + ' ' + r.statusText + ' (' + snippet + ')'); | |
| 659 | } | |
| 660 | }); | |
| 661 | }); | |
| 662 | } | |
| 663 | ||
| 664 | // Visually flash the element to confirm save state. | |
| 665 | function flash(el, klass) { | |
| 666 | el.classList.add(klass); | |
| 667 | setTimeout(function(){ el.classList.remove(klass); }, 1200); | |
| 668 | } | |
| 669 | ||
| 670 | // ---- "Feature this model" buttons on secondary tiles ---- | |
| 671 | // Click promotes the chosen model to sort_order = MIN - 1 on the | |
| 672 | // storefront. The page reloads so the new featured product takes | |
| 673 | // over the spotlight hero. | |
| 674 | document.addEventListener('click', function(e) { | |
| 675 | var btn = e.target.closest('.we-feature-btn'); | |
| 676 | if (!btn) return; | |
| 677 | e.preventDefault(); | |
| 678 | e.stopPropagation(); | |
| 679 | var mid = btn.getAttribute('data-we-feature-id'); | |
| 680 | if (!mid) return; | |
| 681 | btn.disabled = true; | |
| 682 | btn.textContent = 'Saving...'; | |
| 683 | var fd = new FormData(); | |
| 684 | fd.append('action', 'feature'); | |
| 685 | fd.append('model_id', mid); | |
| 686 | fetch('/update_listing.cgi', { method: 'POST', body: fd, credentials: 'same-origin' }) | |
| 687 | .then(function(r){ | |
| 688 | return r.text().then(function(body){ | |
| 689 | try { return JSON.parse(body); } | |
| 690 | catch (e) { | |
| 691 | var snippet = body ? body.replace(/<[^>]*>/g,' ').replace(/\s+/g,' ').trim().slice(0, 200) : '(empty body)'; | |
| 692 | throw new Error('feature: server returned ' + r.status + ' ' + r.statusText + ' (' + snippet + ')'); | |
| 693 | } | |
| 694 | }); | |
| 695 | }) | |
| 696 | .then(function(data){ | |
| 697 | if (!data || !data.success) throw new Error(data && data.error || 'feature failed'); | |
| 698 | window.location.reload(); | |
| 699 | }) | |
| 700 | .catch(function(err){ | |
| 701 | btn.disabled = false; | |
| 702 | btn.innerHTML = '★ Feature'; | |
| 703 | appToast({ message: 'Feature failed: ' + err.message, type: 'error' }); | |
| 704 | }); | |
| 705 | }, true); | |
| 706 | ||
| 707 | // After saving a field, update every OTHER element on the page that | |
| 708 | // shows the same field so the storefront stays consistent without | |
| 709 | // a reload. (e.g., store name appears in header + footer.) | |
| 710 | function propagate(field, value) { | |
| 711 | document.querySelectorAll('[data-we-field="' + field + '"]').forEach(function(el){ | |
| 712 | if (el.getAttribute('data-we-editable') === 'text' && el.textContent !== value) { | |
| 713 | el.textContent = value; | |
| 714 | } | |
| 715 | }); | |
| 716 | } | |
| 717 | ||
| 718 | // ---- TEXT fields ---- | |
| 719 | document.addEventListener('click', function(e) { | |
| 720 | var el = e.target.closest('[data-we-editable="text"]'); | |
| 721 | if (!el) return; | |
| 722 | if (el.getAttribute('contenteditable') === 'true') return; // already editing | |
| 723 | ||
| 724 | el.setAttribute('contenteditable', 'true'); | |
| 725 | el.focus(); | |
| 726 | ||
| 727 | // Select all so the user can just start typing to replace. | |
| 728 | try { | |
| 729 | var range = document.createRange(); | |
| 730 | range.selectNodeContents(el); | |
| 731 | var sel = window.getSelection(); | |
| 732 | sel.removeAllRanges(); | |
| 733 | sel.addRange(range); | |
| 734 | } catch (err) {} | |
| 735 | ||
| 736 | var originalValue = el.textContent; | |
| 737 | ||
| 738 | var handleBlur = function() { | |
| 739 | el.removeEventListener('blur', handleBlur); | |
| 740 | el.removeEventListener('keydown', handleKey); | |
| 741 | el.setAttribute('contenteditable', 'false'); | |
| 742 | ||
| 743 | var newValue = el.textContent.trim(); | |
| 744 | if (newValue === originalValue) return; | |
| 745 | ||
| 746 | var field = el.getAttribute('data-we-field'); | |
| 747 | el.classList.add('we-field-saving'); | |
| 748 | saveField(field, newValue, el).then(function(data) { | |
| 749 | el.classList.remove('we-field-saving'); | |
| 750 | if (data && data.success) { | |
| 751 | flash(el, 'we-field-saved'); | |
| 752 | propagate(field, newValue); | |
| 753 | } else { | |
| 754 | el.textContent = originalValue; | |
| 755 | appToast({ message: 'Save failed: ' + (data && data.error || 'unknown'), type: 'error' }); | |
| 756 | } | |
| 757 | }).catch(function(err) { | |
| 758 | el.classList.remove('we-field-saving'); | |
| 759 | el.textContent = originalValue; | |
| 760 | appToast({ message: 'Save failed: ' + err.message, type: 'error' }); | |
| 761 | }); | |
| 762 | }; | |
| 763 | var handleKey = function(ev) { | |
| 764 | if (ev.key === 'Escape') { | |
| 765 | el.textContent = originalValue; | |
| 766 | el.blur(); | |
| 767 | } | |
| 768 | if (ev.key === 'Enter' && !ev.shiftKey) { | |
| 769 | ev.preventDefault(); | |
| 770 | el.blur(); | |
| 771 | } | |
| 772 | }; | |
| 773 | el.addEventListener('blur', handleBlur); | |
| 774 | el.addEventListener('keydown', handleKey); | |
| 775 | }); | |
| 776 | ||
| 777 | // ---- IMAGE fields ---- | |
| 778 | // Click and drag are unified here: a quick click opens the file picker | |
| 779 | // to replace the image; a drag (more than 5px) pans the focal point of | |
| 780 | // the existing image (CSS background-position) so the creator can pick | |
| 781 | // which part of an off-center photo to show through the layout's | |
| 782 | // fixed-aspect viewport. The new position is saved as a separate field | |
| 783 | // (data-we-field + '_pos') the moment the mouse is released. | |
| 784 | // (Important: NO literal tildes anywhere below -- this block lives | |
| 785 | // inside a Perl q-quoted heredoc and a tilde would end the heredoc.) | |
| 786 | var imgDrag = null; | |
| 787 | ||
| 788 | // Read the current background-position as [x%, y%]. Prefers an | |
| 789 | // explicit data-we-pos that the JS may have stored on a prior drag; | |
| 790 | // falls back to parsing the inline / computed style. | |
| 791 | function readBgPos(el) { | |
| 792 | var raw = el.dataset.wePos || el.style.backgroundPosition || ''; | |
| 793 | if (!raw) { | |
| 794 | var cs = getComputedStyle(el).backgroundPosition || ''; | |
| 795 | if (cs.indexOf('%') !== -1) raw = cs; | |
| 796 | } | |
| 797 | var parts = (raw || '50% 50%').split(/\s+/); | |
| 798 | var x = parseFloat(parts[0]); if (isNaN(x)) x = 50; | |
| 799 | var y = parseFloat(parts[1]); if (isNaN(y)) y = 50; | |
| 800 | return [x, y]; | |
| 801 | } | |
| 802 | ||
| 803 | document.addEventListener('mousedown', function(e) { | |
| 804 | if (e.button !== 0) return; | |
| 805 | // Nested text-editable child takes priority -- otherwise clicking | |
| 806 | // the featured product title inside the hero would start the image | |
| 807 | // drag/replace flow on the surrounding section instead of letting | |
| 808 | // the title go into contenteditable mode. | |
| 809 | if (e.target.closest('[data-we-editable="text"]')) return; | |
| 810 | // Same for buttons and links inside an image-editable container. | |
| 811 | if (e.target.closest('a, button')) return; | |
| 812 | var el = e.target.closest('[data-we-editable="image"]'); | |
| 813 | if (!el) return; | |
| 814 | e.preventDefault(); | |
| 815 | var bg = readBgPos(el); | |
| 816 | imgDrag = { | |
| 817 | el: el, | |
| 818 | field: el.getAttribute('data-we-field'), | |
| 819 | startX: e.clientX, | |
| 820 | startY: e.clientY, | |
| 821 | rect: el.getBoundingClientRect(), | |
| 822 | startBgX: bg[0], | |
| 823 | startBgY: bg[1], | |
| 824 | moved: false, | |
| 825 | pos: null | |
| 826 | }; | |
| 827 | }); | |
| 828 | ||
| 829 | document.addEventListener('mousemove', function(e) { | |
| 830 | if (!imgDrag) return; | |
| 831 | var dx = e.clientX - imgDrag.startX; | |
| 832 | var dy = e.clientY - imgDrag.startY; | |
| 833 | if (!imgDrag.moved && Math.abs(dx) < 5 && Math.abs(dy) < 5) return; | |
| 834 | imgDrag.moved = true; | |
| 835 | imgDrag.el.classList.add('we-dragging'); | |
| 836 | var r = imgDrag.rect; | |
| 837 | // Convert pixel delta to percentage of the element, then subtract | |
| 838 | // from the position the drag started at. Subtracting (not adding) | |
| 839 | // is what gives the "drag the image like a photo" feel -- drag | |
| 840 | // left and the image moves left, exposing what was off-screen to | |
| 841 | // the right. Positive cursor delta -> smaller background-position. | |
| 842 | var dxPct = dx / r.width * 100; | |
| 843 | var dyPct = dy / r.height * 100; | |
| 844 | var px = imgDrag.startBgX - dxPct; | |
| 845 | var py = imgDrag.startBgY - dyPct; | |
| 846 | if (px < 0) px = 0; if (px > 100) px = 100; | |
| 847 | if (py < 0) py = 0; if (py > 100) py = 100; | |
| 848 | var pos = px.toFixed(1) + '% ' + py.toFixed(1) + '%'; | |
| 849 | imgDrag.pos = pos; | |
| 850 | imgDrag.el.style.backgroundPosition = pos; | |
| 851 | imgDrag.el.dataset.wePos = pos; | |
| 852 | }); | |
| 853 | ||
| 854 | document.addEventListener('mouseup', function(e) { | |
| 855 | if (!imgDrag) return; | |
| 856 | var st = imgDrag; | |
| 857 | imgDrag = null; | |
| 858 | st.el.classList.remove('we-dragging'); | |
| 859 | ||
| 860 | if (st.moved && st.pos) { | |
| 861 | // Save the new focal point. Storefront images use a paired | |
| 862 | // <field>_pos column; model images store their position in a | |
| 863 | // single hero_image_pos column regardless of the URL field name, | |
| 864 | // so the template can override via data-we-pos-field. | |
| 865 | var posField = st.el.getAttribute('data-we-pos-field') || (st.field + '_pos'); | |
| 866 | st.el.classList.add('we-field-saving'); | |
| 867 | saveField(posField, st.pos, st.el).then(function(data){ | |
| 868 | st.el.classList.remove('we-field-saving'); | |
| 869 | if (data && data.success) { | |
| 870 | flash(st.el, 'we-field-saved'); | |
| 871 | } else { | |
| 872 | appToast({ message: 'Reposition failed: ' + (data && data.error || 'unknown'), type: 'error' }); | |
| 873 | } | |
| 874 | }).catch(function(err){ | |
| 875 | st.el.classList.remove('we-field-saving'); | |
| 876 | appToast({ message: 'Reposition failed: ' + err.message, type: 'error' }); | |
| 877 | }); | |
| 878 | return; | |
| 879 | } | |
| 880 | ||
| 881 | // No drag -> treat as a click to replace the image. | |
| 882 | openImageReplace(st.el, st.field); | |
| 883 | }); | |
| 884 | ||
| 885 | function openImageReplace(el, field) { | |
| 886 | var input = document.createElement('input'); | |
| 887 | input.type = 'file'; | |
| 888 | input.accept = 'image/*'; | |
| 889 | input.onchange = function() { | |
| 890 | if (!input.files || !input.files[0]) return; | |
| 891 | var file = input.files[0]; | |
| 892 | ||
| 893 | // Catch oversized uploads before the round-trip. Apache/Plesk | |
| 894 | // typically rejects anything over 8MB with a non-JSON 413 page | |
| 895 | // that would otherwise produce "Unexpected end of JSON input". | |
| 896 | var MAX = 10 * 1024 * 1024; | |
| 897 | if (file.size > MAX) { | |
| 898 | appToast({ message: 'Image is too large (' + (file.size / 1048576).toFixed(1) + ' MB). Max 10 MB. Resize or compress and try again.', type: 'warning' }); | |
| 899 | return; | |
| 900 | } | |
| 901 | ||
| 902 | el.classList.add('we-field-saving'); | |
| 903 | var fd = new FormData(); | |
| 904 | fd.append('image', file); | |
| 905 | fetch('/upload_image.cgi', { method: 'POST', body: fd, credentials: 'same-origin' }) | |
| 906 | .then(function(r){ | |
| 907 | return r.text().then(function(body){ | |
| 908 | var data; | |
| 909 | try { data = JSON.parse(body); } | |
| 910 | catch (e) { | |
| 911 | var snippet = body ? body.replace(/<[^>]*>/g,' ').replace(/\s+/g,' ').trim().slice(0, 200) : '(empty body)'; | |
| 912 | throw new Error('server returned ' + r.status + ' ' + r.statusText + ' (' + snippet + ')'); | |
| 913 | } | |
| 914 | return data; | |
| 915 | }); | |
| 916 | }) | |
| 917 | .then(function(data){ | |
| 918 | if (!data || !data.success || !data.url) { | |
| 919 | throw new Error((data && data.error) || 'upload failed'); | |
| 920 | } | |
| 921 | return saveField(field, data.url, el).then(function(saved){ | |
| 922 | if (!saved || !saved.success) throw new Error(saved && saved.error || 'save failed'); | |
| 923 | if (el.tagName === 'IMG') { | |
| 924 | el.src = data.url; | |
| 925 | } else { | |
| 926 | el.style.backgroundImage = "url('" + data.url + "')"; | |
| 927 | } | |
| 928 | el.classList.remove('we-field-saving'); | |
| 929 | flash(el, 'we-field-saved'); | |
| 930 | }); | |
| 931 | }) | |
| 932 | .catch(function(err){ | |
| 933 | el.classList.remove('we-field-saving'); | |
| 934 | appToast({ message: 'Image change failed: ' + err.message, type: 'error' }); | |
| 935 | }); | |
| 936 | }; | |
| 937 | input.click(); | |
| 938 | } | |
| 939 | })(); | |
| 940 | </script>~; | |
| 941 | } | |
| 942 | } | |
| 943 | ||
| 944 | # Demo-mode storefronts get an explicit noindex/nofollow so search | |
| 945 | # engines don't index the sample-data placeholder content (which would | |
| 946 | # pollute SERPs with a fake-looking page). Real storefronts use their | |
| 947 | # own seo_robots_index column on storefronts. | |
| 948 | my $seo_storefront = $tvars->{_storefront_row}; | |
| 949 | if ($tvars->{is_demo}) { | |
| 950 | $seo_storefront = { seo_robots_index => 0 }; | |
| 951 | } | |
| 952 | ||
| 953 | $wrap->render({ | |
| 954 | userinfo => undef, | |
| 955 | title => $tvars->{store_name} . ' — Premium 3D Designs', | |
| 956 | body => $body, | |
| 957 | storefront => $seo_storefront, # per-store SEO override | |
| 958 | # Note: visitor tracking + chat widget (/assets/javascript/track.js) | |
| 959 | # is loaded by repricer_marketing.html for every page that uses the | |
| 960 | # marketing wrapper, so we don't need to add it here explicitly. | |
| 961 | }); | |
| 962 | ||
| 963 | exit; | |
| 964 | ||
| 965 | #====================================================================== | |
| 966 | # Helpers | |
| 967 | #====================================================================== | |
| 968 | ||
| 969 | sub _resolve_storefront { | |
| 970 | my ($form, $userinfo) = @_; | |
| 971 | my $tvars = _demo_tvars(); # start with demo defaults | |
| 972 | ||
| 973 | my $dbh = eval { $db->db_connect() }; | |
| 974 | return $tvars unless $dbh; | |
| 975 | ||
| 976 | # Try the requested storefront in priority order: id > subdomain > host. | |
| 977 | # We now load 'live' AND 'closed' rows so a closed storefront can | |
| 978 | # render its maintenance page; the closed check happens just below. | |
| 979 | my $store; | |
| 980 | if (my $id = $form->{id}) { | |
| 981 | $id =~ s/[^0-9]//g; | |
| 982 | if ($id) { | |
| 983 | my $sql = qq~SELECT * FROM `${DB}`.storefronts WHERE id = '$id' AND status IN ('live','closed') LIMIT 1~; | |
| 984 | $store = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 985 | } | |
| 986 | } | |
| 987 | elsif (my $sub = $form->{subdomain}) { | |
| 988 | # CGI.pm Vars() joins duplicate query keys with NUL ("\0"). When | |
| 989 | # nginx rewrites a subdomain request to /store.cgi?subdomain=NAME | |
| 990 | # AND the original URL ALREADY had ?subdomain=NAME (e.g. from a | |
| 991 | # paginated link), the two values get concatenated. Take the | |
| 992 | # first value only so 'x\0x' resolves to 'x', not 'xx'. | |
| 993 | $sub = (split /\0/, $sub)[0] // ''; | |
| 994 | $sub =~ s/[^a-z0-9\-]//gi; | |
| 995 | my $sql = qq~SELECT * FROM `${DB}`.storefronts WHERE subdomain = '$sub' AND status IN ('live','closed') LIMIT 1~; | |
| 996 | $store = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 997 | } | |
| 998 | elsif (my $host = $form->{host} || $ENV{HTTP_HOST}) { | |
| 999 | $host = (split /\0/, $host)[0] // ''; # same NUL-dedup as subdomain | |
| 1000 | $host =~ s/[^a-z0-9\.\-]//gi; | |
| 1001 | my $sql = qq~SELECT * FROM `${DB}`.storefronts WHERE custom_domain = '$host' AND status IN ('live','closed') LIMIT 1~; | |
| 1002 | $store = $db->db_readwrite($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1003 | } | |
| 1004 | ||
| 1005 | # Closed storefront -> short-circuit with a friendly maintenance | |
| 1006 | # page. The owner can still see the real storefront for editing | |
| 1007 | # purposes (they hit /storefront.cgi to open it again from there). | |
| 1008 | # Anyone else gets the "be right back" notice. | |
| 1009 | if ($store && $store->{id} && ($store->{status} || '') eq 'closed') { | |
| 1010 | my $is_owner_now = ($userinfo && $userinfo->{user_id} | |
| 1011 | && $store->{user_id} eq $userinfo->{user_id}) ? 1 : 0; | |
| 1012 | unless ($is_owner_now && $form->{editor_live}) { | |
| 1013 | $db->db_disconnect($dbh); | |
| 1014 | _emit_maintenance_page($store, $is_owner_now); | |
| 1015 | exit; | |
| 1016 | } | |
| 1017 | } | |
| 1018 | ||
| 1019 | # No real storefront found? Stay in demo mode but tell the caller. | |
| 1020 | if (!$store || !$store->{id}) { | |
| 1021 | $tvars->{is_demo} = 1; | |
| 1022 | if ($userinfo) { | |
| 1023 | # Logged-in creator with no storefront yet — point them at the editor. | |
| 1024 | $tvars->{preview_banner} = qq~You're seeing demo content. <a href="/storefront.cgi">Set up your storefront →</a>~; | |
| 1025 | } | |
| 1026 | $db->db_disconnect($dbh); | |
| 1027 | return $tvars; | |
| 1028 | } | |
| 1029 | ||
| 1030 | # Real storefront — populate from DB. | |
| 1031 | $tvars->{is_demo} = 0; | |
| 1032 | # Stash the raw storefront row so the wrapper can layer per-store | |
| 1033 | # SEO meta tags (seo_title, seo_description, seo_og_image, ...). | |
| 1034 | # Underscore-prefixed key signals "internal, not for template use". | |
| 1035 | $tvars->{_storefront_row} = $store; | |
| 1036 | $tvars->{store_id} = $store->{id}; | |
| 1037 | $tvars->{store_name} = $store->{name} || $store->{subdomain} || 'RePricer Store'; | |
| 1038 | $tvars->{store_subdomain}= $store->{subdomain}; | |
| 1039 | $tvars->{store_url} = $store->{custom_domain} || ($store->{subdomain} . '.repricer.com'); | |
| 1040 | $tvars->{store_initials} = _store_initials($tvars->{store_name}); | |
| 1041 | ||
| 1042 | my $is_owner = ($userinfo && $userinfo->{user_id} && $userinfo->{user_id} eq $store->{user_id}) ? 1 : 0; | |
| 1043 | ||
| 1044 | # A/B + MVT variant assignment runs HERE -- before theme + layout | |
| 1045 | # resolution -- so design-test variants (surface=layout|theme| | |
| 1046 | # layout_and_theme) can swap the page structure / color palette | |
| 1047 | # the visitor sees. Element-level variants (hero_title, cta_label, | |
| 1048 | # product_tile, ...) are applied later, after the tvars they patch | |
| 1049 | # are populated. Owner in editor_live mode is bypassed so editing | |
| 1050 | # always happens against the saved storefront, never a variant. | |
| 1051 | my @experiment_assignments; | |
| 1052 | my $design_overrides = { layout_id => 0, theme_id => 0, custom_theme_id => 0 }; | |
| 1053 | if (!$form->{editor_live} || !$is_owner) { | |
| 1054 | my $vh = $ex_helper->visitor_hash($visitor_token); | |
| 1055 | my @experiments = $ex_helper->active_for_storefront($db, $dbh, $DB, $store->{id}); | |
| 1056 | foreach my $exp (@experiments) { | |
| 1057 | my $v = $ex_helper->assign_variant($vh, $exp); | |
| 1058 | next unless $v; | |
| 1059 | push @experiment_assignments, { | |
| 1060 | experiment_id => $exp->{id}, | |
| 1061 | surface => $exp->{surface}, | |
| 1062 | page_id => $exp->{page_id} || 0, | |
| 1063 | variant => $v, | |
| 1064 | }; | |
| 1065 | # One exposure row per visitor+experiment per render. The | |
| 1066 | # stats engine dedups via COUNT DISTINCT(visitor_hash). | |
| 1067 | $ex_helper->log_event($db, $dbh, $DB, | |
| 1068 | $exp->{id}, $v->{id}, $vh, 'exposure', undef); | |
| 1069 | } | |
| 1070 | $design_overrides = $ex_helper->design_overrides_for_assignments(\@experiment_assignments); | |
| 1071 | } | |
| 1072 | # Stashed on $tvars so the late apply_to_tvars + apply_to_products | |
| 1073 | # calls below can reuse the same assignments without re-querying. | |
| 1074 | $tvars->{_experiment_assignments} = \@experiment_assignments; | |
| 1075 | $tvars->{_design_overrides} = $design_overrides; | |
| 1076 | ||
| 1077 | # Theme — resolve_for_storefront picks the storefront's | |
| 1078 | # custom_theme_id if set, else falls back to the built-in theme_id. | |
| 1079 | # The CSS-vars block is injected into repricer_store.html's <style> tag. | |
| 1080 | my $theme; | |
| 1081 | ||
| 1082 | # Owner-only live previews: | |
| 1083 | # ?preview_theme=N -> built-in theme N | |
| 1084 | # ?preview_custom_theme=N -> the user's saved custom theme N | |
| 1085 | # ?preview_colors=1 + pc_* -> ad-hoc colors from the theme builder | |
| 1086 | # (no DB row yet -- builder iframe) | |
| 1087 | # None of these write to the DB. Buyers can't use them -- owner only. | |
| 1088 | my $preview_tid = $form->{preview_theme}; | |
| 1089 | my $preview_ctid = $form->{preview_custom_theme}; | |
| 1090 | if ($form->{preview_colors} && $is_owner) { | |
| 1091 | # Build a synthetic theme hash straight from URL params. Each | |
| 1092 | # pc_* field is validated as #RRGGBB (input type=color always | |
| 1093 | # emits that form) before being trusted into a <style> block. | |
| 1094 | my %pc; | |
| 1095 | foreach my $k (qw(bg bg_2 surface surface_2 border border_2 | |
| 1096 | text text_2 text_3 accent accent_2 accent_deep)) { | |
| 1097 | my $v = $form->{"pc_$k"}; | |
| 1098 | $v = '' unless defined $v; | |
| 1099 | $pc{$k} = ($v =~ /^#[0-9a-fA-F]{6}$/) ? $v : ''; | |
| 1100 | } | |
| 1101 | # accent_glow is optional rgba; if absent we derive it from accent. | |
| 1102 | my $glow = $form->{pc_accent_glow} || ''; | |
| 1103 | if ($glow !~ /^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*[\d.]+\s*)?\)$/) { | |
| 1104 | my $a = $pc{accent} || '#7c3aed'; | |
| 1105 | my ($r, $g, $b) = (hex(substr($a,1,2)), hex(substr($a,3,2)), hex(substr($a,5,2))); | |
| 1106 | $glow = "rgba($r,$g,$b,0.35)"; | |
| 1107 | } | |
| 1108 | # Fill any missing fields from the seller's currently-resolved | |
| 1109 | # theme so a half-built preview never renders as black-on-black. | |
| 1110 | my $fallback = $themes->resolve_for_storefront($store, $db, $dbh, $DB); | |
| 1111 | foreach my $k (keys %pc) { $pc{$k} ||= $fallback->{$k}; } | |
| 1112 | $theme = { | |
| 1113 | %$fallback, | |
| 1114 | %pc, | |
| 1115 | accent_glow => $glow, | |
| 1116 | name => 'Custom (preview)', | |
| 1117 | slug => 'custom-preview', | |
| 1118 | is_custom => 1, | |
| 1119 | }; | |
| 1120 | $tvars->{is_theme_preview} = 1; | |
| 1121 | $tvars->{preview_theme_name} = $theme->{name}; | |
| 1122 | # Suppress the banner -- the iframe is tiny and the banner would | |
| 1123 | # eat most of its viewport. The builder UI already labels it. | |
| 1124 | } | |
| 1125 | elsif (defined $preview_ctid && $preview_ctid ne '' && $is_owner) { | |
| 1126 | $preview_ctid =~ s/[^0-9]//g; | |
| 1127 | if ($preview_ctid) { | |
| 1128 | my $previewed = $themes->custom_by_id($db, $dbh, $DB, $preview_ctid); | |
| 1129 | if ($previewed && $previewed->{id}) { | |
| 1130 | $theme = $previewed; | |
| 1131 | $tvars->{is_theme_preview} = 1; | |
| 1132 | $tvars->{preview_theme_name} = $previewed->{name}; | |
| 1133 | $tvars->{preview_banner} = qq~You're previewing your custom theme <strong>$previewed->{name}</strong>. <a href="/storefront.cgi#design">Pick a theme →</a>~; | |
| 1134 | } | |
| 1135 | } | |
| 1136 | } elsif (defined $preview_tid && $preview_tid ne '' && $is_owner) { | |
| 1137 | $preview_tid =~ s/[^0-9]//g; | |
| 1138 | if ($preview_tid) { | |
| 1139 | my $previewed = $themes->by_id($preview_tid); | |
| 1140 | $theme = $previewed; | |
| 1141 | $tvars->{is_theme_preview} = 1; | |
| 1142 | $tvars->{preview_theme_name} = $previewed->{name}; | |
| 1143 | $tvars->{preview_banner} = qq~You're previewing the <strong>$previewed->{name}</strong> theme. <a href="/storefront.cgi#design">Pick a theme →</a>~; | |
| 1144 | } | |
| 1145 | } | |
| 1146 | $theme ||= $themes->resolve_for_storefront($store, $db, $dbh, $DB); | |
| 1147 | ||
| 1148 | # A/B + MVT design override: if a running experiment bucketed this | |
| 1149 | # visitor into a theme / layout_and_theme variant, swap the resolved | |
| 1150 | # theme. Owner preview shortcuts above already short-circuited so | |
| 1151 | # they take priority; editor_live mode is also exempt because the | |
| 1152 | # variant assignment block above didn't run in that case. | |
| 1153 | if (!$tvars->{is_theme_preview}) { | |
| 1154 | if ($design_overrides->{custom_theme_id}) { | |
| 1155 | my $t = $themes->custom_by_id($db, $dbh, $DB, $design_overrides->{custom_theme_id}); | |
| 1156 | $theme = $t if $t && $t->{id}; | |
| 1157 | } | |
| 1158 | elsif ($design_overrides->{theme_id}) { | |
| 1159 | $theme = $themes->by_id($design_overrides->{theme_id}); | |
| 1160 | } | |
| 1161 | } | |
| 1162 | ||
| 1163 | $tvars->{theme_css_vars} = $themes->css_vars($theme); | |
| 1164 | $tvars->{theme_name} = $theme->{name}; | |
| 1165 | ||
| 1166 | # Layout — like theme, but governs the page STRUCTURE not colors. | |
| 1167 | # Owner can preview a layout via ?preview_layout=N without saving. | |
| 1168 | my $layout_id_to_use = $store->{layout_id} || 1; | |
| 1169 | my $preview_lid = $form->{preview_layout}; | |
| 1170 | if (defined $preview_lid && $preview_lid ne '' && $is_owner) { | |
| 1171 | $preview_lid =~ s/[^0-9]//g; | |
| 1172 | if ($preview_lid) { | |
| 1173 | $layout_id_to_use = $preview_lid; | |
| 1174 | my $previewed_layout = $layouts->by_id($preview_lid); | |
| 1175 | $tvars->{is_layout_preview} = 1; | |
| 1176 | $tvars->{preview_banner} = qq~You're previewing the <strong>$previewed_layout->{name}</strong> layout. <a href="/storefront.cgi#design">Pick a layout →</a>~; | |
| 1177 | } | |
| 1178 | } | |
| 1179 | # A/B + MVT design override: a layout / layout_and_theme variant | |
| 1180 | # swaps the page structure. Owner preview above wins if both apply. | |
| 1181 | elsif ($design_overrides->{layout_id}) { | |
| 1182 | $layout_id_to_use = $design_overrides->{layout_id}; | |
| 1183 | } | |
| 1184 | my $layout = $layouts->by_id($layout_id_to_use); | |
| 1185 | $tvars->{layout_template} = $layouts->template_for($layout->{id}); | |
| 1186 | $tvars->{layout_name} = $layout->{name}; | |
| 1187 | ||
| 1188 | # Editable storefront fields. Defaults are used until the creator | |
| 1189 | # types/clicks something in the page editor. | |
| 1190 | $tvars->{store_tagline} = $store->{tagline} | |
| 1191 | || 'Pre-supported, print-tested designs from ' . $tvars->{store_name} . '.'; | |
| 1192 | $tvars->{store_about} = $store->{about_text} | |
| 1193 | || 'Welcome to ' . $tvars->{store_name} | |
| 1194 | . q~. This storefront is just getting started -- once designs are listed and the about text is filled in, this is where buyers will see what makes the studio worth following.~; | |
| 1195 | # Neutral inline-SVG placeholder used wherever an image would | |
| 1196 | # otherwise default to a stock reference asset. Renders as a soft | |
| 1197 | # gray gradient -- honest "empty" state instead of borrowed art. | |
| 1198 | # Inline data URL so no extra asset file is required. | |
| 1199 | my $STORE_PLACEHOLDER_IMG = | |
| 1200 | "data:image/svg+xml;utf8," | |
| 1201 | . "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 600'>" | |
| 1202 | . "<defs><linearGradient id='g' x1='0' x2='1' y1='0' y2='1'>" | |
| 1203 | . "<stop offset='0' stop-color='%23374151'/>" | |
| 1204 | . "<stop offset='0.55' stop-color='%231f2937'/>" | |
| 1205 | . "<stop offset='1' stop-color='%23111827'/>" | |
| 1206 | . "</linearGradient></defs>" | |
| 1207 | . "<rect width='800' height='600' fill='url(%23g)'/>" | |
| 1208 | . "<g opacity='0.20' fill='none' stroke='%23ffffff' stroke-width='2'>" | |
| 1209 | . "<rect x='300' y='200' width='200' height='200' rx='12'/>" | |
| 1210 | . "<circle cx='370' cy='270' r='22'/>" | |
| 1211 | . "<polyline points='310,390 380,330 440,365 500,310'/>" | |
| 1212 | . "</g></svg>"; | |
| 1213 | ||
| 1214 | # Hero image slots -- layouts that have a hero use $hero_image_1/2/3. | |
| 1215 | # Default to the neutral placeholder when no per-storefront override | |
| 1216 | # is set; once the seller has uploaded products we'll override these | |
| 1217 | # again below with the first three product images. | |
| 1218 | $tvars->{hero_image_1} = $store->{hero_image_1} || $STORE_PLACEHOLDER_IMG; | |
| 1219 | $tvars->{hero_image_2} = $store->{hero_image_2} || $STORE_PLACEHOLDER_IMG; | |
| 1220 | $tvars->{hero_image_3} = $store->{hero_image_3} || $STORE_PLACEHOLDER_IMG; | |
| 1221 | # Per-image focal point. Stored as CSS background-position ("50% 30%"). | |
| 1222 | # Default to center -- the same as the hardcoded value the layouts | |
| 1223 | # used before the focal-point picker shipped. | |
| 1224 | $tvars->{hero_image_1_pos} = $store->{hero_image_1_pos} || '50% 50%'; | |
| 1225 | $tvars->{hero_image_2_pos} = $store->{hero_image_2_pos} || '50% 50%'; | |
| 1226 | $tvars->{hero_image_3_pos} = $store->{hero_image_3_pos} || '50% 50%'; | |
| 1227 | ||
| 1228 | # Chrome labels -- small CTA / badge / heading strings that show up | |
| 1229 | # on most layouts. NULL in the DB falls back to the default below, | |
| 1230 | # and any layout that references the corresponding tvar lights up | |
| 1231 | # as click-to-edit in the page editor automatically. | |
| 1232 | $tvars->{cta_primary_label} = $store->{cta_primary_label} || 'Get it now'; | |
| 1233 | $tvars->{cta_secondary_label} = $store->{cta_secondary_label} || 'See more designs'; | |
| 1234 | $tvars->{featured_badge_label} = $store->{featured_badge_label} || 'Featured release'; | |
| 1235 | $tvars->{section_heading} = $store->{section_heading} || 'More from the studio'; | |
| 1236 | $tvars->{footer_byline} = $store->{footer_byline} || 'powered by repricer.com'; | |
| 1237 | ||
| 1238 | # Catalog-layout hero chrome. Separate fields from the spotlight CTAs | |
| 1239 | # above because the buttons drive a different action (browse catalog | |
| 1240 | # vs. add featured to cart) -- sharing one field would mean changing | |
| 1241 | # one layout's CTA copy clobbers the other's. | |
| 1242 | $tvars->{hero_title} = $store->{hero_title} || 'Premium 3D printable designs'; | |
| 1243 | $tvars->{hero_pill_label} = $store->{hero_pill_label} || 'battle-ready'; | |
| 1244 | $tvars->{hero_cta_primary_label} = $store->{hero_cta_primary_label} || 'Browse the catalog'; | |
| 1245 | $tvars->{hero_cta_secondary_label} = $store->{hero_cta_secondary_label} || 'Meet the maker'; | |
| 1246 | $tvars->{section_eyebrow} = $store->{section_eyebrow} || 'The catalog'; | |
| 1247 | $tvars->{header_signin_label} = $store->{header_signin_label} || 'Sign in'; | |
| 1248 | $tvars->{header_cart_label} = $store->{header_cart_label} || 'Cart'; | |
| 1249 | ||
| 1250 | # About-section chrome (used by every layout with an "about" block). | |
| 1251 | # NULL columns fall back to the strings below until the seller types | |
| 1252 | # their own. about_image_pos defaults to "center center" so cover | |
| 1253 | # crops symmetrically when no focal point is set yet. | |
| 1254 | $tvars->{about_image} = $store->{about_image} || $STORE_PLACEHOLDER_IMG; | |
| 1255 | $tvars->{about_image_pos} = $store->{about_image_pos} || 'center center'; | |
| 1256 | $tvars->{about_eyebrow} = $store->{about_eyebrow} || 'About this studio'; | |
| 1257 | $tvars->{about_heading} = $store->{about_heading} || 'Made for printers, by a printer.'; | |
| 1258 | $tvars->{footer_trust} = $store->{footer_trust} || 'Secure checkout - Stripe - Instant download - 30-day download window'; | |
| 1259 | ||
| 1260 | # Log a buyer-side page view. Skipped when the owner is in | |
| 1261 | # editor_live preview (so owner previews never inflate buyer | |
| 1262 | # traffic) and skipped on obvious bot UAs. Best-effort: any | |
| 1263 | # missing piece (no visitor cookie yet, missing table) is OK | |
| 1264 | # because the table is information-schema guarded. | |
| 1265 | if ((!$form->{editor_live} || !$is_owner) && !$is_owner) { | |
| 1266 | my $ua = $ENV{HTTP_USER_AGENT} || ''; | |
| 1267 | my $is_bot = ($ua =~ /\b(bot|crawler|spider|slurp|preview|prerender|headless)\b/i) ? 1 : 0; | |
| 1268 | if (!$is_bot) { | |
| 1269 | my $have_view_tbl = $db->db_readwrite($dbh, qq~ | |
| 1270 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 1271 | WHERE table_schema='$DB' AND table_name='view_events' | |
| 1272 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1273 | if ($have_view_tbl && $have_view_tbl->{n}) { | |
| 1274 | require Digest::SHA; | |
| 1275 | my $vh = $visitor_token ? Digest::SHA::sha1_hex($visitor_token) : ''; | |
| 1276 | my $ua_h = $ua ? Digest::SHA::sha1_hex($ua) : ''; | |
| 1277 | my $ref = substr($ENV{HTTP_REFERER} || '', 0, 500); | |
| 1278 | # Determine which page is being viewed. We log the slug | |
| 1279 | # if one is in scope; model_id stays NULL unless a | |
| 1280 | # product page is the target (V1 has none yet). | |
| 1281 | my $page_slug = ''; | |
| 1282 | if (defined $form->{page} && $form->{page} ne '') { | |
| 1283 | $page_slug = lc($form->{page}); | |
| 1284 | # Underscores are valid slug chars (e.g. /product_list). | |
| 1285 | # page.cgi + nav builder already accept them; this | |
| 1286 | # regex used to strip them and made underscore slugs | |
| 1287 | # unfindable -- a "Published" page would render as | |
| 1288 | # "Page not found" because the lookup query missed. | |
| 1289 | $page_slug =~ s/[^a-z0-9_-]//g; | |
| 1290 | $page_slug = substr($page_slug, 0, 120); | |
| 1291 | } | |
| 1292 | # SQL-escape strings. | |
| 1293 | for ($vh, $ua_h, $ref, $page_slug) { s/\\/\\\\/g; s/'/\\'/g; } | |
| 1294 | my $sid = $store->{id}; | |
| 1295 | $db->db_readwrite($dbh, qq~ | |
| 1296 | INSERT INTO `${DB}`.view_events | |
| 1297 | SET storefront_id='$sid', | |
| 1298 | page_slug='$page_slug', | |
| 1299 | visitor_hash='$vh', | |
| 1300 | referrer='$ref', | |
| 1301 | user_agent_hash='$ua_h', | |
| 1302 | occurred_at=NOW() | |
| 1303 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1304 | } | |
| 1305 | } | |
| 1306 | } | |
| 1307 | ||
| 1308 | # Apply running A/B + MVT experiments to the visitor's tvars. The | |
| 1309 | # variant assignments themselves were computed earlier (right after | |
| 1310 | # $is_owner was set) so design-test variants could influence theme + | |
| 1311 | # layout resolution. Here we apply the element-level surfaces | |
| 1312 | # (hero_title, store_tagline, cta_label, ...) now that their tvar | |
| 1313 | # targets have been populated. design / layout / theme surfaces are | |
| 1314 | # no-ops here -- they patched layout_template / theme_css_vars | |
| 1315 | # directly during resolution. | |
| 1316 | if (!$form->{editor_live} || !$is_owner) { | |
| 1317 | $ex_helper->apply_to_tvars($tvars->{_experiment_assignments} || [], $tvars); | |
| 1318 | } | |
| 1319 | ||
| 1320 | # When in editor_live mode AND the user owns the storefront, wrap | |
| 1321 | # the text-field tvars with edit markers. Every layout already uses | |
| 1322 | # these tvars, so this propagates editability automatically without | |
| 1323 | # touching all 36 layout files. | |
| 1324 | if ($form->{editor_live} && $is_owner) { | |
| 1325 | my $wrap_txt = sub { | |
| 1326 | my ($field, $value) = @_; | |
| 1327 | my $esc = $value; | |
| 1328 | $esc =~ s/</</g; # the inner content is text, not HTML | |
| 1329 | $esc =~ s/>/>/g; | |
| 1330 | return qq~<span data-we-editable="text" data-we-field="$field">$esc</span>~; | |
| 1331 | }; | |
| 1332 | $tvars->{store_name} = $wrap_txt->('name', $tvars->{store_name}); | |
| 1333 | $tvars->{store_tagline} = $wrap_txt->('tagline', $tvars->{store_tagline}); | |
| 1334 | $tvars->{store_about} = $wrap_txt->('about_text', $tvars->{store_about}); | |
| 1335 | $tvars->{cta_primary_label} = $wrap_txt->('cta_primary_label', $tvars->{cta_primary_label}); | |
| 1336 | $tvars->{cta_secondary_label} = $wrap_txt->('cta_secondary_label', $tvars->{cta_secondary_label}); | |
| 1337 | $tvars->{featured_badge_label} = $wrap_txt->('featured_badge_label', $tvars->{featured_badge_label}); | |
| 1338 | $tvars->{section_heading} = $wrap_txt->('section_heading', $tvars->{section_heading}); | |
| 1339 | $tvars->{footer_byline} = $wrap_txt->('footer_byline', $tvars->{footer_byline}); | |
| 1340 | ||
| 1341 | $tvars->{hero_title} = $wrap_txt->('hero_title', $tvars->{hero_title}); | |
| 1342 | $tvars->{hero_pill_label} = $wrap_txt->('hero_pill_label', $tvars->{hero_pill_label}); | |
| 1343 | $tvars->{hero_cta_primary_label} = $wrap_txt->('hero_cta_primary_label', $tvars->{hero_cta_primary_label}); | |
| 1344 | $tvars->{hero_cta_secondary_label} = $wrap_txt->('hero_cta_secondary_label', $tvars->{hero_cta_secondary_label}); | |
| 1345 | $tvars->{section_eyebrow} = $wrap_txt->('section_eyebrow', $tvars->{section_eyebrow}); | |
| 1346 | $tvars->{header_signin_label} = $wrap_txt->('header_signin_label', $tvars->{header_signin_label}); | |
| 1347 | $tvars->{header_cart_label} = $wrap_txt->('header_cart_label', $tvars->{header_cart_label}); | |
| 1348 | $tvars->{about_eyebrow} = $wrap_txt->('about_eyebrow', $tvars->{about_eyebrow}); | |
| 1349 | $tvars->{about_heading} = $wrap_txt->('about_heading', $tvars->{about_heading}); | |
| 1350 | $tvars->{footer_trust} = $wrap_txt->('footer_trust', $tvars->{footer_trust}); | |
| 1351 | # about_image is not wrapped here -- it's an image field, edited | |
| 1352 | # via the data-we-editable="image" attribute on the about div in | |
| 1353 | # the layout template (same drag-to-reposition mechanism as the | |
| 1354 | # hero floating cards). | |
| 1355 | } | |
| 1356 | ||
| 1357 | # Optional ?page=<slug> picks a specific storefront_pages row to render. | |
| 1358 | # Drafts are visible only to the owner (so creators can preview pre-publish). | |
| 1359 | # An empty slug means "home" — fall through to the default hero + catalog | |
| 1360 | # layout instead of rendering an empty page stub. | |
| 1361 | if (defined $form->{page} && $form->{page} ne '') { | |
| 1362 | my $slug = lc($form->{page} || ''); | |
| 1363 | # Allow underscores -- they're valid in storefront_pages.slug | |
| 1364 | # (e.g. "product_list"). The previous regex stripped them so | |
| 1365 | # underscore-named pages 404'd even though they were Published. | |
| 1366 | $slug =~ s/[^a-z0-9_-]//g; | |
| 1367 | my $safe_slug = $slug; | |
| 1368 | my $status_filter = $is_owner ? '' : qq~AND status='published'~; | |
| 1369 | ||
| 1370 | my $page = $db->db_readwrite($dbh, | |
| 1371 | qq~SELECT * FROM `${DB}`.storefront_pages | |
| 1372 | WHERE storefront_id='$store->{id}' AND slug='$safe_slug' $status_filter | |
| 1373 | LIMIT 1~, | |
| 1374 | $ENV{SCRIPT_NAME}, __LINE__); | |
| 1375 | ||
| 1376 | # A/B page test: if a running surface=storefront_page experiment | |
| 1377 | # targets this base page id and the visitor was bucketed to a | |
| 1378 | # variant that points at a different storefront_pages row, swap | |
| 1379 | # to that row before rendering. Owner editor_live mode and the | |
| 1380 | # variant page editor's own preview both skip this. | |
| 1381 | if ($page && $page->{id} | |
| 1382 | && (!$form->{editor_live} || !$is_owner) | |
| 1383 | && $tvars->{_experiment_assignments}) { | |
| 1384 | my $vpid = $ex_helper->variant_page_id_for_assignments( | |
| 1385 | $tvars->{_experiment_assignments}, $page->{id} | |
| 1386 | ); | |
| 1387 | if ($vpid && $vpid != $page->{id}) { | |
| 1388 | my $vpid_safe = $vpid + 0; | |
| 1389 | my $variant = $db->db_readwrite($dbh, qq~ | |
| 1390 | SELECT * FROM `${DB}`.storefront_pages | |
| 1391 | WHERE id='$vpid_safe' AND storefront_id='$store->{id}' | |
| 1392 | LIMIT 1 | |
| 1393 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1394 | # We deliberately accept draft variant pages here -- the | |
| 1395 | # whole point of the A/B test is that variant B doesn't | |
| 1396 | # need to be "published" in the seller's nav to be served | |
| 1397 | # to a bucketed visitor. The experiment status (running) | |
| 1398 | # is the gate, not page.status. | |
| 1399 | $page = $variant if $variant && $variant->{id}; | |
| 1400 | } | |
| 1401 | } | |
| 1402 | ||
| 1403 | if ($page && $page->{id}) { | |
| 1404 | $tvars->{page_title} = $page->{title}; | |
| 1405 | $tvars->{page_slug} = $page->{slug}; | |
| 1406 | my $body_html = _extract_body_from_layout($page->{layout}); | |
| 1407 | ||
| 1408 | # MVT page_blocks: substitute {{slot:NAME}} placeholders in | |
| 1409 | # the body with the visitor's bucketed variant's value for | |
| 1410 | # that slot. Multiple page_blocks tests on the same page | |
| 1411 | # compose -- each one's overrides are merged into the slot | |
| 1412 | # map. Owner editor_live mode skips substitution so the | |
| 1413 | # seller sees raw markers while authoring. | |
| 1414 | if ((!$form->{editor_live} || !$is_owner) | |
| 1415 | && $tvars->{_experiment_assignments} | |
| 1416 | && $body_html =~ /\{\{slot:/) { | |
| 1417 | # Filter assignments to page_blocks scoped at THIS page. | |
| 1418 | my @page_assigns; | |
| 1419 | foreach my $a (@{$tvars->{_experiment_assignments}}) { | |
| 1420 | next unless ref($a) eq 'HASH'; | |
| 1421 | next unless ($a->{surface} || '') eq 'page_blocks'; | |
| 1422 | next unless ($a->{page_id} || 0) == $page->{id}; | |
| 1423 | push @page_assigns, $a; | |
| 1424 | } | |
| 1425 | if (@page_assigns) { | |
| 1426 | my $overrides = $ex_helper->block_overrides_for_assignments(\@page_assigns); | |
| 1427 | if ($overrides && %$overrides) { | |
| 1428 | $body_html =~ s{\{\{slot:([A-Za-z0-9_-]+)\}\}}{ | |
| 1429 | exists $overrides->{$1} ? $overrides->{$1} : "{{slot:$1}}" | |
| 1430 | }ge; | |
| 1431 | } | |
| 1432 | } | |
| 1433 | } | |
| 1434 | ||
| 1435 | # Wrap the body in a marker div so the editor's live-preview | |
| 1436 | # iframe can swap its innerHTML on each keystroke without | |
| 1437 | # reloading the page. | |
| 1438 | $tvars->{page_body} = '<div id="we-page-body" class="we-page-body">' | |
| 1439 | . $body_html | |
| 1440 | . '</div>'; | |
| 1441 | $tvars->{page_status} = $page->{status}; | |
| 1442 | $tvars->{page_type} = $page->{page_type} || ''; | |
| 1443 | $tvars->{is_specific_page} = 1; | |
| 1444 | $tvars->{is_draft_page} = ($page->{status} ne 'published') ? 1 : 0; | |
| 1445 | $tvars->{is_collection_page} = ($page->{page_type} && $page->{page_type} eq 'collection') ? 1 : 0; | |
| 1446 | } elsif ($form->{editor_live} && $is_owner) { | |
| 1447 | # Live preview for a not-yet-saved page (creator just typed | |
| 1448 | # a slug that doesn't exist yet). Render the layout with an | |
| 1449 | # empty body wrapper -- postMessage will fill it in. | |
| 1450 | $tvars->{page_title} = 'Preview'; | |
| 1451 | $tvars->{page_slug} = $safe_slug; | |
| 1452 | $tvars->{page_body} = '<div id="we-page-body" class="we-page-body"></div>'; | |
| 1453 | $tvars->{is_specific_page} = 1; | |
| 1454 | $tvars->{is_draft_page} = 1; | |
| 1455 | } elsif ($safe_slug ne '') { | |
| 1456 | # Requested a slug that doesn't exist (or is a draft to a non-owner) | |
| 1457 | $tvars->{is_specific_page} = 1; | |
| 1458 | $tvars->{page_not_found} = 1; | |
| 1459 | $tvars->{page_title} = 'Page not found'; | |
| 1460 | } | |
| 1461 | } | |
| 1462 | ||
| 1463 | # Owner badge if the creator is previewing their own store. Don't | |
| 1464 | # clobber a more specific preview banner already set above (theme | |
| 1465 | # preview, layout preview). Skip entirely when the store is being | |
| 1466 | # rendered inside the page editor iframe -- the editor shell | |
| 1467 | # surrounding the iframe already makes the editing context clear. | |
| 1468 | # Also skip when rendered inside /my_heatmap.cgi (heatmap_view=1): | |
| 1469 | # the whole point of that iframe is to show the seller exactly what | |
| 1470 | # buyers see, so the owner-only "Preview mode" chrome would be | |
| 1471 | # misleading -- and the dots would land on the wrong DOM offsets | |
| 1472 | # because of the extra banner shifting layout down. | |
| 1473 | if ($is_owner && !$tvars->{preview_banner} && !$form->{editor_live} && !$form->{heatmap_view} && !$form->{as_visitor}) { | |
| 1474 | my $what = $tvars->{is_draft_page} | |
| 1475 | ? 'Draft preview — only you can see this.' | |
| 1476 | : 'Preview mode — this is how buyers see your storefront.'; | |
| 1477 | # Include a "View as buyer" link in the banner so the owner can | |
| 1478 | # toggle into the clean visitor view with one click. We append | |
| 1479 | # as_visitor=1 to whatever URL they're on so deep pages keep | |
| 1480 | # the flag through navigation. The branch above guarantees | |
| 1481 | # we're not already in as_visitor mode, so a simple append is | |
| 1482 | # always safe. | |
| 1483 | my $here_path = $ENV{SCRIPT_NAME} || '/store.cgi'; | |
| 1484 | my $hereqs = $ENV{QUERY_STRING} || ''; | |
| 1485 | my $as_visitor_href = $here_path . ($hereqs ? "?$hereqs&as_visitor=1" : '?as_visitor=1'); | |
| 1486 | $tvars->{preview_banner} = qq~$what <a href="$as_visitor_href">View as buyer →</a> · <a href="/storefront.cgi">Edit →</a>~; | |
| 1487 | } | |
| 1488 | ||
| 1489 | # Pull listed products. Hero image priority: | |
| 1490 | # 1. models.hero_image_url -- creator's explicit override (set via the | |
| 1491 | # storefront editor when they pick a custom hero for the spotlight) | |
| 1492 | # 2. The first primary uploaded image from model_images (the | |
| 1493 | # is_primary=1 row, written by upload.cgi for the first image) | |
| 1494 | # 3. Any uploaded image at all, ordered by sort_order then id | |
| 1495 | # 4. Neutral inline-SVG placeholder (only when no image at all) | |
| 1496 | # We resolve #2 / #3 via a correlated subquery so the dataset stays | |
| 1497 | # one round-trip. | |
| 1498 | # | |
| 1499 | # Pagination: ?p=N picks the page; PRODUCTS_PER_PAGE controls the | |
| 1500 | # grid size. We compute total_count up front so the template can | |
| 1501 | # render real prev/next + page-number controls. | |
| 1502 | my $PRODUCTS_PER_PAGE = 24; | |
| 1503 | my $page_num = $form->{p} || 1; | |
| 1504 | $page_num =~ s/[^0-9]//g; | |
| 1505 | $page_num = 1 if !$page_num || $page_num < 1; | |
| 1506 | ||
| 1507 | # Storefront-scoped catalog search. ?q=foo filters listings by | |
| 1508 | # m.title and m.tagline. Cap length and SQL-escape the input so the | |
| 1509 | # interpolated LIKE stays safe alongside the rest of this codebase's | |
| 1510 | # string-interpolated queries. | |
| 1511 | my $q_raw = defined $form->{q} ? $form->{q} : ''; | |
| 1512 | $q_raw =~ s/^\s+|\s+$//g; | |
| 1513 | $q_raw = substr($q_raw, 0, 100); | |
| 1514 | my $search_sql_frag = ''; | |
| 1515 | if ($q_raw ne '') { | |
| 1516 | my $esc = $q_raw; | |
| 1517 | $esc =~ s/\\/\\\\/g; | |
| 1518 | $esc =~ s/'/''/g; | |
| 1519 | $esc =~ s/%/\\%/g; | |
| 1520 | $esc =~ s/_/\\_/g; | |
| 1521 | $search_sql_frag = " AND (m.title LIKE '%$esc%' OR m.tagline LIKE '%$esc%')"; | |
| 1522 | } | |
| 1523 | $tvars->{search_query} = $q_raw; | |
| 1524 | $tvars->{has_search} = $q_raw ne '' ? 1 : 0; | |
| 1525 | ||
| 1526 | my $count_row = $db->db_readwrite($dbh, qq~ | |
| 1527 | SELECT COUNT(*) AS n | |
| 1528 | FROM `${DB}`.storefront_listings sl | |
| 1529 | JOIN `${DB}`.models m ON m.id = sl.model_id | |
| 1530 | WHERE sl.storefront_id = '$store->{id}' | |
| 1531 | AND sl.visible = 1 | |
| 1532 | AND m.status = 'published' | |
| 1533 | $search_sql_frag | |
| 1534 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1535 | my $total_products = ($count_row && $count_row->{n}) ? $count_row->{n} : 0; | |
| 1536 | my $total_pages = $total_products | |
| 1537 | ? int(($total_products + $PRODUCTS_PER_PAGE - 1) / $PRODUCTS_PER_PAGE) | |
| 1538 | : 1; | |
| 1539 | $page_num = $total_pages if $page_num > $total_pages && $total_pages > 0; | |
| 1540 | my $offset = ($page_num - 1) * $PRODUCTS_PER_PAGE; | |
| 1541 | ||
| 1542 | # Optional seller-category filter -- ?seller_cat=<slug> activates | |
| 1543 | # a filter chip on the storefront. Resolves the slug to an id | |
| 1544 | # against seller_categories owned by THIS storefront's user so a | |
| 1545 | # cross-store slug collision can't surface unrelated listings. | |
| 1546 | my $cat_filter_sql = ''; | |
| 1547 | my $active_cat_slug = ''; | |
| 1548 | my $active_cat_name = ''; | |
| 1549 | if (defined $form->{seller_cat} && length $form->{seller_cat}) { | |
| 1550 | my $slug = lc $form->{seller_cat}; | |
| 1551 | $slug =~ s/[^a-z0-9\-]//g; | |
| 1552 | if (length $slug) { | |
| 1553 | my $cr = $db->db_readwrite($dbh, qq~ | |
| 1554 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 1555 | WHERE table_schema='$DB' AND table_name='seller_categories' | |
| 1556 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1557 | if ($cr && $cr->{n}) { | |
| 1558 | my $catrow = $db->db_readwrite($dbh, qq~ | |
| 1559 | SELECT id, name FROM `${DB}`.seller_categories | |
| 1560 | WHERE user_id='$store->{user_id}' AND slug='$slug' | |
| 1561 | AND is_public=1 LIMIT 1 | |
| 1562 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1563 | if ($catrow && $catrow->{id}) { | |
| 1564 | $cat_filter_sql = qq~ AND m.id IN ( | |
| 1565 | SELECT model_id FROM `${DB}`.model_seller_categories | |
| 1566 | WHERE category_id='$catrow->{id}' | |
| 1567 | )~; | |
| 1568 | $active_cat_slug = $slug; | |
| 1569 | $active_cat_name = $catrow->{name}; | |
| 1570 | } | |
| 1571 | } | |
| 1572 | } | |
| 1573 | } | |
| 1574 | ||
| 1575 | my $sql = qq~ | |
| 1576 | SELECT m.id, m.title, m.slug, m.base_price_cents, m.currency, m.rating_avg, m.rating_count, | |
| 1577 | m.hero_image_url, m.hero_image_pos, | |
| 1578 | m.product_kind, m.physical_price_cents, m.inventory_qty, m.production_time_days, | |
| 1579 | sl.override_price_cents, sl.sort_order, | |
| 1580 | ( | |
| 1581 | SELECT mi.id FROM `${DB}`.model_images mi | |
| 1582 | WHERE mi.model_id = m.id | |
| 1583 | ORDER BY mi.is_primary DESC, mi.sort_order ASC, mi.id ASC | |
| 1584 | LIMIT 1 | |
| 1585 | ) AS primary_image_id | |
| 1586 | FROM `${DB}`.storefront_listings sl | |
| 1587 | JOIN `${DB}`.models m ON m.id = sl.model_id | |
| 1588 | WHERE sl.storefront_id = '$store->{id}' | |
| 1589 | AND sl.visible = 1 | |
| 1590 | AND m.status = 'published' | |
| 1591 | $search_sql_frag | |
| 1592 | $cat_filter_sql | |
| 1593 | ORDER BY sl.sort_order ASC, m.created_at DESC | |
| 1594 | LIMIT $PRODUCTS_PER_PAGE OFFSET $offset | |
| 1595 | ~; | |
| 1596 | my @rows = $db->db_readwrite_multiple($dbh, $sql, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1597 | ||
| 1598 | my @products; | |
| 1599 | my $is_first = 1; | |
| 1600 | foreach my $r (@rows) { | |
| 1601 | # Price selection by product_kind: | |
| 1602 | # digital -> base_price_cents (file download) | |
| 1603 | # physical -> physical_price_cents (printed copy) | |
| 1604 | # both -> show the lower of the two as the headline so | |
| 1605 | # buyers see the most accessible entry point | |
| 1606 | my $kind = $r->{product_kind} || 'digital'; | |
| 1607 | my $base_cents = $r->{override_price_cents} || $r->{base_price_cents} || 0; | |
| 1608 | my $phys_cents = $r->{physical_price_cents} || 0; | |
| 1609 | my $cents; | |
| 1610 | if ($kind eq 'physical') { | |
| 1611 | $cents = $phys_cents; | |
| 1612 | } elsif ($kind eq 'both') { | |
| 1613 | $cents = ($phys_cents && $base_cents && $phys_cents < $base_cents) | |
| 1614 | ? $phys_cents : ($base_cents || $phys_cents); | |
| 1615 | } else { | |
| 1616 | $cents = $base_cents; | |
| 1617 | } | |
| 1618 | my $rating = $r->{rating_avg} || ''; | |
| 1619 | my $img_idx = ($r->{id} % 39) + 1; # spread across the 39 reference webp images | |
| 1620 | # Resolved hero URL with the priority described above. | |
| 1621 | my $resolved_hero = | |
| 1622 | ($r->{hero_image_url} && length $r->{hero_image_url}) ? $r->{hero_image_url} | |
| 1623 | : ($r->{primary_image_id} && $r->{primary_image_id} > 0) ? "/img.cgi?m=$r->{primary_image_id}" | |
| 1624 | : $STORE_PLACEHOLDER_IMG; | |
| 1625 | # Product-kind badge -- pre-composed HTML so each layout | |
| 1626 | # template can drop it into a card without an extra [if:]. | |
| 1627 | # Color hint matches the kind: digital = brand-accent, physical | |
| 1628 | # = warm copper, both = purple. | |
| 1629 | my $badge_html = ''; | |
| 1630 | if ($kind eq 'physical') { | |
| 1631 | $badge_html = qq~<span class="pill" style="background:rgba(217,119,6,0.15);color:#f59e0b;font-size:10px;letter-spacing:1px;font-weight:700;text-transform:uppercase">3D Print</span>~; | |
| 1632 | } elsif ($kind eq 'both') { | |
| 1633 | $badge_html = qq~<span class="pill" style="background:rgba(168,85,247,0.15);color:#c084fc;font-size:10px;letter-spacing:1px;font-weight:700;text-transform:uppercase">File + Print</span>~; | |
| 1634 | } else { | |
| 1635 | $badge_html = qq~<span class="pill" style="background:rgba(59,130,246,0.15);color:#60a5fa;font-size:10px;letter-spacing:1px;font-weight:700;text-transform:uppercase">Digital</span>~; | |
| 1636 | } | |
| 1637 | # Fold the kind badge into the title so every storefront | |
| 1638 | # layout shows it without per-layout edits. Layouts that want | |
| 1639 | # the badge in a different slot can use $loop1.kind_badge_html | |
| 1640 | # alone and strip it out of the title with a tagless variant | |
| 1641 | # in their own copy. Title content was previously emitted | |
| 1642 | # unescaped here, so prefixing raw badge HTML is consistent. | |
| 1643 | my $title_with_badge = $badge_html . qq~ <span style="vertical-align:middle">$r->{title}</span>~; | |
| 1644 | push @products, { | |
| 1645 | id => $r->{id}, | |
| 1646 | title => $title_with_badge, | |
| 1647 | title_plain => $r->{title}, # alt/og:title use this | |
| 1648 | price => '$' . sprintf('%.2f', $cents / 100), | |
| 1649 | rating => $rating, | |
| 1650 | rating_n => $r->{rating_count} || 0, | |
| 1651 | # Used by the Spotlight layout to render the first product as | |
| 1652 | # a giant featured hero. Other layouts ignore this flag. | |
| 1653 | is_first => $is_first, | |
| 1654 | image_idx => $img_idx, | |
| 1655 | hero_image_url => $r->{hero_image_url} || '', | |
| 1656 | hero_image => $resolved_hero, | |
| 1657 | hero_image_pos => $r->{hero_image_pos} || '50% 50%', | |
| 1658 | # Precomputed HTML keeps conditionals + UTF-8 stars out of the | |
| 1659 | # loop body, which the template engine parses unreliably. | |
| 1660 | rating_html => $rating ne '' | |
| 1661 | ? qq~<span class="pill warning">\x{2605} $rating</span>~ | |
| 1662 | : '', | |
| 1663 | product_kind => $kind, | |
| 1664 | kind_badge_html => $badge_html, | |
| 1665 | is_placeholder => 0, | |
| 1666 | }; | |
| 1667 | $is_first = 0; | |
| 1668 | } | |
| 1669 | ||
| 1670 | # Empty real storefront? Fall back to placeholder products so the | |
| 1671 | # layout still renders something visual. Creators see the storefront | |
| 1672 | # populated even before they've added their own designs; replaced | |
| 1673 | # one-for-one as real products land. | |
| 1674 | # Exception: when a search is active and returned nothing, DO NOT | |
| 1675 | # surface placeholders -- they'd read as "matches" and mislead the | |
| 1676 | # buyer. The search results banner below tells them 0 was found. | |
| 1677 | my $is_placeholder_set = 0; | |
| 1678 | if (!@products && !$tvars->{has_search}) { | |
| 1679 | @products = _placeholder_products(); | |
| 1680 | $is_placeholder_set = 1; | |
| 1681 | } | |
| 1682 | ||
| 1683 | # Apply any product_tile experiment overrides to @products. This | |
| 1684 | # patches title + hero image on the specific product the experiment | |
| 1685 | # targets; other products in the grid render normally. Skipped when | |
| 1686 | # placeholder products are in play (no real models to target). | |
| 1687 | if (!$is_placeholder_set && $tvars->{_experiment_assignments}) { | |
| 1688 | $ex_helper->apply_to_products( | |
| 1689 | $tvars->{_experiment_assignments}, \@products | |
| 1690 | ); | |
| 1691 | } | |
| 1692 | ||
| 1693 | $tvars->{products} = \@products; | |
| 1694 | $tvars->{has_products} = scalar(@products) ? 1 : 0; | |
| 1695 | $tvars->{product_count} = scalar(@products); | |
| 1696 | $tvars->{products_are_placeholder} = $is_placeholder_set; | |
| 1697 | ||
| 1698 | # Pagination tvars. Only surface controls when there's more than one | |
| 1699 | # page; the template hides the nav block when has_pagination=0. | |
| 1700 | # Build URLs by preserving the existing query string + replacing p=N | |
| 1701 | # so deep-linked storefronts (subdomain/custom-domain/preview) keep | |
| 1702 | # their context across page navigation. | |
| 1703 | { | |
| 1704 | my $here_path = $ENV{SCRIPT_NAME} || '/store.cgi'; | |
| 1705 | my $build_qs = sub { | |
| 1706 | my ($pn) = @_; | |
| 1707 | my %kv; | |
| 1708 | foreach my $k (keys %$form) { | |
| 1709 | next if $k eq 'p'; | |
| 1710 | # subdomain + host are injected by nginx when serving a | |
| 1711 | # subdomain/custom-domain request, so emitting them here | |
| 1712 | # too would double them up. CGI.pm Vars joins duplicate | |
| 1713 | # keys with NUL; the regex below would then strip the NUL | |
| 1714 | # and concatenate the values ('3dshawn\03dshawn' -> | |
| 1715 | # '3dshawn3dshawn'), causing the storefront lookup to | |
| 1716 | # miss and the page to fall back to the demo storefront. | |
| 1717 | next if $k eq 'subdomain' || $k eq 'host'; | |
| 1718 | my $v = $form->{$k}; | |
| 1719 | next unless defined $v && length $v; | |
| 1720 | # Dedup any NUL-joined value we read from $form. | |
| 1721 | $v = (split /\0/, $v)[0] // ''; | |
| 1722 | next unless length $v; | |
| 1723 | $kv{$k} = $v; | |
| 1724 | } | |
| 1725 | $kv{p} = $pn if $pn && $pn > 1; | |
| 1726 | return '' unless %kv; | |
| 1727 | return '?' . join('&', map { | |
| 1728 | my $k = $_; my $v = $kv{$k}; | |
| 1729 | $k =~ s/([^A-Za-z0-9_.\-])/sprintf('%%%02X', ord($1))/ge; | |
| 1730 | $v =~ s/([^A-Za-z0-9_.\-])/sprintf('%%%02X', ord($1))/ge; | |
| 1731 | "$k=$v"; | |
| 1732 | } sort keys %kv); | |
| 1733 | }; | |
| 1734 | ||
| 1735 | my @pg_links; | |
| 1736 | if ($total_pages > 1 && !$is_placeholder_set) { | |
| 1737 | for (my $i = 1; $i <= $total_pages; $i++) { | |
| 1738 | push @pg_links, { | |
| 1739 | num => $i, | |
| 1740 | href => $here_path . $build_qs->($i), | |
| 1741 | is_active => ($i == $page_num) ? 1 : 0, | |
| 1742 | }; | |
| 1743 | } | |
| 1744 | } | |
| 1745 | ||
| 1746 | $tvars->{page_num} = $page_num; | |
| 1747 | $tvars->{total_pages} = $total_pages; | |
| 1748 | $tvars->{total_products} = $total_products; | |
| 1749 | $tvars->{has_pagination} = ($total_pages > 1 && !$is_placeholder_set) ? 1 : 0; | |
| 1750 | $tvars->{page_links} = \@pg_links; | |
| 1751 | $tvars->{has_prev_page} = ($page_num > 1) ? 1 : 0; | |
| 1752 | $tvars->{has_next_page} = ($page_num < $total_pages) ? 1 : 0; | |
| 1753 | $tvars->{prev_page_href} = $here_path . $build_qs->($page_num - 1); | |
| 1754 | $tvars->{next_page_href} = $here_path . $build_qs->($page_num + 1); | |
| 1755 | ||
| 1756 | # Pre-render the pagination block as a chunk of HTML so every | |
| 1757 | # storefront layout gets pagination FOR FREE -- current AND | |
| 1758 | # future themes. Layouts can either: | |
| 1759 | # (a) drop $pagination_html into their template where they | |
| 1760 | # want it placed (recommended -- usually below the grid), | |
| 1761 | # OR | |
| 1762 | # (b) do nothing -- store.cgi appends the block to the body | |
| 1763 | # automatically when the rendered output doesn't already | |
| 1764 | # contain it (id="store-pagination" is the marker). | |
| 1765 | # This avoids touching every layout file when adding new themes. | |
| 1766 | my $pag = ''; | |
| 1767 | if ($tvars->{has_pagination}) { | |
| 1768 | my $prev_btn = $tvars->{has_prev_page} | |
| 1769 | ? qq~<a href="$tvars->{prev_page_href}" class="btn btn-secondary btn-sm" style="text-decoration:none">← Previous</a>~ | |
| 1770 | : qq~<span class="btn btn-secondary btn-sm" style="opacity:0.4;cursor:default">← Previous</span>~; | |
| 1771 | my $next_btn = $tvars->{has_next_page} | |
| 1772 | ? qq~<a href="$tvars->{next_page_href}" class="btn btn-secondary btn-sm" style="text-decoration:none">Next →</a>~ | |
| 1773 | : qq~<span class="btn btn-secondary btn-sm" style="opacity:0.4;cursor:default">Next →</span>~; | |
| 1774 | my @num_links; | |
| 1775 | foreach my $pl (@pg_links) { | |
| 1776 | if ($pl->{is_active}) { | |
| 1777 | push @num_links, qq~<span class="btn btn-primary btn-sm" style="min-width:36px;text-align:center">$pl->{num}</span>~; | |
| 1778 | } else { | |
| 1779 | push @num_links, qq~<a href="$pl->{href}" class="btn btn-secondary btn-sm" style="text-decoration:none;min-width:36px;text-align:center">$pl->{num}</a>~; | |
| 1780 | } | |
| 1781 | } | |
| 1782 | my $nums = join('', @num_links); | |
| 1783 | $pag = <<"PAG"; | |
| 1784 | <section style="padding:40px 32px 60px;max-width:1480px;margin:0 auto"> | |
| 1785 | <div id="store-pagination" class="store-pagination" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap"> | |
| 1786 | $prev_btn | |
| 1787 | $nums | |
| 1788 | $next_btn | |
| 1789 | </div> | |
| 1790 | <div style="text-align:center;color:var(--col-text-3);font-size:12px;margin-top:10px">Page $page_num of $total_pages · $total_products designs total</div> | |
| 1791 | </section> | |
| 1792 | PAG | |
| 1793 | } | |
| 1794 | $tvars->{pagination_html} = $pag; | |
| 1795 | } | |
| 1796 | ||
| 1797 | # Hero-image fallback chain. The defaults set earlier point at stock | |
| 1798 | # reference images (assets/images/N.webp) when the storefront row's | |
| 1799 | # hero_image_1/2/3 columns are NULL. That's wrong once the seller has | |
| 1800 | # uploaded real product images -- they expect to see their own work | |
| 1801 | # in the hero. Override those defaults here so: | |
| 1802 | # - If the seller has explicit storefront-level hero_image_N, keep it | |
| 1803 | # - Otherwise, use the resolved hero from the first 3 real products | |
| 1804 | # - If we're showing placeholder products, leave the stock fallback | |
| 1805 | if (!$is_placeholder_set) { | |
| 1806 | # Walk every product and collect the first three REAL hero | |
| 1807 | # images (skipping the stock /assets/images/N.webp set and the | |
| 1808 | # neutral SVG data: URL). This way a product with no uploaded | |
| 1809 | # image doesn't stall the hero strip -- the next product with | |
| 1810 | # real art fills the slot. | |
| 1811 | my @real_imgs; | |
| 1812 | for my $p (@products) { | |
| 1813 | last if @real_imgs >= 3; | |
| 1814 | next unless $p && $p->{hero_image}; | |
| 1815 | next if $p->{hero_image} =~ m{^/assets/images/}; | |
| 1816 | next if $p->{hero_image} =~ m{^data:image/svg}; | |
| 1817 | push @real_imgs, $p->{hero_image}; | |
| 1818 | } | |
| 1819 | # Validate token-based seller overrides against page_images so a | |
| 1820 | # deleted upload no longer leaves an empty card. /img.cgi?id=TOK | |
| 1821 | # URLs whose TOK isn't in page_images get treated like an empty | |
| 1822 | # slot, falling through to the product-image fill below. | |
| 1823 | my %live_tokens; | |
| 1824 | my @cand_tokens; | |
| 1825 | for my $slot ('hero_image_1','hero_image_2','hero_image_3','about_image') { | |
| 1826 | my $u = $store->{$slot}; | |
| 1827 | next unless defined $u && length $u; | |
| 1828 | $u =~ s/^\s+|\s+$//g; | |
| 1829 | push @cand_tokens, $1 if $u =~ m{^/img\.cgi\?id=([a-zA-Z0-9]{8,32})\b}; | |
| 1830 | } | |
| 1831 | if (@cand_tokens) { | |
| 1832 | my $in_list = join(',', map { "'$_'" } @cand_tokens); | |
| 1833 | my @rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 1834 | SELECT id, storefront_id, filename | |
| 1835 | FROM `${DB}`.page_images WHERE id IN ($in_list) | |
| 1836 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1837 | # Row present doesn't prove the file is still on disk -- the | |
| 1838 | # uploads/N dir can be cleaned without the DB row going away. | |
| 1839 | # Stat the file (same path img.cgi serves from) so we only | |
| 1840 | # treat the URL as live when the actual bytes are present. | |
| 1841 | for my $r (@rows) { | |
| 1842 | my $sid = $r->{storefront_id} || ''; | |
| 1843 | $sid =~ s/[^0-9]//g; | |
| 1844 | my $fn = $r->{filename} || ''; | |
| 1845 | next unless $sid && length $fn; | |
| 1846 | next if $fn =~ m{[/\\]} || $fn =~ /\.\./; | |
| 1847 | my $path = "/var/www/vhosts/3dshawn.com/repricer.3dshawn.com/uploads/$sid/$fn"; | |
| 1848 | $live_tokens{$r->{id}} = 1 if -r $path; | |
| 1849 | } | |
| 1850 | } | |
| 1851 | my $seller_value_is_live = sub { | |
| 1852 | my ($u) = @_; | |
| 1853 | return 0 unless defined $u && length $u; | |
| 1854 | $u =~ s/^\s+|\s+$//g; | |
| 1855 | return 0 unless length $u; | |
| 1856 | if ($u =~ m{^/img\.cgi\?id=([a-zA-Z0-9]{8,32})\b}) { | |
| 1857 | return $live_tokens{$1} ? 1 : 0; | |
| 1858 | } | |
| 1859 | return 1; # /img.cgi?m=N, external URLs, data: -- trust | |
| 1860 | }; | |
| 1861 | ||
| 1862 | my @hero_slots = ('hero_image_1', 'hero_image_2', 'hero_image_3'); | |
| 1863 | for (my $i = 0; $i < @hero_slots; $i++) { | |
| 1864 | my $slot = $hero_slots[$i]; | |
| 1865 | # Respect seller-set value only if its target still resolves; | |
| 1866 | # dead tokens fall through to the product fallback below. | |
| 1867 | next if $seller_value_is_live->($store->{$slot}); | |
| 1868 | # If we found fewer than 3 real images, cycle through what | |
| 1869 | # we have so every slot still shows real content instead of | |
| 1870 | # an empty placeholder. With 1 real image all three slots | |
| 1871 | # show the same model -- consistent and obviously "single | |
| 1872 | # design" rather than partly-empty. | |
| 1873 | next unless @real_imgs; | |
| 1874 | $tvars->{$slot} = $real_imgs[$i % scalar(@real_imgs)]; | |
| 1875 | } | |
| 1876 | # Stash the live-check closure so the about-image block below | |
| 1877 | # can reuse the same liveness check without re-querying. | |
| 1878 | $tvars->{_seller_value_is_live} = $seller_value_is_live; | |
| 1879 | } | |
| 1880 | ||
| 1881 | # About-section auto-fill. Same idea as the hero fallback: when the | |
| 1882 | # seller hasn't typed/picked their own, surface their actual content | |
| 1883 | # so the storefront looks populated immediately. Sellers always win | |
| 1884 | # if they've set the column explicitly on the storefront row. | |
| 1885 | if (!$is_placeholder_set) { | |
| 1886 | # about_image -- prefer the 4th product so it's visually distinct | |
| 1887 | # from the three already used in the hero strip. Falls back to | |
| 1888 | # the first product when fewer than 4 exist. Skips stock / | |
| 1889 | # placeholder URLs the same way the hero fallback does. | |
| 1890 | my $about_live = $tvars->{_seller_value_is_live} | |
| 1891 | ? $tvars->{_seller_value_is_live}->($store->{about_image}) | |
| 1892 | : ($store->{about_image} && length $store->{about_image}); | |
| 1893 | if (!$about_live) { | |
| 1894 | # Prefer a real image we haven't already used in the hero | |
| 1895 | # strip so the about block reads as visually distinct from | |
| 1896 | # the hero. Falls back to any real image when fewer than | |
| 1897 | # four products with real art exist. | |
| 1898 | my %hero_used = map { $_ => 1 } grep { $_ } | |
| 1899 | @{$tvars}{qw(hero_image_1 hero_image_2 hero_image_3)}; | |
| 1900 | my $pick_img; | |
| 1901 | for my $p (@products) { | |
| 1902 | next unless $p && $p->{hero_image}; | |
| 1903 | next if $p->{hero_image} =~ m{^/assets/images/}; | |
| 1904 | next if $p->{hero_image} =~ m{^data:image/svg}; | |
| 1905 | next if $hero_used{$p->{hero_image}}; | |
| 1906 | $pick_img = $p->{hero_image}; | |
| 1907 | last; | |
| 1908 | } | |
| 1909 | # Nothing distinct? Reuse the first real image we have. | |
| 1910 | unless ($pick_img) { | |
| 1911 | for my $p (@products) { | |
| 1912 | next unless $p && $p->{hero_image}; | |
| 1913 | next if $p->{hero_image} =~ m{^/assets/images/}; | |
| 1914 | next if $p->{hero_image} =~ m{^data:image/svg}; | |
| 1915 | $pick_img = $p->{hero_image}; | |
| 1916 | last; | |
| 1917 | } | |
| 1918 | } | |
| 1919 | $tvars->{about_image} = $pick_img if $pick_img; | |
| 1920 | } | |
| 1921 | # store_about -- swap the boilerplate for count-aware copy so a | |
| 1922 | # storefront with real designs reads as a real storefront, not | |
| 1923 | # an empty template. n=0 keeps the existing "just getting | |
| 1924 | # started" line so 0-product stores still get a warm message. | |
| 1925 | if (!$store->{about_text} || !length $store->{about_text}) { | |
| 1926 | my $n = $total_products || 0; | |
| 1927 | my $sn = $tvars->{store_name} || 'this studio'; | |
| 1928 | if ($n == 1) { | |
| 1929 | $tvars->{store_about} = "Welcome to $sn. The first design is up -- more on the way."; | |
| 1930 | } elsif ($n >= 2 && $n <= 5) { | |
| 1931 | $tvars->{store_about} = "$sn has $n printable designs ready to download. New releases landing regularly."; | |
| 1932 | } elsif ($n >= 6) { | |
| 1933 | $tvars->{store_about} = "$sn has a growing catalog of $n+ printable designs. New drops added regularly."; | |
| 1934 | } | |
| 1935 | } | |
| 1936 | } | |
| 1937 | ||
| 1938 | # Spotlight layout uses a single featured product (the first one) and | |
| 1939 | # a separate strip of secondary products (the rest). Split here in | |
| 1940 | # Perl so templates never need conditionals inside iterations. | |
| 1941 | # | |
| 1942 | # Caveat: if a storefront-level featured override is set | |
| 1943 | # (featured_title / featured_hero_image), the hero is no longer | |
| 1944 | # a representation of products[0] -- it shows whatever custom | |
| 1945 | # content the creator typed in. In that case, the actual products | |
| 1946 | # are decoupled from the hero, so ALL of them belong in the | |
| 1947 | # secondary grid (otherwise the first model would disappear from | |
| 1948 | # the storefront entirely). | |
| 1949 | my $has_featured_override = | |
| 1950 | (defined $store->{featured_title} && length $store->{featured_title}) | |
| 1951 | || (defined $store->{featured_hero_image} && length $store->{featured_hero_image}) | |
| 1952 | || (defined $store->{featured_price} && length $store->{featured_price}); | |
| 1953 | ||
| 1954 | $tvars->{has_featured} = 1; | |
| 1955 | $tvars->{featured_product} = $products[0]; | |
| 1956 | if ($has_featured_override) { | |
| 1957 | $tvars->{secondary_products} = [ @products ]; | |
| 1958 | $tvars->{has_secondary} = scalar(@products) ? 1 : 0; | |
| 1959 | } else { | |
| 1960 | $tvars->{secondary_products} = [ @products[1..$#products] ]; | |
| 1961 | $tvars->{has_secondary} = (scalar(@products) > 1) ? 1 : 0; | |
| 1962 | } | |
| 1963 | ||
| 1964 | # Storefront navigation pages -- every published page that isn't | |
| 1965 | # the home (the brand link in the header already points home). | |
| 1966 | # Layouts loop [loop:@nav_pages] in their header to render real | |
| 1967 | # page links instead of hardcoded #shop / #about anchor jumps. | |
| 1968 | # menu_label is the display text the seller controls in the page | |
| 1969 | # editor (lets them call a page "Maker" while the actual page | |
| 1970 | # title stays "About 3DShawn"). Falls back to title. | |
| 1971 | my @nav_rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 1972 | SELECT slug, title, menu_label, page_type | |
| 1973 | FROM `${DB}`.storefront_pages | |
| 1974 | WHERE storefront_id='$store->{id}' | |
| 1975 | AND status='published' | |
| 1976 | AND page_type != 'home' | |
| 1977 | AND show_in_menu = 1 | |
| 1978 | ORDER BY page_type ASC, title ASC | |
| 1979 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 1980 | my @nav_pages; | |
| 1981 | foreach my $p (@nav_rows) { | |
| 1982 | my $slug = $p->{slug} || ''; | |
| 1983 | $slug =~ s/[^a-z0-9\-_]//g; | |
| 1984 | next unless length $slug; | |
| 1985 | my $label = (defined $p->{menu_label} && length $p->{menu_label}) | |
| 1986 | ? $p->{menu_label} | |
| 1987 | : ($p->{title} || ucfirst($slug)); | |
| 1988 | push @nav_pages, { | |
| 1989 | slug => $slug, | |
| 1990 | title => $label, # what shows in the header link | |
| 1991 | page_title => $p->{title}, # the full page title (kept for completeness) | |
| 1992 | href => "/store.cgi?id=$store->{id}&page=$slug", | |
| 1993 | }; | |
| 1994 | } | |
| 1995 | $tvars->{nav_pages} = \@nav_pages; | |
| 1996 | $tvars->{has_nav_pages} = scalar(@nav_pages) ? 1 : 0; | |
| 1997 | ||
| 1998 | # Cart count for the header badge. We read the buyer's cookie | |
| 1999 | # here so the badge is correct on every page render. The | |
| 2000 | # repricer_cart cookie may be missing (returns 0). | |
| 2001 | # Buyer auth state. Lets the storefront swap the "Sign in" button | |
| 2002 | # for "My orders" when a logged-in buyer is on the page. | |
| 2003 | my $ba = MODS::RePricer::BuyerAuth->new; | |
| 2004 | my $buyer = $ba->verify($q, $db, $dbh, $DB); | |
| 2005 | $tvars->{is_buyer_logged_in} = ($buyer && $buyer->{id}) ? 1 : 0; | |
| 2006 | $tvars->{buyer_email} = $buyer ? $buyer->{email} : ''; | |
| 2007 | $tvars->{buyer_name} = $buyer ? ($buyer->{display_name} || $buyer->{email}) : ''; | |
| 2008 | ||
| 2009 | my $cart_token = $cart->read_token($q); | |
| 2010 | $tvars->{cart_count} = $cart_token | |
| 2011 | ? $cart->count($db, $dbh, $DB, $cart_token, $store->{id}) | |
| 2012 | : 0; | |
| 2013 | $tvars->{has_cart_items} = $tvars->{cart_count} ? 1 : 0; | |
| 2014 | $tvars->{cart_href} = "/cart.cgi?id=$store->{id}"; | |
| 2015 | ||
| 2016 | # ---- Pre-rendered header chrome: search + sign in + cart + category | |
| 2017 | # chips. Layouts paste these tvars into their own <header> so the | |
| 2018 | # buyer chrome integrates with each layout's branding instead of | |
| 2019 | # floating above as a separate strip. Buttons use .btn classes so | |
| 2020 | # each layout's theme styling carries through automatically. | |
| 2021 | { | |
| 2022 | my $sid_int = $store->{id} + 0; | |
| 2023 | my $sq_attr_h = defined $tvars->{search_query} ? $tvars->{search_query} : ''; | |
| 2024 | for ($sq_attr_h) { s/&/&/g; s/"/"/g; s/</</g; s/>/>/g; } | |
| 2025 | my $cart_href_h = $tvars->{cart_href} || '/cart.cgi'; | |
| 2026 | my $cart_count_h = $tvars->{cart_count} || 0; | |
| 2027 | my $has_cart_h = ($cart_count_h && $cart_count_h > 0) ? 1 : 0; | |
| 2028 | my $is_buyer_h = $tvars->{is_buyer_logged_in} ? 1 : 0; | |
| 2029 | my $signin_label_h = $tvars->{header_signin_label} || 'Sign in'; | |
| 2030 | my $cart_label_h = $tvars->{header_cart_label} || 'Cart'; | |
| 2031 | ||
| 2032 | # Search form -- inherits layout's .input styling and theme vars. | |
| 2033 | $tvars->{store_search_form} = qq~<form id="store-search" action="/store.cgi" method="GET" role="search" style="display:flex;align-items:center;gap:8px;background:var(--col-surface-2);border:1px solid var(--col-border);border-radius:8px;padding:7px 12px;flex:1;min-width:0;max-width:420px"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;opacity:0.6;color:var(--col-text-2)"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg><input type="hidden" name="id" value="$sid_int"><input type="text" name="q" value="$sq_attr_h" placeholder="Search this store..." autocomplete="off" style="background:none;border:none;outline:none;color:var(--col-text);font-size:13px;flex:1;min-width:0;font-family:inherit"></form>~; | |
| 2034 | ||
| 2035 | # Sign in / My orders button. .btn class theming. | |
| 2036 | $tvars->{store_signin_link} = $is_buyer_h | |
| 2037 | ? qq~<a href="/my_orders.cgi?id=$sid_int" class="btn btn-secondary btn-sm">My orders</a>~ | |
| 2038 | : qq~<a href="/buyer_login.cgi?id=$sid_int" class="btn btn-secondary btn-sm">$signin_label_h</a>~; | |
| 2039 | ||
| 2040 | # Cart button with optional count badge. | |
| 2041 | my $badge_h = $has_cart_h | |
| 2042 | ? qq~<span class="we-cart-badge" style="margin-left:6px;display:inline-block;min-width:18px;height:18px;padding:0 5px;border-radius:9px;background:#fff;color:var(--col-accent);font-size:11px;font-weight:800;line-height:18px;text-align:center">$cart_count_h</span>~ | |
| 2043 | : ''; | |
| 2044 | $tvars->{store_cart_link} = qq~<a href="$cart_href_h" id="we-cart-link" class="btn btn-primary btn-sm" style="position:relative">$cart_label_h$badge_h</a>~; | |
| 2045 | ||
| 2046 | # Convenience bundle: signin + cart side-by-side so layouts can | |
| 2047 | # drop one tvar at the end of their nav row instead of two. | |
| 2048 | $tvars->{store_account_actions} = qq~<div id="store-account-actions" style="display:inline-flex;gap:8px;align-items:center">$tvars->{store_signin_link} $tvars->{store_cart_link}</div>~; | |
| 2049 | ||
| 2050 | # ---- Seller category chips strip. Themed to layout's colors | |
| 2051 | # (uses --col-surface-2 / --col-border / --col-text). Rendered | |
| 2052 | # below the layout's header by store.cgi if not consumed | |
| 2053 | # explicitly via $store_categories_html in the template. | |
| 2054 | $tvars->{store_categories_html} = ''; | |
| 2055 | my $cat_dbh = $db->db_connect(); | |
| 2056 | my $cat_tbl = $db->db_readwrite($cat_dbh, qq~ | |
| 2057 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 2058 | WHERE table_schema='$DB' AND table_name='seller_categories' | |
| 2059 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 2060 | if ($cat_tbl && $cat_tbl->{n}) { | |
| 2061 | my $uid_cat = $store->{user_id} + 0; | |
| 2062 | my @chip_rows = $db->db_readwrite_multiple($cat_dbh, qq~ | |
| 2063 | SELECT slug, name, color, icon | |
| 2064 | FROM `${DB}`.seller_categories | |
| 2065 | WHERE user_id='$uid_cat' AND is_public=1 AND model_count > 0 | |
| 2066 | ORDER BY sort_order ASC, name ASC | |
| 2067 | LIMIT 20 | |
| 2068 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 2069 | if (@chip_rows) { | |
| 2070 | my $active_slug = lc($form->{seller_cat} || ''); | |
| 2071 | $active_slug =~ s/[^a-z0-9\-]//g; | |
| 2072 | my $all_active = $active_slug ? 0 : 1; | |
| 2073 | my $all_bg = $all_active ? 'var(--col-accent)' : 'var(--col-surface-2)'; | |
| 2074 | my $all_fg = $all_active ? '#fff' : 'var(--col-text-2)'; | |
| 2075 | my $chips = qq~<nav id="store-categories" aria-label="Categories" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;padding:10px 18px;border-bottom:1px solid var(--col-border);background:var(--col-bg-2);font-family:inherit"><a href="/store.cgi?id=$sid_int" style="padding:4px 11px;border-radius:999px;background:$all_bg;color:$all_fg;font-size:12px;font-weight:700;text-decoration:none;white-space:nowrap;border:1px solid var(--col-border)">All</a>~; | |
| 2076 | foreach my $cr (@chip_rows) { | |
| 2077 | my $slug = $cr->{slug} || ''; | |
| 2078 | my $name = $cr->{name} || $slug; | |
| 2079 | my $color = $cr->{color} || '#6d28d9'; | |
| 2080 | my $icon = $cr->{icon} || ''; | |
| 2081 | for ($name, $icon) { s/&/&/g; s/</</g; s/>/>/g; } | |
| 2082 | my $is_active = ($slug eq $active_slug) ? 1 : 0; | |
| 2083 | my $bg = $is_active ? $color : 'var(--col-surface-2)'; | |
| 2084 | my $fg = $is_active ? '#fff' : 'var(--col-text-2)'; | |
| 2085 | my $icon_html = length($icon) ? qq~<span style="opacity:0.85">$icon</span> ~ : ''; | |
| 2086 | $chips .= qq~<a href="/store.cgi?id=$sid_int&seller_cat=$slug" style="padding:4px 11px;border-radius:999px;background:$bg;color:$fg;font-size:12px;font-weight:600;text-decoration:none;white-space:nowrap;border:1px solid var(--col-border)">${icon_html}$name</a>~; | |
| 2087 | } | |
| 2088 | $chips .= "</nav>\n"; | |
| 2089 | $tvars->{store_categories_html} = $chips; | |
| 2090 | } | |
| 2091 | } | |
| 2092 | $db->db_disconnect($cat_dbh); | |
| 2093 | } | |
| 2094 | ||
| 2095 | # Storefront-level marketplace links. Pulls connected marketplace | |
| 2096 | # accounts for the creator -- visible as "Also find $name on:" in | |
| 2097 | # the footer. Wrapped because marketplace_accounts only exists | |
| 2098 | # after the 5.6 migration; DBConnect's error() helper exit()s | |
| 2099 | # the page if the table is missing. | |
| 2100 | # | |
| 2101 | # Use information_schema for the existence check -- SHOW TABLES | |
| 2102 | # LIKE goes through DBConnect's autoviv path and returns a | |
| 2103 | # truthy-but-empty hashref when nothing matches, which silently | |
| 2104 | # bypasses any naive "is the table here?" guard. | |
| 2105 | my @mp_links; | |
| 2106 | my $have_mp_check = $db->db_readwrite($dbh, qq~ | |
| 2107 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 2108 | WHERE table_schema='$DB' AND table_name='marketplace_accounts' | |
| 2109 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 2110 | if ($have_mp_check && $have_mp_check->{n}) { | |
| 2111 | my $store_uid = $store->{user_id} || 0; | |
| 2112 | $store_uid =~ s/[^0-9]//g; | |
| 2113 | if ($store_uid) { | |
| 2114 | my @mp_rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 2115 | SELECT platform, account_handle, metadata | |
| 2116 | FROM `${DB}`.marketplace_accounts | |
| 2117 | WHERE user_id='$store_uid' | |
| 2118 | AND status='connected' | |
| 2119 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 2120 | require MODS::RePricer::Marketplaces; | |
| 2121 | my $mp_registry = MODS::RePricer::Marketplaces->new; | |
| 2122 | foreach my $r (@mp_rows) { | |
| 2123 | my $p = $mp_registry->by_slug($r->{platform}) or next; | |
| 2124 | push @mp_links, { | |
| 2125 | slug => $p->{slug}, | |
| 2126 | name => $p->{name}, | |
| 2127 | href => $r->{account_handle} || $p->{signup_url}, | |
| 2128 | icon => $p->{icon}, | |
| 2129 | }; | |
| 2130 | } | |
| 2131 | } | |
| 2132 | } | |
| 2133 | $tvars->{marketplace_links} = \@mp_links; | |
| 2134 | $tvars->{has_marketplace_links} = scalar(@mp_links) ? 1 : 0; | |
| 2135 | ||
| 2136 | # Buyer-facing reviews -- pull the seller's 3 most recent 4-or-5-star | |
| 2137 | # external_comments (cross-platform inbox). Guarded by table-exists | |
| 2138 | # check since external_comments is added by a later migration. | |
| 2139 | my @reviews; | |
| 2140 | my $have_rev_tbl = $db->db_readwrite($dbh, qq~ | |
| 2141 | SELECT COUNT(*) AS n FROM information_schema.tables | |
| 2142 | WHERE table_schema='$DB' AND table_name='external_comments' | |
| 2143 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 2144 | if ($have_rev_tbl && $have_rev_tbl->{n}) { | |
| 2145 | my $store_uid = $store->{user_id} || 0; | |
| 2146 | $store_uid =~ s/[^0-9]//g; | |
| 2147 | if ($store_uid) { | |
| 2148 | my @rev_rows = $db->db_readwrite_multiple($dbh, qq~ | |
| 2149 | SELECT ec.body, ec.rating, ec.platform_name, ec.author_name, | |
| 2150 | m.title AS model_title | |
| 2151 | FROM `${DB}`.external_comments ec | |
| 2152 | LEFT JOIN `${DB}`.models m ON m.id = ec.model_id | |
| 2153 | WHERE ec.user_id='$store_uid' | |
| 2154 | AND ec.rating >= 4 | |
| 2155 | AND ec.is_reply=0 | |
| 2156 | AND ec.status != 'archived' | |
| 2157 | ORDER BY ec.occurred_at DESC | |
| 2158 | LIMIT 3 | |
| 2159 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 2160 | foreach my $r (@rev_rows) { | |
| 2161 | my $stars = int($r->{rating} || 0); | |
| 2162 | $stars = 5 if $stars > 5; | |
| 2163 | $stars = 1 if $stars < 1; | |
| 2164 | my $body = $r->{body} || ''; | |
| 2165 | $body =~ s/&/&/g; $body =~ s/</</g; $body =~ s/>/>/g; $body =~ s/"/"/g; | |
| 2166 | my $author = $r->{author_name} || 'Anonymous'; | |
| 2167 | $author =~ s/&/&/g; $author =~ s/</</g; $author =~ s/>/>/g; $author =~ s/"/"/g; | |
| 2168 | my $model_title = $r->{model_title} || ''; | |
| 2169 | $model_title =~ s/&/&/g; $model_title =~ s/</</g; $model_title =~ s/>/>/g; $model_title =~ s/"/"/g; | |
| 2170 | push @reviews, { | |
| 2171 | body => $body, | |
| 2172 | stars_str => ('★' x $stars), | |
| 2173 | author => $author, | |
| 2174 | model_title => $model_title, | |
| 2175 | has_model => $model_title ne '' ? 1 : 0, | |
| 2176 | }; | |
| 2177 | } | |
| 2178 | } | |
| 2179 | } | |
| 2180 | $tvars->{reviews} = \@reviews; | |
| 2181 | $tvars->{has_reviews} = scalar(@reviews) ? 1 : 0; | |
| 2182 | ||
| 2183 | # Spotlight featured content can be overridden at the storefront | |
| 2184 | # level. Priority: storefront override > real model field > whatever | |
| 2185 | # came from placeholder data. Storefront-level overrides let the | |
| 2186 | # creator customize the hero even before any real model is listed. | |
| 2187 | $tvars->{featured_image_attrs} = ''; | |
| 2188 | if ($tvars->{featured_product}) { | |
| 2189 | my $fp = $tvars->{featured_product}; | |
| 2190 | my %f = %$fp; | |
| 2191 | # Apply overrides where present. | |
| 2192 | $f{title} = $store->{featured_title} if defined $store->{featured_title} && length $store->{featured_title}; | |
| 2193 | $f{price} = $store->{featured_price} if defined $store->{featured_price} && length $store->{featured_price}; | |
| 2194 | $f{hero_image} = $store->{featured_hero_image} if defined $store->{featured_hero_image} && length $store->{featured_hero_image}; | |
| 2195 | $f{hero_image_pos} = $store->{featured_hero_pos} if defined $store->{featured_hero_pos} && length $store->{featured_hero_pos}; | |
| 2196 | $tvars->{featured_product} = \%f; | |
| 2197 | } | |
| 2198 | ||
| 2199 | # Set a single tvar that templates can check via [if:$is_editor_owner] | |
| 2200 | # to render owner-only chrome (e.g. the "Feature" button overlay | |
| 2201 | # on secondary tiles in the spotlight layout). | |
| 2202 | $tvars->{is_editor_owner} = ($form->{editor_live} && $is_owner) ? 1 : 0; | |
| 2203 | ||
| 2204 | # In editor_live + owner mode, wrap title / price / image with the | |
| 2205 | # right save-target. Storefront-level overrides route saves to | |
| 2206 | # update_storefront_field.cgi; real-model fields route to | |
| 2207 | # update_model_field.cgi. Placeholders also get wrapped (as | |
| 2208 | # storefront-level) so the creator can customize the hero without | |
| 2209 | # having to upload a model first -- whatever they type lands in | |
| 2210 | # storefronts.featured_* and persists across page loads. | |
| 2211 | if ($form->{editor_live} && $is_owner && $tvars->{featured_product}) { | |
| 2212 | my $fp = $tvars->{featured_product}; | |
| 2213 | my $mid = $fp->{id}; | |
| 2214 | my $esc = sub { | |
| 2215 | my ($s) = @_; $s //= ''; | |
| 2216 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 2217 | return $s; | |
| 2218 | }; | |
| 2219 | ||
| 2220 | my $real_model = (!$is_placeholder_set); | |
| 2221 | my $title_overridden = defined $store->{featured_title} && length $store->{featured_title}; | |
| 2222 | my $price_overridden = defined $store->{featured_price} && length $store->{featured_price}; | |
| 2223 | my $hero_overridden = defined $store->{featured_hero_image} && length $store->{featured_hero_image}; | |
| 2224 | ||
| 2225 | # Helper -- pick the right target/field/id triple for a given | |
| 2226 | # piece of content. If the override is set, or if the underlying | |
| 2227 | # data is a placeholder, route to the storefront-level field. | |
| 2228 | # Otherwise (real model with no override) route to the model. | |
| 2229 | my $wrap_text = sub { | |
| 2230 | my ($store_field, $model_field, $is_overridden, $value) = @_; | |
| 2231 | my ($target, $field, $id_attr); | |
| 2232 | if ($is_overridden || !$real_model) { | |
| 2233 | $target = 'storefront'; | |
| 2234 | $field = $store_field; | |
| 2235 | $id_attr = ''; | |
| 2236 | } else { | |
| 2237 | $target = 'model'; | |
| 2238 | $field = $model_field; | |
| 2239 | $id_attr = qq~ data-we-target-id="$mid"~; | |
| 2240 | } | |
| 2241 | return qq~<span data-we-editable="text" data-we-field="$field" data-we-target="$target"$id_attr>~ . $esc->($value) . qq~</span>~; | |
| 2242 | }; | |
| 2243 | ||
| 2244 | my %f = %$fp; | |
| 2245 | $f{title} = $wrap_text->('featured_title', 'title', $title_overridden, $fp->{title}); | |
| 2246 | $f{price} = $wrap_text->('featured_price', 'price', $price_overridden, $fp->{price}); | |
| 2247 | $tvars->{featured_product} = \%f; | |
| 2248 | ||
| 2249 | # Hero image attributes follow the same routing rule. | |
| 2250 | my $hero_pos = $fp->{hero_image_pos} || '50% 50%'; | |
| 2251 | if ($hero_overridden || !$real_model) { | |
| 2252 | $tvars->{featured_image_attrs} = | |
| 2253 | qq~data-we-editable="image" data-we-field="featured_hero_image" data-we-target="storefront" data-we-pos-field="featured_hero_pos" data-we-pos="$hero_pos"~; | |
| 2254 | } else { | |
| 2255 | $tvars->{featured_image_attrs} = | |
| 2256 | qq~data-we-editable="image" data-we-field="hero_image_url" data-we-target="model" data-we-target-id="$mid" data-we-pos-field="hero_image_pos" data-we-pos="$hero_pos"~; | |
| 2257 | } | |
| 2258 | } | |
| 2259 | ||
| 2260 | $db->db_disconnect($dbh); | |
| 2261 | return $tvars; | |
| 2262 | } | |
| 2263 | ||
| 2264 | # Demo data — shown when no real storefront resolves. Uses the reference | |
| 2265 | # webp images shipped in /assets/images/. | |
| 2266 | sub _extract_body_from_layout { | |
| 2267 | my ($json) = @_; | |
| 2268 | return '' unless defined $json && length $json; | |
| 2269 | if ($json =~ /"body"\s*:\s*"((?:[^"\\]|\\.)*)"/s) { | |
| 2270 | my $b = $1; | |
| 2271 | $b =~ s/\\"/"/g; | |
| 2272 | $b =~ s/\\\\/\\/g; | |
| 2273 | $b =~ s/\\n/\n/g; | |
| 2274 | $b =~ s/\\r/\r/g; | |
| 2275 | $b =~ s/\\t/\t/g; | |
| 2276 | return $b; | |
| 2277 | } | |
| 2278 | return ''; | |
| 2279 | } | |
| 2280 | ||
| 2281 | # Placeholder products shown when a storefront has no real products yet. | |
| 2282 | # Used both by the always-on demo storefront and as a fallback for live | |
| 2283 | # storefronts whose creator hasn't added designs. Keeps any layout | |
| 2284 | # looking populated until real products land. | |
| 2285 | sub _emit_maintenance_page { | |
| 2286 | my ($store, $is_owner) = @_; | |
| 2287 | # HTML-escape the store name -- it's seller-supplied free text. | |
| 2288 | my $name = defined $store->{name} ? $store->{name} : 'this storefront'; | |
| 2289 | $name =~ s/&/&/g; $name =~ s/</</g; $name =~ s/>/>/g; | |
| 2290 | my $initials = _store_initials($name); | |
| 2291 | my $sid = $store->{id}; | |
| 2292 | my $owner_strip = $is_owner | |
| 2293 | ? qq~<div style="position:fixed;top:0;left:0;right:0;padding:10px 20px;background:#0a0e1c;border-bottom:1px solid rgba(59,130,246,0.40);text-align:center;font-size:13px;color:#93c5fd;font-weight:600;z-index:9999">You're viewing your own closed storefront. <a href="/storefront.cgi" style="color:#fff;text-decoration:underline">Open it again →</a></div>~ | |
| 2294 | : ''; | |
| 2295 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store\n\n"; | |
| 2296 | print <<"MAINT"; | |
| 2297 | <!DOCTYPE html> | |
| 2298 | <html lang="en"> | |
| 2299 | <head> | |
| 2300 | <meta charset="UTF-8"> | |
| 2301 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 2302 | <title>$name · Back soon</title> | |
| 2303 | <meta name="robots" content="noindex"> | |
| 2304 | <style> | |
| 2305 | body { margin: 0; min-height: 100vh; display: grid; place-items: center; | |
| 2306 | background: radial-gradient(ellipse 80% 60% at 50% 30%, rgba(59,130,246,0.18) 0%, transparent 70%), #060914; | |
| 2307 | color: #e2e8f0; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | |
| 2308 | padding: 40px 20px; } | |
| 2309 | .card { max-width: 520px; width: 100%; background: rgba(15,23,42,0.65); | |
| 2310 | border: 1px solid rgba(148,163,184,0.18); backdrop-filter: blur(14px); | |
| 2311 | border-radius: 18px; padding: 44px 38px; text-align: center; | |
| 2312 | box-shadow: 0 30px 80px rgba(0,0,0,0.55); } | |
| 2313 | .mark { display: inline-grid; place-items: center; width: 66px; height: 66px; | |
| 2314 | margin: 0 auto 22px; border-radius: 16px; | |
| 2315 | background: linear-gradient(135deg, #3b82f6, #6366f1, #06b6d4); | |
| 2316 | color: #fff; font-weight: 800; font-size: 24px; letter-spacing: 1px; | |
| 2317 | box-shadow: 0 0 28px rgba(99,102,241,0.45); } | |
| 2318 | .eyebrow { font-size: 11px; letter-spacing: 2px; text-transform: uppercase; | |
| 2319 | color: #93c5fd; font-weight: 700; margin-bottom: 10px; } | |
| 2320 | h1 { font-size: 28px; margin: 0 0 12px; color: #fff; letter-spacing: 0.2px; } | |
| 2321 | p { font-size: 15px; line-height: 1.65; color: #cbd5e1; margin: 0 0 14px; } | |
| 2322 | .pulse { display: inline-block; width: 10px; height: 10px; border-radius: 50%; | |
| 2323 | background: #f59e0b; box-shadow: 0 0 10px rgba(245,158,11,0.7); | |
| 2324 | margin-right: 8px; vertical-align: middle; | |
| 2325 | animation: blink 1.8s ease-in-out infinite; } | |
| 2326 | \@keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } } | |
| 2327 | .foot { margin-top: 22px; font-size: 12px; color: #64748b; | |
| 2328 | letter-spacing: 1px; text-transform: uppercase; font-weight: 700; } | |
| 2329 | </style> | |
| 2330 | </head> | |
| 2331 | <body> | |
| 2332 | $owner_strip | |
| 2333 | <div class="card"> | |
| 2334 | <div class="mark">$initials</div> | |
| 2335 | <div class="eyebrow"><span class="pulse"></span>$name · brief pause</div> | |
| 2336 | <h1>We'll be right back.</h1> | |
| 2337 | <p>This storefront is taking a short break for maintenance. Nothing's wrong — we're just tidying things up.</p> | |
| 2338 | <p>Please check back shortly. Thanks for your patience.</p> | |
| 2339 | <div class="foot">Powered by repricer.com</div> | |
| 2340 | </div> | |
| 2341 | </body> | |
| 2342 | </html> | |
| 2343 | MAINT | |
| 2344 | } | |
| 2345 | ||
| 2346 | sub _placeholder_products { | |
| 2347 | # Neutral SVG placeholder so empty storefronts show clean gray cards | |
| 2348 | # instead of borrowed stock art. Same data URL as the live path. | |
| 2349 | my $placeholder_img = | |
| 2350 | "data:image/svg+xml;utf8," | |
| 2351 | . "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 800 600'>" | |
| 2352 | . "<defs><linearGradient id='g' x1='0' x2='1' y1='0' y2='1'>" | |
| 2353 | . "<stop offset='0' stop-color='%23374151'/>" | |
| 2354 | . "<stop offset='0.55' stop-color='%231f2937'/>" | |
| 2355 | . "<stop offset='1' stop-color='%23111827'/>" | |
| 2356 | . "</linearGradient></defs>" | |
| 2357 | . "<rect width='800' height='600' fill='url(%23g)'/>" | |
| 2358 | . "<g opacity='0.20' fill='none' stroke='%23ffffff' stroke-width='2'>" | |
| 2359 | . "<rect x='300' y='200' width='200' height='200' rx='12'/>" | |
| 2360 | . "<circle cx='370' cy='270' r='22'/>" | |
| 2361 | . "<polyline points='310,390 380,330 440,365 500,310'/>" | |
| 2362 | . "</g></svg>"; | |
| 2363 | ||
| 2364 | my @titles = ( | |
| 2365 | 'Your first design', 'Add your second model', 'Add your third model', | |
| 2366 | 'Add another model', 'Add another model', 'Add another model', | |
| 2367 | 'Add another model', 'Add another model', 'Add another model', | |
| 2368 | 'Add another model', 'Add another model', 'Add another model', | |
| 2369 | ); | |
| 2370 | my @prices = ('','','','','','','','','','','',''); | |
| 2371 | my @list; | |
| 2372 | for (my $i = 0; $i < @titles; $i++) { | |
| 2373 | my $idx = $i + 1; | |
| 2374 | push @list, { | |
| 2375 | id => $idx, | |
| 2376 | title => $titles[$i], | |
| 2377 | price => $prices[$i], | |
| 2378 | rating => '', | |
| 2379 | rating_n => 0, | |
| 2380 | image_idx => $idx, | |
| 2381 | # Same shape as real product hashes so layouts can reference | |
| 2382 | # $featured_product.hero_image / hero_image_pos directly | |
| 2383 | # without conditionals. Real models override these via | |
| 2384 | # m.hero_image_url + m.hero_image_pos in the SELECT. | |
| 2385 | hero_image => $placeholder_img, | |
| 2386 | hero_image_url => '', | |
| 2387 | hero_image_pos => '50% 50%', | |
| 2388 | is_first => ($i == 0) ? 1 : 0, | |
| 2389 | is_placeholder => 1, | |
| 2390 | rating_html => qq~<span class="pill warning">\x{2605} 4.8</span>~, | |
| 2391 | title_plain => $titles[$i], | |
| 2392 | product_kind => 'digital', | |
| 2393 | kind_badge_html => '', | |
| 2394 | }; | |
| 2395 | } | |
| 2396 | return @list; | |
| 2397 | } | |
| 2398 | ||
| 2399 | sub _demo_tvars { | |
| 2400 | my @demo_products = _placeholder_products(); | |
| 2401 | ||
| 2402 | my $themes = MODS::RePricer::Themes->new; | |
| 2403 | my $layouts = MODS::RePricer::Layouts->new; | |
| 2404 | my $theme = $themes->by_id(1); | |
| 2405 | my $layout = $layouts->by_id(1); | |
| 2406 | ||
| 2407 | return { | |
| 2408 | is_demo => 1, | |
| 2409 | preview_banner => '', | |
| 2410 | store_id => 0, | |
| 2411 | store_name => 'Demo Storefront', | |
| 2412 | store_subdomain => 'demo', | |
| 2413 | store_initials => 'DS', | |
| 2414 | store_url => 'demo.repricer.com', | |
| 2415 | store_tagline => 'Pre-supported tabletop minis. Print-tested. Battle-ready.', | |
| 2416 | store_about => 'A sample storefront built with RePricer. Hand-sculpted, pre-supported, layout-tested designs ready to print on FDM or resin. This is what every creator\'s store looks like out of the box.', | |
| 2417 | theme_css_vars => $themes->css_vars($theme), | |
| 2418 | theme_name => $theme->{name}, | |
| 2419 | layout_template => $layouts->template_for($layout->{id}), | |
| 2420 | layout_name => $layout->{name}, | |
| 2421 | has_products => 1, | |
| 2422 | product_count => scalar(@demo_products), | |
| 2423 | products => \@demo_products, | |
| 2424 | has_featured => 1, | |
| 2425 | featured_product => $demo_products[0], | |
| 2426 | secondary_products => [ @demo_products[1..$#demo_products] ], | |
| 2427 | has_secondary => 1, | |
| 2428 | nav_pages => [], | |
| 2429 | has_nav_pages => 0, | |
| 2430 | cart_count => 0, | |
| 2431 | has_cart_items => 0, | |
| 2432 | cart_href => '#', | |
| 2433 | marketplace_links => [], | |
| 2434 | has_marketplace_links => 0, | |
| 2435 | }; | |
| 2436 | } | |
| 2437 | ||
| 2438 | # Pull 1-2 character initials out of a store name for the brand-mark icon. | |
| 2439 | # "Demo Studio" -> "DS", "3DShawn" -> "3D", "Workshop" -> "WO" | |
| 2440 | sub _store_initials { | |
| 2441 | my ($name) = @_; | |
| 2442 | return '?' unless defined $name; | |
| 2443 | $name =~ s/^\s+|\s+$//g; | |
| 2444 | return '?' unless length $name; | |
| 2445 | my @parts = grep { length } split /[\s\-_\.]+/, $name; | |
| 2446 | if (@parts >= 2) { | |
| 2447 | return uc(substr($parts[0], 0, 1) . substr($parts[1], 0, 1)); | |
| 2448 | } | |
| 2449 | return uc(substr($name, 0, 2)); | |
| 2450 | } |