added on WebSTLs (webstls.com) at 2026-07-01 22:26:41
| 1 | package MODS::WebSTLs::StoreChrome; | |
| 2 | #====================================================================== | |
| 3 | # WebSTLs - shared storefront header chrome (brand + search + sign in | |
| 4 | # + cart). Used by every buyer-facing page that isn't store.cgi itself | |
| 5 | # (cart.cgi, listing_details.cgi, my_orders.cgi, checkout.cgi, ...). | |
| 6 | # | |
| 7 | # The store.cgi page builds these as tvars its layout templates render | |
| 8 | # inline. The other CGIs don't go through a layout template -- they | |
| 9 | # emit their body directly -- so they prepend the output of | |
| 10 | # header_html(\%opts) to keep one consistent header across every | |
| 11 | # buyer-facing surface. | |
| 12 | # | |
| 13 | # Public surface: | |
| 14 | # $html = MODS::WebSTLs::StoreChrome->header_html(\%opts); | |
| 15 | # | |
| 16 | # Required opts: | |
| 17 | # store_id -- numeric storefront id (used in href links) | |
| 18 | # store_name -- display name | |
| 19 | # | |
| 20 | # Optional opts: | |
| 21 | # store_initials -- 1-2 char brand mark text (auto-derived if absent) | |
| 22 | # is_buyer_in -- 1 if a buyer session is active, else 0 | |
| 23 | # cart_count -- integer; renders as badge on the Cart button | |
| 24 | # signin_label -- defaults to "Sign in" | |
| 25 | # cart_label -- defaults to "Cart" | |
| 26 | # search_query -- pre-fill text for the search input | |
| 27 | # nav_pages -- arrayref of {href, title}; renders as nav links | |
| 28 | # between the brand and the search bar | |
| 29 | # db, dbh, DB -- pass these (instead of nav_pages) to have the | |
| 30 | # helper query storefront_pages itself | |
| 31 | #====================================================================== | |
| 32 | ||
| 33 | use strict; | |
| 34 | use warnings; | |
| 35 | ||
| 36 | sub header_html { | |
| 37 | my ($class, $opts) = @_; | |
| 38 | $opts ||= {}; | |
| 39 | my $sid = ($opts->{store_id} // 0) + 0; | |
| 40 | return '' unless $sid; | |
| 41 | ||
| 42 | my $name = defined $opts->{store_name} ? $opts->{store_name} : ''; | |
| 43 | my $init = defined $opts->{store_initials} && length $opts->{store_initials} | |
| 44 | ? $opts->{store_initials} | |
| 45 | : (length $name ? uc(substr($name, 0, 1)) : '?'); | |
| 46 | my $is_buyer = $opts->{is_buyer_in} ? 1 : 0; | |
| 47 | my $cart_n = ($opts->{cart_count} // 0) + 0; | |
| 48 | my $signin = defined $opts->{signin_label} ? $opts->{signin_label} : 'Sign in'; | |
| 49 | my $cartlbl = defined $opts->{cart_label} ? $opts->{cart_label} : 'Cart'; | |
| 50 | my $q = defined $opts->{search_query} ? $opts->{search_query} : ''; | |
| 51 | ||
| 52 | for ($name, $init, $signin, $cartlbl, $q) { | |
| 53 | $_ = '' unless defined $_; | |
| 54 | s/&/&/g; s/"/"/g; s/</</g; s/>/>/g; | |
| 55 | } | |
| 56 | ||
| 57 | my $left_action = $is_buyer | |
| 58 | ? qq~<a href="/my_orders.cgi?id=$sid" class="btn btn-secondary btn-sm">My orders</a>~ | |
| 59 | : qq~<a href="/buyer_login.cgi?id=$sid" class="btn btn-secondary btn-sm">$signin</a>~; | |
| 60 | my $badge = ($cart_n > 0) | |
| 61 | ? 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_n</span>~ | |
| 62 | : ''; | |
| 63 | my $cart_btn = qq~<a href="/cart.cgi?id=$sid" id="we-cart-link" class="btn btn-primary btn-sm" style="position:relative">$cartlbl$badge</a>~; | |
| 64 | ||
| 65 | # Optional nav links from @nav_pages -- shown between the brand | |
| 66 | # and the search bar. Home is always first, then the seller's | |
| 67 | # published pages (with their menu_label override). Identical to | |
| 68 | # the storefront layout's nav so the header looks the same | |
| 69 | # everywhere. | |
| 70 | my @nav_pages; | |
| 71 | if ($opts->{nav_pages} && ref $opts->{nav_pages} eq 'ARRAY') { | |
| 72 | @nav_pages = @{ $opts->{nav_pages} }; | |
| 73 | } elsif ($opts->{db} && $opts->{dbh} && $opts->{DB}) { | |
| 74 | # Auto-fetch from storefront_pages so callers that don't | |
| 75 | # build their own @nav_pages (cart, checkout, my_orders, ...) | |
| 76 | # still get menu links. | |
| 77 | my $sid_safe = $sid + 0; | |
| 78 | my $DB_safe = $opts->{DB}; | |
| 79 | my @rows = $opts->{db}->db_readwrite_multiple($opts->{dbh}, qq~ | |
| 80 | SELECT slug, title, menu_label | |
| 81 | FROM `${DB_safe}`.storefront_pages | |
| 82 | WHERE storefront_id='$sid_safe' | |
| 83 | AND status='published' | |
| 84 | AND page_type != 'home' | |
| 85 | AND show_in_menu = 1 | |
| 86 | ORDER BY page_type ASC, title ASC | |
| 87 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 88 | foreach my $p (@rows) { | |
| 89 | my $slug = $p->{slug} || ''; $slug =~ s/[^a-z0-9\-_]//g; | |
| 90 | next unless length $slug; | |
| 91 | my $label = (defined $p->{menu_label} && length $p->{menu_label}) | |
| 92 | ? $p->{menu_label} | |
| 93 | : ($p->{title} || ucfirst($slug)); | |
| 94 | push @nav_pages, { | |
| 95 | href => "/store.cgi?id=$sid_safe&page=$slug", | |
| 96 | title => $label, | |
| 97 | }; | |
| 98 | } | |
| 99 | } | |
| 100 | my $nav_html = qq~<a href="/store.cgi?id=$sid" style="font-size:13px;color:var(--col-text-2,#9aa5b3);font-weight:500;text-decoration:none">Home</a>~; | |
| 101 | foreach my $np (@nav_pages) { | |
| 102 | my $href = defined $np->{href} ? $np->{href} : ''; | |
| 103 | my $title = defined $np->{title} ? $np->{title} : ''; | |
| 104 | for ($href, $title) { s/&/&/g; s/"/"/g; s/</</g; s/>/>/g; } | |
| 105 | $nav_html .= qq~<a href="$href" style="font-size:13px;color:var(--col-text-2,#9aa5b3);font-weight:500;text-decoration:none">$title</a>~; | |
| 106 | } | |
| 107 | ||
| 108 | return qq~ | |
| 109 | <header id="store-topbar" style="background:var(--col-bg-2,#161b22);border-bottom:1px solid var(--col-border,#363f4d);padding:14px 28px;display:flex;align-items:center;gap:18px;flex-wrap:wrap;font-family:inherit"> | |
| 110 | <a href="/store.cgi?id=$sid" style="display:flex;align-items:center;gap:12px;text-decoration:none;flex-shrink:0"> | |
| 111 | <div class="brand-mark" style="background:linear-gradient(130deg, var(--col-accent,#0b1f47) 0%, var(--col-accent-bright,#6a90d2) 100%)">$init</div> | |
| 112 | <span style="font-family:var(--font-display,inherit);font-size:20px;font-weight:700;color:var(--col-text,#e6ebf2)">$name</span> | |
| 113 | </a> | |
| 114 | <nav style="display:flex;gap:18px;align-items:center;flex-wrap:wrap">$nav_html</nav> | |
| 115 | <form action="/store.cgi" method="GET" role="search" style="flex:1;min-width:0;max-width:420px;display:flex;align-items:center;gap:8px;background:var(--col-surface-2,#262e3a);border:1px solid var(--col-border,#363f4d);border-radius:8px;padding:7px 12px;margin:0"> | |
| 116 | <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,#9aa5b3)"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg> | |
| 117 | <input type="hidden" name="id" value="$sid"> | |
| 118 | <input type="text" name="q" value="$q" placeholder="Search this store..." autocomplete="off" style="background:none;border:none;outline:none;color:var(--col-text,#e6ebf2);font-size:13px;flex:1;min-width:0;font-family:inherit"> | |
| 119 | </form> | |
| 120 | <div style="display:inline-flex;gap:8px;align-items:center;margin-left:auto"> | |
| 121 | $left_action | |
| 122 | $cart_btn | |
| 123 | </div> | |
| 124 | </header>~; | |
| 125 | } | |
| 126 | ||
| 127 | 1; |