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

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

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

Added
+663
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to ec9f69a9de84
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# WebSTLs -- public model detail page (buyer-facing).
4#
5# /listing_details.cgi?id=<model_id>
6# Renders the full product detail experience for one digital model:
7# hero image, gallery thumbnails, description / files / reviews tabs,
8# sticky purchase card with designer info, rating, price, Add-to-Cart.
9#
10# Resolution:
11# - Looks up the model by id.
12# - Verifies it's status='published', visible on a live storefront
13# listing, and not purged. Anything else 404s so buyers can't deep
14# link to drafts or trashed items.
15# - Loads all model_images for the gallery, model_files for the
16# "Files Included" tab, model_tags for the chips, plus the parent
17# storefront row so the page renders with the seller's branding.
18#
19# Scope: digital downloads only for now. The template is structured so
20# adding physical-print fields later is additive (a new "Buy a Print"
21# action in the purchase card + a Shipping tab) without rewriting.
22#======================================================================
23use strict;
24use warnings;
25
26use lib '/var/www/vhosts/webstls.com/httpdocs';
27use CGI;
28use MODS::Template;
29use MODS::DBConnect;
30use MODS::Login;
31use MODS::WebSTLs::Config;
32use MODS::WebSTLs::Wrapper;
33use MODS::WebSTLs::Themes;
34
35my $q = CGI->new;
36my $form = $q->Vars;
37my $tfile = MODS::Template->new;
38my $db = MODS::DBConnect->new;
39my $auth = MODS::Login->new;
40my $config = MODS::WebSTLs::Config->new;
41my $wrap = MODS::WebSTLs::Wrapper->new;
42my $DB = $config->settings('database_name');
43
44$| = 1;
45
46# Optional creator session -- powers a "previewing your own listing"
47# affordance later, otherwise unused on this buyer-facing page.
48my $userinfo = $auth->login_verify();
49
50# ---- ID validation -------------------------------------------------
51my $model_id = $form->{id} || '';
52$model_id =~ s/[^0-9]//g;
53unless (length $model_id) {
54 _not_found($wrap, 'No listing id provided.');
55 exit;
56}
57
58my $dbh = $db->db_connect();
59
60# Probe whether the physical-product columns exist on this install.
61# A missing column would crash db_readwrite -> DBConnect's error()
62# helper calls exit() -> bare 500 for the buyer. On legacy installs
63# that haven't run the physical-products migration we degrade
64# gracefully to digital-only rendering.
65my $have_physical_cols = 0;
66{
67 my $r = $db->db_readwrite($dbh, qq~
68 SELECT COUNT(*) AS n FROM information_schema.columns
69 WHERE table_schema='$DB' AND table_name='models'
70 AND column_name='product_kind'
71 ~, $ENV{SCRIPT_NAME}, __LINE__);
72 $have_physical_cols = 1 if $r && $r->{n};
73}
74
75# ---- Model row + storefront join -----------------------------------
76# Join storefront_listings + storefronts so a single query verifies
77# everything: published model, live storefront, visible listing row,
78# not purged. Anything missing -> 404.
79my $phys_select = $have_physical_cols
80 ? q~m.product_kind, m.physical_price_cents, m.inventory_qty,
81 m.weight_grams, m.dim_length_mm, m.dim_width_mm, m.dim_height_mm,
82 m.production_time_days, m.ship_from_country, m.ship_from_postal_code,
83 m.shipping_options_json, m.color_options, m.material_options,~
84 : '';
85my $row = $db->db_readwrite($dbh, qq~
86 SELECT m.id, m.user_id, m.title, m.slug, m.tagline, m.description,
87 m.base_price_cents, m.currency, m.category, m.license_type,
88 m.status, m.hero_image_url, m.hero_image_pos,
89 m.total_downloads, m.rating_avg, m.rating_count,
90 m.highlights, m.included_items, m.print_settings_json,
91 $phys_select
92 m.source_platform, m.source_url,
93 m.created_at, m.updated_at,
94 sl.override_price_cents, sl.storefront_id,
95 s.name AS store_name, s.subdomain AS store_subdomain,
96 s.tagline AS store_tagline, s.user_id AS store_user_id,
97 s.status AS store_status, s.theme_id AS store_theme_id,
98 s.custom_theme_id AS store_custom_theme_id,
99 u.display_name AS designer_name
100 FROM `${DB}`.models m
101 JOIN `${DB}`.storefront_listings sl ON sl.model_id = m.id
102 JOIN `${DB}`.storefronts s ON s.id = sl.storefront_id
103 JOIN `${DB}`.users u ON u.id = m.user_id
104 WHERE m.id = '$model_id'
105 AND m.status = 'published'
106 AND m.purged_at IS NULL
107 AND sl.visible = 1
108 AND s.status = 'live'
109 LIMIT 1
110~, $ENV{SCRIPT_NAME}, __LINE__);
111
112unless ($row && $row->{id}) {
113 $db->db_disconnect($dbh);
114 _not_found($wrap, "We couldn't find that listing -- it may have been removed or unpublished.");
115 exit;
116}
117
118# Preview-mode banner. Same trigger as store.cgi: a logged-in owner
119# viewing their OWN listing sees the "this is how buyers see it"
120# strip until they click View-as-buyer. Buyers without a session
121# never see it -- the banner is tied to the owner-preview state, not
122# to the page or layout, so adding new layouts / pages doesn't change
123# whether the banner shows.
124my $ld_preview_banner = '';
125{
126 my $is_owner = ($userinfo && $userinfo->{user_id}
127 && $row->{store_user_id}
128 && $userinfo->{user_id} eq $row->{store_user_id}) ? 1 : 0;
129 if ($is_owner && !$form->{as_visitor} && !$form->{editor_live} && !$form->{heatmap_view}) {
130 my $here_path = $ENV{SCRIPT_NAME} || '/listing_details.cgi';
131 my $hereqs = $ENV{QUERY_STRING} || '';
132 my $as_visitor_href = $here_path . ($hereqs ? "?$hereqs&as_visitor=1" : '?as_visitor=1');
133 $ld_preview_banner = qq~Preview mode &mdash; this is how buyers see your storefront. <a href="$as_visitor_href">View as buyer &rarr;</a> &middot; <a href="/storefront.cgi">Edit &rarr;</a>~;
134 }
135}
136
137# ---- Gallery images ------------------------------------------------
138# Hero first (is_primary=1), then everything else by sort_order. We
139# render via /img.cgi?m=<id> which has the auth + safe-serve plumbing.
140my @img_rows = $db->db_readwrite_multiple($dbh, qq~
141 SELECT id, image_type, sort_order, is_primary, alt_text
142 FROM `${DB}`.model_images
143 WHERE model_id = '$model_id'
144 ORDER BY is_primary DESC, sort_order ASC, id ASC
145~, $ENV{SCRIPT_NAME}, __LINE__);
146
147my @gallery;
148my $hero_url = '';
149my $i = 0;
150foreach my $im (@img_rows) {
151 my $url = "/img.cgi?m=" . $im->{id};
152 $hero_url = $url unless $hero_url;
153 push @gallery, {
154 idx => ++$i,
155 url => $url,
156 alt => _t($im->{alt_text} || $row->{title}),
157 is_active => ($i == 1) ? 1 : 0,
158 };
159}
160
161# Fall back to the models.hero_image_url if there are no model_images
162# rows yet (some imports land with the URL set but no image row).
163if (!@gallery && $row->{hero_image_url}) {
164 $hero_url = $row->{hero_image_url};
165 push @gallery, {
166 idx => 1,
167 url => $hero_url,
168 alt => _t($row->{title}),
169 is_active => 1,
170 };
171}
172
173# Last-ditch fallback to the neutral placeholder so the page never
174# renders with a broken <img>.
175unless ($hero_url) {
176 $hero_url = '/assets/no-cover.svg';
177 push @gallery, {
178 idx => 1,
179 url => $hero_url,
180 alt => 'No cover image yet',
181 is_active => 1,
182 };
183}
184
185# ---- Files included ------------------------------------------------
186# Powers the "Files Included" tab + the per-row file-count meta entry.
187# Sizes are rendered human-readable (KB / MB / GB).
188my @file_rows = $db->db_readwrite_multiple($dbh, qq~
189 SELECT id, filename, file_type, file_size_bytes
190 FROM `${DB}`.model_files
191 WHERE model_id = '$model_id'
192 ORDER BY id ASC
193~, $ENV{SCRIPT_NAME}, __LINE__);
194
195my @files;
196my $total_bytes = 0;
197foreach my $f (@file_rows) {
198 my $size_n = $f->{file_size_bytes} + 0;
199 $total_bytes += $size_n;
200 push @files, {
201 name => _t($f->{filename} || 'unnamed'),
202 ext => uc($f->{file_type} || 'FILE'),
203 size_human => _human_bytes($size_n),
204 fmt_class => _fmt_class($f->{file_type} || ''),
205 };
206}
207my $file_count = scalar @files;
208my $total_size_human = _human_bytes($total_bytes);
209
210# ---- Tags ----------------------------------------------------------
211my @tag_rows = $db->db_readwrite_multiple($dbh, qq~
212 SELECT tag FROM `${DB}`.model_tags WHERE model_id = '$model_id'
213 ORDER BY tag ASC
214~, $ENV{SCRIPT_NAME}, __LINE__);
215my @tags;
216foreach my $t (@tag_rows) {
217 next unless $t->{tag} && length $t->{tag};
218 push @tags, { name => _t($t->{tag}) };
219}
220
221# ---- Highlights + included items (newline-joined text columns) -----
222my @highlights;
223foreach my $line (split /\r?\n/, ($row->{highlights} || '')) {
224 $line =~ s/^\s+|\s+$//g;
225 next unless length $line;
226 push @highlights, { text => _t($line) };
227 last if scalar(@highlights) >= 8;
228}
229my @included;
230foreach my $line (split /\r?\n/, ($row->{included_items} || '')) {
231 $line =~ s/^\s+|\s+$//g;
232 next unless length $line;
233 push @included, { text => _t($line) };
234 last if scalar(@included) >= 16;
235}
236
237# ---- Print settings (key=val newline-joined) -----------------------
238# Both label + value are user-supplied free text -- template-safe escape
239# on both. Values like "215 - 225C" or "60mm/s" are common but a stray
240# "$5" in there would otherwise crash the eval.
241my @print_settings;
242foreach my $line (split /\r?\n/, ($row->{print_settings_json} || '')) {
243 $line =~ s/^\s+|\s+$//g;
244 next unless $line =~ /^([^=]+)=(.+)$/;
245 my ($k, $v) = ($1, $2);
246 $k =~ s/^\s+|\s+$//g; $v =~ s/^\s+|\s+$//g;
247 next unless length $k && length $v;
248 push @print_settings, { label => _t(_humanize_key($k)), value => _t($v) };
249 last if scalar(@print_settings) >= 12;
250}
251
252# ---- Related models from the SAME storefront -----------------------
253# Limit 6 -- enough for a "more from this designer" strip without
254# making the buyer scroll forever. Excludes the current model.
255my @rel_rows = $db->db_readwrite_multiple($dbh, qq~
256 SELECT m.id, m.title, m.slug, m.base_price_cents, m.currency,
257 m.hero_image_url, m.rating_avg, m.rating_count,
258 (
259 SELECT mi.id FROM `${DB}`.model_images mi
260 WHERE mi.model_id = m.id
261 ORDER BY mi.is_primary DESC, mi.sort_order ASC, mi.id ASC
262 LIMIT 1
263 ) AS primary_image_id
264 FROM `${DB}`.storefront_listings sl
265 JOIN `${DB}`.models m ON m.id = sl.model_id
266 WHERE sl.storefront_id = '$row->{storefront_id}'
267 AND sl.visible = 1
268 AND m.status = 'published'
269 AND m.id <> '$model_id'
270 AND m.purged_at IS NULL
271 ORDER BY sl.sort_order ASC, m.created_at DESC
272 LIMIT 6
273~, $ENV{SCRIPT_NAME}, __LINE__);
274my @related;
275foreach my $r (@rel_rows) {
276 my $img = $r->{hero_image_url}
277 || ($r->{primary_image_id} ? "/img.cgi?m=$r->{primary_image_id}" : '/assets/no-cover.svg');
278 my $cents = $r->{base_price_cents} || 0;
279 # Title is user-supplied -- template-safe escape so a model called
280 # e.g. "$25 bundle" doesn't crash the related-models loop. The
281 # price string itself goes through $tvars (hash-substituted at eval
282 # time), so its literal "$" is NOT re-parsed by qq~~ -- no escape.
283 my $price_text = $cents > 0 ? '$' . sprintf('%.2f', $cents / 100) : 'Free';
284 push @related, {
285 id => $r->{id},
286 title => _t($r->{title}),
287 hero => $img,
288 price_label => $price_text,
289 is_free => $cents > 0 ? 0 : 1,
290 rating => $r->{rating_avg} ? sprintf('%.1f', $r->{rating_avg}) : '',
291 rating_n => $r->{rating_count} || 0,
292 };
293}
294
295# Storefront nav pages -- same query the catalog layout uses to build
296# the header nav. Runs BEFORE db_disconnect so we still have a live
297# handle. Result is consumed below by the header in the template.
298my @nav_pages_raw = $db->db_readwrite_multiple($dbh, qq~
299 SELECT slug, title, page_type
300 FROM `${DB}`.storefront_pages
301 WHERE storefront_id='$row->{storefront_id}'
302 AND status='published'
303 AND page_type != 'home'
304 ORDER BY page_type ASC, title ASC
305 LIMIT 20
306~, $ENV{SCRIPT_NAME}, __LINE__);
307my @nav_pages;
308foreach my $p (@nav_pages_raw) {
309 my $slug = $p->{slug} || '';
310 $slug =~ s/[^a-z0-9\-_]//g;
311 next unless length $slug;
312 push @nav_pages, {
313 title => _t($p->{title} || ucfirst($slug)),
314 href => "/store.cgi?id=$row->{storefront_id}&page=$slug",
315 };
316}
317
318$db->db_disconnect($dbh);
319
320# ---- Price + display fields ----------------------------------------
321my $price_cents = defined $row->{override_price_cents} && $row->{override_price_cents} ne ''
322 ? $row->{override_price_cents}
323 : ($row->{base_price_cents} || 0);
324my $price_label = $price_cents > 0 ? '$' . sprintf('%.2f', $price_cents / 100) : 'Free';
325my $is_free = $price_cents > 0 ? 0 : 1;
326
327# ---- Product kind + variant options --------------------------------
328# Build the per-kind metadata buyers need to choose between Digital
329# Download and Printed Copy on a 'both' listing, or just see what's on
330# offer for a kind=physical-only listing. All fields collapse to safe
331# defaults on a legacy install where the columns don't exist.
332my $kind = ($have_physical_cols && $row->{product_kind}) ? $row->{product_kind} : 'digital';
333my $phys_cents = $row->{physical_price_cents} || 0;
334my $phys_label = $phys_cents > 0 ? '$' . sprintf('%.2f', $phys_cents / 100) : 'Free';
335# inventory: -1 = print-to-order, 0 = sold out, >0 = stock on hand
336my $inv_qty = defined $row->{inventory_qty} ? $row->{inventory_qty} : -1;
337my $is_sold_out = ($kind ne 'digital' && $inv_qty == 0) ? 1 : 0;
338my $stock_label = ($inv_qty < 0) ? 'Print-to-order'
339 : ($inv_qty == 0) ? 'Sold out'
340 : ($inv_qty == 1) ? '1 left in stock'
341 : "$inv_qty in stock";
342my $prod_days = $row->{production_time_days} || 0;
343my $prod_label = $prod_days > 0 ? "Ships in ~$prod_days days" : 'Ships when ready';
344
345# Comma-separated color/material option lists -> arrays for the
346# template's [loop:] iterators on the variant chooser.
347my @colors;
348foreach my $c (split /,/, ($row->{color_options} || '')) {
349 $c =~ s/^\s+|\s+$//g;
350 next unless length $c;
351 push @colors, { value => _h($c), label => _t($c) };
352}
353my @materials;
354foreach my $m (split /,/, ($row->{material_options} || '')) {
355 $m =~ s/^\s+|\s+$//g;
356 next unless length $m;
357 push @materials, { value => _h($m), label => _t($m) };
358}
359
360# Ship-from country -> friendly label for the badge under the
361# "Buy printed copy" section.
362my $ship_from = $row->{ship_from_country} || '';
363$ship_from = uc(substr($ship_from, 0, 2));
364my $ship_from_label = $ship_from ? "Ships from $ship_from" : '';
365
366my $rating_num = $row->{rating_avg} ? sprintf('%.1f', $row->{rating_avg}) : '';
367my $rating_n = $row->{rating_count} || 0;
368my $rating_stars = $rating_num
369 ? ('&#9733;' x int($rating_num + 0.5)) . ('&#9734;' x (5 - int($rating_num + 0.5)))
370 : '';
371
372# Updated date in friendly format ("May 2026") + raw for the meta row.
373my $updated_pretty = '';
374if ($row->{updated_at} && $row->{updated_at} =~ /^(\d{4})-(\d{2})/) {
375 my @mn = ('','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec');
376 $updated_pretty = "$mn[int($2)] $1";
377}
378
379# Designer first initial (or store name first letter) for the avatar.
380my $designer_initial = uc(substr($row->{designer_name} || $row->{store_name} || 'D', 0, 1));
381
382# Source link badge -- shown only for marketplace imports so buyers
383# know the model originated elsewhere. Cosmetic; doesn't gate purchase.
384my $source_link = '';
385my $source_label = '';
386if ($row->{source_platform} && $row->{source_url}) {
387 my $plat = lc($row->{source_platform});
388 $source_label = $plat eq 'cults3d' ? 'Cults3D'
389 : $plat eq 'thangs' ? 'Thangs'
390 : ucfirst($plat);
391 $source_link = $row->{source_url};
392}
393
394# ---- Storefront theme --------------------------------------------
395# Resolve the storefront's theme so this page picks up the same
396# --col-* CSS variables that the catalog page uses (incl. any custom
397# theme the user has built and applied).
398my $themes = MODS::WebSTLs::Themes->new;
399my $theme_lookup_store = {
400 theme_id => $row->{store_theme_id} || 1,
401 custom_theme_id => $row->{store_custom_theme_id},
402};
403my $theme = $themes->resolve_for_storefront($theme_lookup_store, $db, $dbh, $DB);
404my $theme_css_vars = $themes->css_vars($theme);
405
406my $store_initials = uc(substr($row->{store_name} || $row->{store_subdomain} || '?', 0, 1));
407
408# ---- Build template vars -------------------------------------------
409my $tvars = {
410 theme_css_vars => $theme_css_vars,
411 # Model basics. Anything that started as free-form user text uses
412 # _t (template-safe + HTML escape). Anything that's a system-
413 # controlled value (URLs, ids, slugs) uses _h.
414 model_id => $row->{id},
415 title => _t($row->{title}),
416 tagline => _t($row->{tagline} || ''),
417 has_tagline => ($row->{tagline} && length $row->{tagline}) ? 1 : 0,
418 description => _description_html($row->{description} || ''),
419 has_description => ($row->{description} && length $row->{description}) ? 1 : 0,
420 category => _t($row->{category} || ''),
421 has_category => ($row->{category} && length $row->{category}) ? 1 : 0,
422 license => _t($row->{license_type} || 'Standard digital license'),
423
424 # Images
425 hero_url => $hero_url,
426 hero_pos => $row->{hero_image_pos} || '50% 50%',
427 gallery => \@gallery,
428 has_gallery => scalar(@gallery) > 1 ? 1 : 0,
429 gallery_count => scalar(@gallery),
430
431 # Files
432 files => \@files,
433 file_count => $file_count,
434 has_files => $file_count > 0 ? 1 : 0,
435 total_size => $total_size_human,
436
437 # Tags
438 tags => \@tags,
439 has_tags => scalar(@tags) > 0 ? 1 : 0,
440
441 # Highlights / included items / print settings
442 highlights => \@highlights,
443 has_highlights => scalar(@highlights) > 0 ? 1 : 0,
444 included => \@included,
445 has_included => scalar(@included) > 0 ? 1 : 0,
446 print_settings => \@print_settings,
447 has_print_settings => scalar(@print_settings) > 0 ? 1 : 0,
448
449 # Price + ratings
450 price_label => $price_label,
451 is_free => $is_free,
452 is_paid => $is_free ? 0 : 1,
453
454 # Product kind + variant chooser
455 product_kind => $kind,
456 is_kind_digital => ($kind eq 'digital') ? 1 : 0,
457 is_kind_physical => ($kind eq 'physical') ? 1 : 0,
458 is_kind_both => ($kind eq 'both') ? 1 : 0,
459 offers_physical => ($kind ne 'digital') ? 1 : 0,
460 offers_digital => ($kind ne 'physical') ? 1 : 0,
461 phys_price_label => $phys_label,
462 phys_price_cents => $phys_cents,
463 is_sold_out => $is_sold_out,
464 stock_label => _t($stock_label),
465 prod_label => _t($prod_label),
466 ship_from_label => _t($ship_from_label),
467 has_ship_from => length $ship_from_label ? 1 : 0,
468 color_choices => \@colors,
469 has_color_choices => scalar(@colors) ? 1 : 0,
470 material_choices => \@materials,
471 has_material_choices=> scalar(@materials) ? 1 : 0,
472 rating_num => $rating_num,
473 rating_n => $rating_n,
474 rating_stars => $rating_stars,
475 has_rating => $rating_num ? 1 : 0,
476
477 # Stats
478 total_downloads => $row->{total_downloads} || 0,
479
480 # Storefront context. Names + taglines came from the seller --
481 # template-safe escape required.
482 store_id => $row->{storefront_id},
483 store_name => _t($row->{store_name} || 'Storefront'),
484 store_initials => _t($store_initials),
485 store_subdomain => _h($row->{store_subdomain} || ''),
486 store_tagline => _t($row->{store_tagline} || ''),
487 designer_name => _t($row->{designer_name} || $row->{store_name} || 'Designer'),
488 designer_initial=> $designer_initial,
489 nav_pages => \@nav_pages,
490 has_nav_pages => scalar(@nav_pages) ? 1 : 0,
491
492 # Source provenance
493 has_source => ($source_link && length $source_link) ? 1 : 0,
494 source_label => _t($source_label),
495 source_url => $source_link,
496
497 # Related models
498 related => \@related,
499 has_related => scalar(@related) > 0 ? 1 : 0,
500
501 # Misc
502 updated_pretty => $updated_pretty,
503 has_updated => length $updated_pretty ? 1 : 0,
504 breadcrumb_cat => _t($row->{category} || 'Catalog'),
505};
506
507# Render body + wrap with the marketing shell so the storefront topbar
508# / footer / SEO meta are consistent with store.cgi.
509my $body = join('', $tfile->template('webstls_listing_details.html', $tvars, undef));
510
511# Storefront header chrome (brand + search + sign in + cart). Built
512# inline here so listing_details renders the same header as every
513# store.cgi layout. Previous version used floating glassmorphic pills
514# (position:fixed) with `href="/cart.cgi"` (no storefront id) -- the
515# cart link redirected to the storefront home because cart.cgi can't
516# resolve a storefront without id.
517{
518 require MODS::WebSTLs::StoreChrome;
519 require MODS::WebSTLs::BuyerAuth;
520 require MODS::WebSTLs::Cart;
521 my $chrome_dbh = $db->db_connect();
522 my $ba_h = MODS::WebSTLs::BuyerAuth->new;
523 my $buyer_h = $ba_h->verify($q, $db, $chrome_dbh, $DB);
524 my $is_buyer_h = ($buyer_h && $buyer_h->{id}) ? 1 : 0;
525 my $cart_h = MODS::WebSTLs::Cart->new;
526 my $cart_tok_h = $cart_h->read_token($q);
527 my $cart_n_h = $cart_tok_h
528 ? $cart_h->count($db, $chrome_dbh, $DB, $cart_tok_h, $tvars->{store_id})
529 : 0;
530 $body = MODS::WebSTLs::StoreChrome->header_html({
531 store_id => $tvars->{store_id} || 0,
532 store_name => $tvars->{store_name} || '',
533 store_initials => $tvars->{store_initials} || '',
534 is_buyer_in => $is_buyer_h,
535 cart_count => $cart_n_h,
536 # Let StoreChrome run its own nav query -- the local @nav_pages
537 # builder above is missing the show_in_menu=1 filter and the
538 # menu_label override, so passing it produces too many links
539 # (A/B variants + the listing_details product page slip in).
540 db => $db,
541 dbh => $chrome_dbh,
542 DB => $DB,
543 }) . $body;
544 $db->db_disconnect($chrome_dbh);
545}
546
547# Preview banner -- same sticky strip the storefront layouts render,
548# prepended to the body so it appears above the listing chrome. Only
549# present when the owner-preview check above set the variable.
550if ($ld_preview_banner ne '') {
551 my $pb = qq~<div id="store-preview-banner" style="position:sticky;top:0;z-index:9999;background:#0a0e1c;border-bottom:1px solid rgba(59,130,246,0.40);box-shadow:0 4px 16px rgba(0,0,0,0.55);padding:10px 20px;text-align:center;font-size:13px;color:#93c5fd;font-weight:600">$ld_preview_banner</div>~;
552 $body = $pb . $body;
553}
554
555print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
556
557$wrap->render({
558 userinfo => undef,
559 title => ($row->{title} || 'Model') . " - " . ($row->{store_name} || 'WebSTLs'),
560 body => $body,
561});
562
563exit;
564
565
566#======================================================================
567# Helpers
568#======================================================================
569
570# HTML-escape only. Safe for attribute values + URLs that the template
571# engine should NOT try to interpolate further (URL params with $ are
572# rare and stripped before storage).
573sub _h {
574 my $s = shift; $s = '' unless defined $s;
575 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
576 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
577 return $s;
578}
579
580# Template-safe escape for ANY user-supplied free text. Layers on top
581# of _h(): a literal "$24" or "$1,200" in a model description would
582# otherwise hit the template engine's qq~~ eval and either crash with
583# "syntax error near <number>" or silently interpolate into garbage
584# ($2 reads as the last regex capture, etc.). Same for "@list" at the
585# start of a token. We escape both with leading backslashes so they
586# pass through as literal characters.
587sub _t {
588 my $s = shift; $s = '' unless defined $s;
589 $s = _h($s);
590 $s =~ s/\$/\\\$/g;
591 $s =~ s/\@/\\\@/g;
592 return $s;
593}
594
595# Convert plain-text description (with paragraph breaks) into safe
596# HTML. Newlines become <br>, blank lines become paragraph breaks.
597# Uses _t for template-safety -- descriptions are the most common
598# place to find "$24.99" / "$1,200.00" / similar that would otherwise
599# blow up the qq~~ eval.
600sub _description_html {
601 my $s = shift; $s = '' unless defined $s;
602 return '' unless length $s;
603 $s = _t($s);
604 # Paragraph splits on a blank line.
605 my @paras = split /\n\s*\n/, $s;
606 my @out;
607 foreach my $p (@paras) {
608 $p =~ s/\n/<br>/g;
609 $p =~ s/^\s+|\s+$//g;
610 next unless length $p;
611 push @out, "<p>$p</p>";
612 }
613 return join("\n", @out);
614}
615
616# Pretty-print byte counts for file size displays. ~3.4 MB, etc.
617sub _human_bytes {
618 my $n = shift; $n = 0 unless defined $n;
619 return '0 B' unless $n > 0;
620 if ($n >= 1024 * 1024 * 1024) { return sprintf('%.1f GB', $n / 1024 / 1024 / 1024); }
621 if ($n >= 1024 * 1024) { return sprintf('%.1f MB', $n / 1024 / 1024); }
622 if ($n >= 1024) { return sprintf('%.1f KB', $n / 1024); }
623 return "$n B";
624}
625
626# Map file extension to a CSS class for the colored "STL" / "3MF" etc.
627# format chip in the files table.
628sub _fmt_class {
629 my $ext = lc(shift || '');
630 return 'dp-fmt-stl' if $ext eq 'stl';
631 return 'dp-fmt-3mf' if $ext eq '3mf';
632 return 'dp-fmt-step' if $ext eq 'step' || $ext eq 'stp';
633 return 'dp-fmt-obj' if $ext eq 'obj';
634 return 'dp-fmt-zip' if $ext eq 'zip' || $ext eq 'rar';
635 return 'dp-fmt-other';
636}
637
638# print_settings_json column stores key=val rows. The keys are usually
639# snake_case (layer_height, infill_density). This humanizes them for
640# display ("Layer Height", "Infill Density").
641sub _humanize_key {
642 my $k = shift;
643 $k =~ s/[_\-]+/ /g;
644 $k =~ s/\b(\w)/\U$1/g;
645 return $k;
646}
647
648sub _not_found {
649 my ($wrap, $msg) = @_;
650 print "Status: 404 Not Found\nContent-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
651 my $body = qq~
652 <section style="padding:80px 24px;text-align:center">
653 <h1 style="font-family:var(--font-display);font-size:42px;margin:0 0 10px;color:#fff">Listing not found</h1>
654 <p style="color:#94a3b8;font-size:16px;max-width:520px;margin:0 auto 24px">~ . _h($msg) . qq~</p>
655 <a href="/" style="display:inline-block;padding:12px 22px;background:linear-gradient(135deg,#3b82f6,#7c3aed);color:#fff;text-decoration:none;border-radius:10px;font-weight:600">&larr; Browse other storefronts</a>
656 </section>
657 ~;
658 $wrap->render({
659 userinfo => undef,
660 title => 'Listing not found',
661 body => $body,
662 });
663}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help