added on WebSTLs (webstls.com) at 2026-07-01 22:26:34
| 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 | #====================================================================== | |
| 23 | use strict; | |
| 24 | use warnings; | |
| 25 | ||
| 26 | use lib '/var/www/vhosts/webstls.com/httpdocs'; | |
| 27 | use CGI; | |
| 28 | use MODS::Template; | |
| 29 | use MODS::DBConnect; | |
| 30 | use MODS::Login; | |
| 31 | use MODS::WebSTLs::Config; | |
| 32 | use MODS::WebSTLs::Wrapper; | |
| 33 | use MODS::WebSTLs::Themes; | |
| 34 | ||
| 35 | my $q = CGI->new; | |
| 36 | my $form = $q->Vars; | |
| 37 | my $tfile = MODS::Template->new; | |
| 38 | my $db = MODS::DBConnect->new; | |
| 39 | my $auth = MODS::Login->new; | |
| 40 | my $config = MODS::WebSTLs::Config->new; | |
| 41 | my $wrap = MODS::WebSTLs::Wrapper->new; | |
| 42 | my $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. | |
| 48 | my $userinfo = $auth->login_verify(); | |
| 49 | ||
| 50 | # ---- ID validation ------------------------------------------------- | |
| 51 | my $model_id = $form->{id} || ''; | |
| 52 | $model_id =~ s/[^0-9]//g; | |
| 53 | unless (length $model_id) { | |
| 54 | _not_found($wrap, 'No listing id provided.'); | |
| 55 | exit; | |
| 56 | } | |
| 57 | ||
| 58 | my $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. | |
| 65 | my $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. | |
| 79 | my $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 | : ''; | |
| 85 | my $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 | ||
| 112 | unless ($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. | |
| 124 | my $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 — this is how buyers see your storefront. <a href="$as_visitor_href">View as buyer →</a> · <a href="/storefront.cgi">Edit →</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. | |
| 140 | my @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 | ||
| 147 | my @gallery; | |
| 148 | my $hero_url = ''; | |
| 149 | my $i = 0; | |
| 150 | foreach 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). | |
| 163 | if (!@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>. | |
| 175 | unless ($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). | |
| 188 | my @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 | ||
| 195 | my @files; | |
| 196 | my $total_bytes = 0; | |
| 197 | foreach 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 | } | |
| 207 | my $file_count = scalar @files; | |
| 208 | my $total_size_human = _human_bytes($total_bytes); | |
| 209 | ||
| 210 | # ---- Tags ---------------------------------------------------------- | |
| 211 | my @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__); | |
| 215 | my @tags; | |
| 216 | foreach 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) ----- | |
| 222 | my @highlights; | |
| 223 | foreach 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 | } | |
| 229 | my @included; | |
| 230 | foreach 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. | |
| 241 | my @print_settings; | |
| 242 | foreach 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. | |
| 255 | my @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__); | |
| 274 | my @related; | |
| 275 | foreach 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. | |
| 298 | my @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__); | |
| 307 | my @nav_pages; | |
| 308 | foreach 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 ---------------------------------------- | |
| 321 | my $price_cents = defined $row->{override_price_cents} && $row->{override_price_cents} ne '' | |
| 322 | ? $row->{override_price_cents} | |
| 323 | : ($row->{base_price_cents} || 0); | |
| 324 | my $price_label = $price_cents > 0 ? '$' . sprintf('%.2f', $price_cents / 100) : 'Free'; | |
| 325 | my $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. | |
| 332 | my $kind = ($have_physical_cols && $row->{product_kind}) ? $row->{product_kind} : 'digital'; | |
| 333 | my $phys_cents = $row->{physical_price_cents} || 0; | |
| 334 | my $phys_label = $phys_cents > 0 ? '$' . sprintf('%.2f', $phys_cents / 100) : 'Free'; | |
| 335 | # inventory: -1 = print-to-order, 0 = sold out, >0 = stock on hand | |
| 336 | my $inv_qty = defined $row->{inventory_qty} ? $row->{inventory_qty} : -1; | |
| 337 | my $is_sold_out = ($kind ne 'digital' && $inv_qty == 0) ? 1 : 0; | |
| 338 | my $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"; | |
| 342 | my $prod_days = $row->{production_time_days} || 0; | |
| 343 | my $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. | |
| 347 | my @colors; | |
| 348 | foreach 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 | } | |
| 353 | my @materials; | |
| 354 | foreach 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. | |
| 362 | my $ship_from = $row->{ship_from_country} || ''; | |
| 363 | $ship_from = uc(substr($ship_from, 0, 2)); | |
| 364 | my $ship_from_label = $ship_from ? "Ships from $ship_from" : ''; | |
| 365 | ||
| 366 | my $rating_num = $row->{rating_avg} ? sprintf('%.1f', $row->{rating_avg}) : ''; | |
| 367 | my $rating_n = $row->{rating_count} || 0; | |
| 368 | my $rating_stars = $rating_num | |
| 369 | ? ('★' x int($rating_num + 0.5)) . ('☆' x (5 - int($rating_num + 0.5))) | |
| 370 | : ''; | |
| 371 | ||
| 372 | # Updated date in friendly format ("May 2026") + raw for the meta row. | |
| 373 | my $updated_pretty = ''; | |
| 374 | if ($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. | |
| 380 | my $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. | |
| 384 | my $source_link = ''; | |
| 385 | my $source_label = ''; | |
| 386 | if ($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). | |
| 398 | my $themes = MODS::WebSTLs::Themes->new; | |
| 399 | my $theme_lookup_store = { | |
| 400 | theme_id => $row->{store_theme_id} || 1, | |
| 401 | custom_theme_id => $row->{store_custom_theme_id}, | |
| 402 | }; | |
| 403 | my $theme = $themes->resolve_for_storefront($theme_lookup_store, $db, $dbh, $DB); | |
| 404 | my $theme_css_vars = $themes->css_vars($theme); | |
| 405 | ||
| 406 | my $store_initials = uc(substr($row->{store_name} || $row->{store_subdomain} || '?', 0, 1)); | |
| 407 | ||
| 408 | # ---- Build template vars ------------------------------------------- | |
| 409 | my $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. | |
| 509 | my $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. | |
| 550 | if ($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 | ||
| 555 | print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n"; | |
| 556 | ||
| 557 | $wrap->render({ | |
| 558 | userinfo => undef, | |
| 559 | title => ($row->{title} || 'Model') . " - " . ($row->{store_name} || 'WebSTLs'), | |
| 560 | body => $body, | |
| 561 | }); | |
| 562 | ||
| 563 | exit; | |
| 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). | |
| 573 | sub _h { | |
| 574 | my $s = shift; $s = '' unless defined $s; | |
| 575 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 576 | $s =~ s/"/"/g; $s =~ s/'/'/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. | |
| 587 | sub _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. | |
| 600 | sub _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. | |
| 617 | sub _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. | |
| 628 | sub _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"). | |
| 641 | sub _humanize_key { | |
| 642 | my $k = shift; | |
| 643 | $k =~ s/[_\-]+/ /g; | |
| 644 | $k =~ s/\b(\w)/\U$1/g; | |
| 645 | return $k; | |
| 646 | } | |
| 647 | ||
| 648 | sub _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">← Browse other storefronts</a> | |
| 656 | </section> | |
| 657 | ~; | |
| 658 | $wrap->render({ | |
| 659 | userinfo => undef, | |
| 660 | title => 'Listing not found', | |
| 661 | body => $body, | |
| 662 | }); | |
| 663 | } |