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

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

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

Added
+2450
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 6f0b9deedd6e
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# ShopCart — 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#======================================================================
17use strict;
18use warnings;
19
20use lib '/var/www/vhosts/3dshawn.com/shop.3dshawn.com';
21use CGI;
22use MODS::Template;
23use MODS::DBConnect;
24use MODS::Login;
25use MODS::ShopCart::Config;
26use MODS::ShopCart::Wrapper;
27use MODS::ShopCart::Themes;
28use MODS::ShopCart::Layouts;
29use MODS::ShopCart::Cart;
30use MODS::ShopCart::Experiments;
31use MODS::ShopCart::BuyerAuth;
32
33my $q = CGI->new;
34my $form = $q->Vars;
35my $tfile = MODS::Template->new;
36my $db = MODS::DBConnect->new;
37my $auth = MODS::Login->new;
38my $config = MODS::ShopCart::Config->new;
39my $wrap = MODS::ShopCart::Wrapper->new;
40my $themes = MODS::ShopCart::Themes->new;
41my $layouts = MODS::ShopCart::Layouts->new;
42my $cart = MODS::ShopCart::Cart->new;
43
44my $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.
50my $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.
56my $ex_helper = MODS::ShopCart::Experiments->new;
57my $visitor_token = $ex_helper->read_visitor($q);
58my $visitor_cookie = '';
59unless ($visitor_token) {
60 ($visitor_token, $visitor_cookie) = $ex_helper->mint_visitor;
61}
62
63my $tvars = _resolve_storefront($form, $userinfo);
64
65print "Content-Type: text/html; charset=utf-8\n";
66print "Set-Cookie: $visitor_cookie\n" if $visitor_cookie;
67print "Cache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\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}.
73my $layout_template = $tvars->{layout_template} || 'store_layouts/catalog.html';
74my $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.
85if ($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/&/&amp;/g; s/"/&quot;/g; s/</&lt;/g; s/>/&gt;/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.
140my $allow_pagination = !$tvars->{is_specific_page}
141 || $tvars->{is_collection_page};
142if ($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.
155if (!$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.)
173if (!$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/&/&amp;/g; $title =~ s/</&lt;/g; $title =~ s/>/&gt;/g;
234 my $tag = $b->{tagline} || ''; $tag =~ s/&/&amp;/g; $tag =~ s/</&lt;/g; $tag =~ s/>/&gt;/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 &middot; \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/&/&amp;/g; $name =~ s/</&lt;/g; $name =~ s/>/&gt;/g;
268 my $tag = $p->{tagline} || ''; $tag =~ s/&/&amp;/g; $tag =~ s/</&lt;/g; $tag =~ s/>/&gt;/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.
335if ($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.
370if ($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; }
419section [style*="height:480px"],
420section [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,'&amp;').replace(/"/g,'&quot;');
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// ====================================================================
578document.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 = '&#9733; 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.
948my $seo_storefront = $tvars->{_storefront_row};
949if ($tvars->{is_demo}) {
950 $seo_storefront = { seo_robots_index => 0 };
951}
952
953$wrap->render({
954 userinfo => undef,
955 title => $tvars->{store_name},
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 shopcart_marketing.html for every page that uses the
960 # marketing wrapper, so we don't need to add it here explicitly.
961});
962
963exit;
964
965#======================================================================
966# Helpers
967#======================================================================
968
969sub _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 &rarr;</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} || 'ShopCart Store';
1038 $tvars->{store_subdomain}= $store->{subdomain};
1039 $tvars->{store_url} = $store->{custom_domain} || ($store->{subdomain} . '.shop.3dshawn.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 shopcart_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 &rarr;</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 &rarr;</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 &rarr;</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 || 'Items 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 items are listed and the about text is filled in, this is where buyers will see what makes the shop 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 items';
1234 $tvars->{featured_badge_label} = $store->{featured_badge_label} || 'Featured';
1235 $tvars->{section_heading} = $store->{section_heading} || 'More from this shop';
1236 $tvars->{footer_byline} = $store->{footer_byline} || 'powered by shop.3dshawn.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} || 'Featured items';
1243 $tvars->{hero_pill_label} = $store->{hero_pill_label} || 'available now';
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 seller';
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 shop';
1257 $tvars->{about_heading} = $store->{about_heading} || 'Built with care.';
1258 $tvars->{footer_trust} = $store->{footer_trust} || 'Secure checkout - Stripe - Buyer protection';
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/</&lt;/g; # the inner content is text, not HTML
1329 $esc =~ s/>/&gt;/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 &mdash; only you can see this.'
1476 : 'Preview mode &mdash; 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 &rarr;</a> &middot; <a href="/storefront.cgi">Edit &rarr;</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">&larr; Previous</a>~
1770 : qq~<span class="btn btn-secondary btn-sm" style="opacity:0.4;cursor:default">&larr; 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 &rarr;</a>~
1773 : qq~<span class="btn btn-secondary btn-sm" style="opacity:0.4;cursor:default">Next &rarr;</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 &middot; $total_products items total</div>
1791</section>
1792PAG
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/shop.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 shop';
1928 if ($n == 1) {
1929 $tvars->{store_about} = "Welcome to $sn. The first item is up -- more on the way.";
1930 } elsif ($n >= 2 && $n <= 5) {
1931 $tvars->{store_about} = "$sn has $n items available. New releases landing regularly.";
1932 } elsif ($n >= 6) {
1933 $tvars->{store_about} = "$sn has a growing catalog of $n+ items. 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 # shopcart_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::ShopCart::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/&/&amp;/g; s/"/&quot;/g; s/</&lt;/g; s/>/&gt;/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/&/&amp;/g; s/</&lt;/g; s/>/&gt;/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>&nbsp;~ : '';
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::ShopCart::Marketplaces;
2121 my $mp_registry = MODS::ShopCart::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/&/&amp;/g; $body =~ s/</&lt;/g; $body =~ s/>/&gt;/g; $body =~ s/"/&quot;/g;
2166 my $author = $r->{author_name} || 'Anonymous';
2167 $author =~ s/&/&amp;/g; $author =~ s/</&lt;/g; $author =~ s/>/&gt;/g; $author =~ s/"/&quot;/g;
2168 my $model_title = $r->{model_title} || '';
2169 $model_title =~ s/&/&amp;/g; $model_title =~ s/</&lt;/g; $model_title =~ s/>/&gt;/g; $model_title =~ s/"/&quot;/g;
2170 push @reviews, {
2171 body => $body,
2172 stars_str => ('&#9733;' 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/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/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/.
2266sub _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.
2285sub _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/&/&amp;/g; $name =~ s/</&lt;/g; $name =~ s/>/&gt;/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 &rarr;</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 &middot; 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 &middot; 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 &mdash; we're just tidying things up.</p>
2338 <p>Please check back shortly. Thanks for your patience.</p>
2339 <div class="foot">Powered by shop.3dshawn.com</div>
2340</div>
2341</body>
2342</html>
2343MAINT
2344}
2345
2346sub _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 item', 'Add your second item', 'Add your third item',
2366 'Add another item', 'Add another item', 'Add another item',
2367 'Add another item', 'Add another item', 'Add another item',
2368 'Add another item', 'Add another item', 'Add another item',
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
2399sub _demo_tvars {
2400 my @demo_products = _placeholder_products();
2401
2402 my $themes = MODS::ShopCart::Themes->new;
2403 my $layouts = MODS::ShopCart::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.shop.3dshawn.com',
2415 store_tagline => 'Demo storefront. Sell anything. Ready in minutes.',
2416 store_about => 'A sample storefront built with ShopCart. Sell physical goods, digital downloads, or anything in between. 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"
2440sub _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}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help