@blamejs/blamejs-shop 0.2.11 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.2.x
10
10
 
11
+ - v0.2.12 (2026-05-28) — **Truthful product availability and real SEO metadata — canonical URLs, structured data, and per-page descriptions.** Two related improvements to the storefront's honesty and discoverability. Product pages now report real availability from inventory (instead of a hardcoded 'In stock') and reflect it in their structured data, and the shipping line is suppressed for digital products. Separately, every page now emits a canonical URL and a populated Open Graph URL (previously empty — which broke social-share unfurls and let sort/currency query parameters fork into duplicate indexed pages), the home page emits the same Organization/Website structured data the edge already did, breadcrumb structured data uses absolute URLs, and the home, category, and search pages get their own meta descriptions instead of one shared default. The footer's account links are regrouped and a shipping-and-returns link was added. **Changed:** *Product availability is truthful* — The product page's stock badge and its `Product`/`AggregateOffer` structured-data `availability` are now driven by real per-SKU inventory (`InStock` / `OutOfStock`) rather than a hardcoded value, so a sold-out item reads as sold out everywhere and structured data no longer mismatches reality. The shipping-time line is suppressed for all-digital products (shown as delivered on purchase). Identical across the edge and container render paths. · *Footer account links regrouped* — The footer's operator-oriented column is now a customer 'Your account' grouping (Account / Orders / Contact), the public Admin link was removed, and a 'Shipping & returns' link was added to the policy area. · *Form fields are legible on the dark theme* — Text inputs, selects, and textareas (checkout, address book, search, sign-in) previously rendered with a 10%-opacity hairline that disappeared against the near-black canvas — fields read as empty voids. They now have a clearly-bounded outline and a lifted fill, stretch to fill their column, and the rule covers `tel`/`url`/`password` inputs it previously missed. **Fixed:** *Canonical + Open Graph URLs on every page* — Every page now includes `<link rel="canonical">` and a populated `og:url` (both were absent/empty before). This fixes social-share previews that unfurled with no link target and prevents tracking/sort/currency query-string variants from being indexed as duplicate pages. Emitted on both render paths, derived from the request's own origin. · *Consistent structured data + unique page descriptions* — The home page now emits the `Organization` and `WebSite` (search-action) structured data that previously only appeared on the edge render, breadcrumb structured-data item URLs are absolute (so the breadcrumb rich result is eligible), and the home, category, and search pages each carry their own meta/OG description instead of sharing one generic string.
12
+
11
13
  - v0.2.11 (2026-05-27) — **A clearer checkout — itemised order summary, honest pricing, and recoverable errors.** Reworks the checkout and cart experience. Checkout now uses the same two-column layout as the cart, with a sticky order-summary rail that lists the actual items being bought (thumbnail, title, quantity, line total) rather than a bare number in a lonely column. The summary no longer shows a misleading 'Total' equal to the subtotal — it shows the subtotal plus an honest note that tax and shipping are calculated at the payment step, matching the cart. Adding an item now confirms it and offers to keep shopping, checkout and cart failures render as proper pages you can recover from instead of a blank error string, required fields announce themselves to screen readers, and the header 'Framework' link works from every page. **Changed:** *Checkout shows an itemised, sticky order summary* — The checkout page adopts the cart's two-column layout: the shipping form on the left and a sticky order summary on the right that lists each item (thumbnail, title, quantity, line total) with an 'Edit cart' link back to the cart. Previously the form was a narrow column with a bare subtotal box and no way back. · *Honest checkout pricing* — The order summary no longer renders a 'Total (plus tax + shipping)' line bound to the subtotal value (which read as the final price but wasn't). It now shows the subtotal and the same honest note the cart uses — tax and shipping are calculated at the payment step. · *Add-to-cart confirmation + continue shopping* — Adding an item lands on the cart with a dismissible 'Added to cart' confirmation, and the cart now has a 'Continue shopping' link so multi-item shoppers aren't dead-ended. **Fixed:** *Checkout and cart errors are recoverable, not blank pages* — A declined gift card, an invalid input, or a checkout failure previously dumped an unstyled plain-text error. These now render as styled pages with the reason and a way back — gift-card and loyalty errors re-display the checkout form with the message inline so the shopper can correct it without losing their place. · *Required fields announce to screen readers* — Required form fields kept only a red asterisk (color-only); they now include a visually-hidden '(required)' so assistive technology conveys it. · *Header 'Framework' link works on every page* — It pointed at an on-page anchor that only existed on the home page (a no-op elsewhere); it now resolves from any page.
12
14
 
13
15
  - v0.2.10 (2026-05-27) — **Accessibility and mobile polish — the cookie bar stops covering the page, and forms behave on phones.** A round of default-theme refinements focused on the cookie consent bar, mobile ergonomics, and accessibility. The consent bar previously floated over page content — on a product page or checkout it could sit on top of the price, variant picker, and the add-to-cart / continue-to-payment button on a first visit. It is now a compact bottom bar that reserves its own space (so nothing important is ever hidden under it), scrolls within itself if the text is long, and receives keyboard focus when it appears. Alongside it: mobile form inputs no longer trigger iOS zoom-on-focus, the header touch targets meet the 44px minimum, inline text links are underlined, and a consistency pass tightened button shapes, table-label typography, and small-screen layout. **Changed:** *Visual consistency pass* — Action buttons share a single corner radius (primary and secondary no longer differ in shape), uppercase micro-labels use the monospace face consistently across tables and forms, the catalog grid shows two columns on small phones instead of one, the hero stat row breathes on narrow screens, and a few dark-theme borders/placeholders that read muddy were moved onto the semantic token palette. **Fixed:** *Cookie consent bar no longer covers page content* — On a first visit the consent bar could overlap the product buy box and the checkout form (the price, variant selector, and primary button). It is now a compact bottom-docked bar that reserves matching space at the foot of the page until a choice is made, caps its height and scrolls internally for long text, and moves keyboard focus to its first control when shown — so the page's primary action is never hidden and the bar is reachable without tabbing the whole page. · *Mobile form inputs no longer zoom on iOS* — Text inputs, selects, and textareas render at a 16px minimum on touch devices, so mobile Safari no longer zooms the viewport when a field is focused (which previously left the page zoomed-in and pannable mid-checkout). · *Header touch targets meet the 44px minimum on mobile* — The account and cart controls in the header — the two primary controls on a phone — are now at least 44×44px. · *Clearer link and prompt-glyph affordances* — Inline text links in body copy are underlined (no longer distinguished by color alone), and the terminal prompt glyph on the dark accent bands renders in full contrast.
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.2.11",
2
+ "version": "0.2.12",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-FgqvXcZygYkJjjOtXAeu9xi6AtYqkchPnJKcS0OeHm8lynhd9r4RfdpoT7P7j7/l",
6
6
  "fingerprinted": "css/admin.afd7b964c1f6fe1b.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-8sqFG/ZiALsYtuuzk8cAlSI1exfg9l2UOFs/EmWacVW83DGwOI4g+r29MUCGlKSE",
10
- "fingerprinted": "css/main.7f09021a222915e6.css"
9
+ "integrity": "sha384-4n7LcnY4MZQmhe0dlnwm50HaOyzJ0QMqtb7b70N9qO20B4LJ858lMOxXuua7aFYz",
10
+ "fingerprinted": "css/main.666c51fd74364739.css"
11
11
  },
12
12
  "js/announcement.js": {
13
13
  "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
package/lib/storefront.js CHANGED
@@ -145,6 +145,7 @@ var LAYOUT =
145
145
  " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" +
146
146
  " <title>{{title}} — {{shop_name}}</title>\n" +
147
147
  " <meta name=\"description\" content=\"{{og_description}}\">\n" +
148
+ " <link rel=\"canonical\" href=\"{{canonical_url}}\">\n" +
148
149
  " <link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">\n" +
149
150
  " <link rel=\"icon\" type=\"image/png\" href=\"/assets/brand/favicon.png\">\n" +
150
151
  " <link rel=\"apple-touch-icon\" href=\"/assets/brand/favicon.png\">\n" +
@@ -231,6 +232,7 @@ var LAYOUT =
231
232
  " <li><a href=\"/?sort=sale\">{{footer_shop_sale}}</a></li>\n" +
232
233
  " <li><a href=\"/compare\">{{footer_shop_compare}}</a></li>\n" +
233
234
  " <li><a href=\"/cart\">{{footer_shop_cart}}</a></li>\n" +
235
+ " <li><a href=\"/terms\">{{footer_shop_shipping}}</a></li>\n" +
234
236
  " </ul>\n" +
235
237
  " </div>\n" +
236
238
  " <div class=\"site-footer__col\">\n" +
@@ -247,7 +249,6 @@ var LAYOUT =
247
249
  " <ul>\n" +
248
250
  " <li><a href=\"/account\">{{footer_operators_account}}</a></li>\n" +
249
251
  " <li><a href=\"/orders\">{{footer_operators_orders}}</a></li>\n" +
250
- " <li><a href=\"/admin\">{{footer_operators_admin}}</a></li>\n" +
251
252
  " <li><a href=\"mailto:hello@blamejs.shop\">{{footer_operators_contact}}</a></li>\n" +
252
253
  " </ul>\n" +
253
254
  " </div>\n" +
@@ -530,6 +531,37 @@ var _DEFAULT_LOCALE_CTX = {
530
531
  switcher_html: "",
531
532
  };
532
533
 
534
+ // Derive the absolute canonical + og:url for a request. Origin comes from
535
+ // the `Host` header the same way robots.txt/sitemap.xml resolve it (https
536
+ // is the only scheme the storefront serves on); the path is the request
537
+ // path. `canonical_url` strips the query (the canonical names the page,
538
+ // not a filtered/sorted view); `og_url` keeps the full URL so a share of
539
+ // a filtered listing unfurls to that exact view. A request with no Host
540
+ // header yields empty strings — the renderer then omits the absolute URLs
541
+ // rather than emit a host-less one. Defensive request-shape reader:
542
+ // returns defaults on any malformed input.
543
+ function _requestUrls(req) {
544
+ var host = (req && req.headers && (req.headers.host || req.headers.Host)) || "";
545
+ if (!host) return { canonical_url: "", og_url: "" };
546
+ var origin = "https://" + host;
547
+ var rawUrl = (req && typeof req.url === "string" && req.url.length) ? req.url : "/";
548
+ // req.url may be path-only ("/products/x?y=1") or absolute; normalise
549
+ // through URL with the derived origin as the base so either shape works.
550
+ var path = "/";
551
+ var search = "";
552
+ try {
553
+ var parsed = new URL(rawUrl, origin);
554
+ path = parsed.pathname || "/";
555
+ search = parsed.search || "";
556
+ } catch (_e) {
557
+ var qIdx = rawUrl.indexOf("?");
558
+ path = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx);
559
+ search = qIdx === -1 ? "" : rawUrl.slice(qIdx);
560
+ if (path.charCodeAt(0) !== 47 /* "/" */) path = "/";
561
+ }
562
+ return { canonical_url: origin + path, og_url: origin + path + search };
563
+ }
564
+
533
565
  function _wrap(opts) {
534
566
  var themeCss = (opts && typeof opts.theme_css === "string" && opts.theme_css.length)
535
567
  ? opts.theme_css
@@ -547,7 +579,14 @@ function _wrap(opts) {
547
579
  var ogTitle = opts.og_title || (opts.title ? opts.title + " — " + shopName : shopName);
548
580
  var ogDescription = opts.og_description || "Open-source ecommerce framework built on blamejs. Server-rendered HTML, post-quantum crypto, zero npm runtime dependencies.";
549
581
  var ogImage = opts.og_image || "/assets/brand/logo.png";
550
- var ogUrl = opts.og_url || "";
582
+ // Absolute request URL drives both `og:url` (full URL incl. query) and
583
+ // the canonical link (query stripped — the canonical names the page,
584
+ // not the filtered/sorted view). Renderers thread `opts.canonical_url`
585
+ // + `opts.og_url` (built by `_requestUrls` in the route handler); absent
586
+ // them (a unit test calling the renderer directly), both stay empty
587
+ // rather than emit a bogus host-less URL.
588
+ var canonicalUrl = opts.canonical_url || "";
589
+ var ogUrl = opts.og_url || canonicalUrl;
551
590
  // Multi-currency display switcher — populated only when the operator
552
591
  // configured >1 display currency (opts.currency_options). The block is
553
592
  // empty otherwise, so a single-currency store renders unchanged.
@@ -600,6 +639,7 @@ function _wrap(opts) {
600
639
  og_description: ogDescription,
601
640
  og_image: ogImage,
602
641
  og_url: ogUrl,
642
+ canonical_url: canonicalUrl,
603
643
  lang: localeCtx.lang || "en",
604
644
  dir: localeCtx.dir || "ltr",
605
645
  nav_cart_aria: cartAria,
@@ -966,16 +1006,71 @@ function renderHome(opts) {
966
1006
  // The hero + value band + catalog section give the home page a
967
1007
  // designed surface even when no products are loaded yet —
968
1008
  // visitors land on the storefront shell, not a tech demo.
969
- var body = hero + catalog;
1009
+ // Organization + WebSite JSON-LD — surfaces the storefront in Google's
1010
+ // knowledge-panel (logo + social) and registers the sitelinks search
1011
+ // box pointing at `/search?q=`. Mirrors the edge renderer
1012
+ // (`worker/render/home.js`) byte-for-byte so the structured data is
1013
+ // identical whichever substrate serves the home page. `_orgWebsiteJsonLd`
1014
+ // resolves the absolute base from the shop name (a bare host or a full
1015
+ // URL both normalise to `https://<host>`).
1016
+ var homeJsonLd = _orgWebsiteJsonLd(shopName);
1017
+ var body = hero + catalog + homeJsonLd;
970
1018
  return _wrap(Object.assign({
971
1019
  title: title,
972
1020
  shop_name: shopName,
973
1021
  cart_count: cartCount,
974
1022
  theme_css: opts.theme_css,
1023
+ canonical_url: opts.canonical_url,
1024
+ og_url: opts.og_url,
1025
+ og_description: "Shop the blamejs.shop catalog — an open-source, server-rendered storefront with post-quantum crypto and zero npm runtime dependencies.",
975
1026
  body: body,
976
1027
  }, _currencyWrapOpts(opts)));
977
1028
  }
978
1029
 
1030
+ // Absolute origin (scheme + host, no trailing slash) for building
1031
+ // fully-qualified structured-data URLs. Prefers the request-derived
1032
+ // canonical URL's origin (matches the host the visitor reached); falls
1033
+ // back to the shop-name host when the renderer is called without one (a
1034
+ // unit test, or a theme path). A bare host shop name and a full `https://`
1035
+ // value both normalise to `https://<host>`.
1036
+ function _absoluteBase(canonicalUrl, shopName) {
1037
+ if (typeof canonicalUrl === "string" && canonicalUrl.length) {
1038
+ try {
1039
+ var parsed = new URL(canonicalUrl);
1040
+ return parsed.origin;
1041
+ } catch (_e) { /* fall through to the shop-name base */ }
1042
+ }
1043
+ return "https://" + String(shopName || "blamejs.shop").replace(/^https?:\/\//, "");
1044
+ }
1045
+
1046
+ // Schema.org Organization + WebSite JSON-LD for the home page. Shared by
1047
+ // the container `renderHome` and mirrored by the edge
1048
+ // `worker/render/home.js` — keep the two byte-identical. The base URL is
1049
+ // derived from the shop name (operators set SHOP_NAME to their host); a
1050
+ // bare host and a full `https://` value both normalise to `https://<host>`.
1051
+ function _orgWebsiteJsonLd(shopName) {
1052
+ var base = "https://" + String(shopName).replace(/^https?:\/\//, "");
1053
+ return _jsonLdScript({
1054
+ "@context": "https://schema.org",
1055
+ "@type": "Organization",
1056
+ "name": shopName,
1057
+ "url": base,
1058
+ "logo": base + "/assets/brand/logo.png",
1059
+ "sameAs": ["https://github.com/blamejs/blamejs.shop"],
1060
+ }) +
1061
+ _jsonLdScript({
1062
+ "@context": "https://schema.org",
1063
+ "@type": "WebSite",
1064
+ "name": shopName,
1065
+ "url": base,
1066
+ "potentialAction": {
1067
+ "@type": "SearchAction",
1068
+ "target": base + "/search?q={search_term_string}",
1069
+ "query-input": "required name=search_term_string",
1070
+ },
1071
+ });
1072
+ }
1073
+
979
1074
  // ---- search results -----------------------------------------------------
980
1075
 
981
1076
  var SEARCH_HEADER =
@@ -1218,12 +1313,20 @@ function renderSearch(opts) {
1218
1313
  } else {
1219
1314
  body = header + correctionHtml + chipsHtml + resultsInner;
1220
1315
  }
1316
+ var searchShopName = opts.shop_name || "blamejs.shop";
1317
+ // Per-page meta description — mirrors the edge renderer.
1318
+ var searchMetaDescription = qTrim.length > 0
1319
+ ? ("Results for “" + qTrim + "” in the " + searchShopName + " catalog.")
1320
+ : ("Search the " + searchShopName + " catalog by title, SKU, or description.");
1221
1321
  return _wrap(Object.assign({
1222
1322
  title: "Search",
1223
- shop_name: opts.shop_name || "blamejs.shop",
1323
+ shop_name: searchShopName,
1224
1324
  cart_count: opts.cart_count,
1225
1325
  search_q: opts.q,
1226
1326
  theme_css: opts.theme_css,
1327
+ og_description: searchMetaDescription,
1328
+ canonical_url: opts.canonical_url,
1329
+ og_url: opts.og_url,
1227
1330
  body: body,
1228
1331
  }, _currencyWrapOpts(opts)));
1229
1332
  }
@@ -1335,6 +1438,78 @@ function _buildBuyBox(variants, escAttr) {
1335
1438
  " " + trustLine;
1336
1439
  }
1337
1440
 
1441
+ // Resolve a product's availability + shipping shape from the variant
1442
+ // list + (optional) inventory map the route loads. `in_stock` is true if
1443
+ // ANY variant is buyable; a SKU with no inventory row counts as available
1444
+ // (the never-block-on-missing-inventory stance the cart-hold path already
1445
+ // takes). `requires_shipping` is true if ANY variant ships physically —
1446
+ // an all-digital product (`requires_shipping = 0` on every variant)
1447
+ // suppresses the "Ships in 1–2 business days" line. Returns a normalised
1448
+ // `{ in_stock, requires_shipping }` so the two render paths drive the
1449
+ // badge + JSON-LD from the same shape. Defensive request-shape reader:
1450
+ // missing/garbage inputs resolve to the available + physical default.
1451
+ function _resolveAvailability(variants, inventoryBySku) {
1452
+ variants = Array.isArray(variants) ? variants : [];
1453
+ var inv = (inventoryBySku && typeof inventoryBySku === "object") ? inventoryBySku : null;
1454
+ var anyTracked = false;
1455
+ var anyInStock = false;
1456
+ var requiresShipping = false;
1457
+ for (var i = 0; i < variants.length; i += 1) {
1458
+ var v = variants[i] || {};
1459
+ // requires_shipping defaults to physical (true) unless the column is
1460
+ // explicitly 0/false — matches the catalog default for tangible goods.
1461
+ if (v.requires_shipping === undefined || v.requires_shipping === null ||
1462
+ Number(v.requires_shipping) !== 0) {
1463
+ requiresShipping = true;
1464
+ }
1465
+ if (inv && Object.prototype.hasOwnProperty.call(inv, v.sku)) {
1466
+ anyTracked = true;
1467
+ var row = inv[v.sku];
1468
+ var available = row ? (Number(row.stock_on_hand) - Number(row.stock_held)) : 0;
1469
+ if (available > 0) anyInStock = true;
1470
+ }
1471
+ }
1472
+ return {
1473
+ // No tracked variant → the operator hasn't opted into stock tracking,
1474
+ // so the product reads as in stock (never-block stance).
1475
+ in_stock: anyTracked ? anyInStock : true,
1476
+ requires_shipping: variants.length === 0 ? true : requiresShipping,
1477
+ };
1478
+ }
1479
+
1480
+ // PDP availability badges, driven by the resolved availability shape so
1481
+ // the displayed state matches the JSON-LD `availability`. Mirrored byte-
1482
+ // for-byte by worker/render/product.js#_buildAvailability.
1483
+ function _buildAvailability(availability) {
1484
+ var a = availability || { in_stock: true, requires_shipping: true };
1485
+ var stockBadge = a.in_stock
1486
+ ? "<span class=\"pdp__badge pdp__badge--ok\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> In stock</span>"
1487
+ : "<span class=\"pdp__badge pdp__badge--out\">Out of stock</span>";
1488
+ // The "Ships in 1–2 business days" line only applies to a physical good;
1489
+ // an all-digital product suppresses it (nothing ships).
1490
+ var shipBadge = a.requires_shipping
1491
+ ? "<span class=\"pdp__badge\">Ships in 1–2 business days</span>"
1492
+ : "<span class=\"pdp__badge\">Digital — delivered on purchase</span>";
1493
+ return "<div class=\"pdp__meta\">\n" +
1494
+ " " + stockBadge + "\n" +
1495
+ " " + shipBadge + "\n" +
1496
+ " <span class=\"pdp__badge\">Stripe-secured checkout</span>\n" +
1497
+ " </div>";
1498
+ }
1499
+
1500
+ // Short shipping/returns line under the buy box pointing shoppers at the
1501
+ // public policy page. Physical goods get a shipping-and-returns line; a
1502
+ // digital-only product drops the shipping half (no parcel to return).
1503
+ // Mirrored byte-for-byte by worker/render/product.js#_pdpShippingNote.
1504
+ function _pdpShippingNote(availability) {
1505
+ var a = availability || { in_stock: true, requires_shipping: true };
1506
+ var copy = a.requires_shipping
1507
+ ? "Free returns within 30 days. "
1508
+ : "";
1509
+ return "<p class=\"pdp__shipping-note\">" + copy +
1510
+ "See our <a href=\"/terms\">shipping &amp; returns policy</a>.</p>";
1511
+ }
1512
+
1338
1513
  var PRODUCT_PAGE =
1339
1514
  "<section class=\"pdp\">\n" +
1340
1515
  " <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
@@ -1349,12 +1524,9 @@ var PRODUCT_PAGE =
1349
1524
  " <p class=\"eyebrow\">Catalog product</p>\n" +
1350
1525
  " <h1 class=\"pdp__title\">{{title}}</h1>\n" +
1351
1526
  " <p class=\"pdp__description\">{{description}}</p>\n" +
1352
- " <div class=\"pdp__meta\">\n" +
1353
- " <span class=\"pdp__badge pdp__badge--ok\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> In stock</span>\n" +
1354
- " <span class=\"pdp__badge\">Ships in 1–2 business days</span>\n" +
1355
- " <span class=\"pdp__badge\">Stripe-secured checkout</span>\n" +
1356
- " </div>\n" +
1527
+ " RAW_AVAILABILITY_PLACEHOLDER\n" +
1357
1528
  " RAW_BUYBOX_PLACEHOLDER\n" +
1529
+ " RAW_SHIPPING_NOTE_PLACEHOLDER\n" +
1358
1530
  " RAW_QTYBREAK_PLACEHOLDER\n" +
1359
1531
  " RAW_WISHLIST_PLACEHOLDER\n" +
1360
1532
  " RAW_COMPARE_PLACEHOLDER\n" +
@@ -2398,7 +2570,10 @@ function renderCategoryIndex(opts) {
2398
2570
  "</section>";
2399
2571
  return _wrap({
2400
2572
  title: "Categories", shop_name: opts.shop_name || "blamejs.shop",
2401
- cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
2573
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css,
2574
+ og_description: "Browse every product category in the " + (opts.shop_name || "blamejs.shop") + " catalog.",
2575
+ canonical_url: opts.canonical_url, og_url: opts.og_url,
2576
+ body: body,
2402
2577
  });
2403
2578
  }
2404
2579
 
@@ -2463,9 +2638,17 @@ function renderCategory(opts) {
2463
2638
  hero +
2464
2639
  grid +
2465
2640
  "</section>";
2641
+ // Per-page meta description: the category's own description when set,
2642
+ // otherwise a "Shop {category}…" pitch.
2643
+ var catMetaDescription = (cat.description && String(cat.description).trim().length)
2644
+ ? String(cat.description)
2645
+ : ("Shop " + cat.title + " at " + (opts.shop_name || "blamejs.shop") + ".");
2466
2646
  return _wrap({
2467
2647
  title: cat.title, shop_name: opts.shop_name || "blamejs.shop",
2468
- cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
2648
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css,
2649
+ og_description: catMetaDescription,
2650
+ canonical_url: opts.canonical_url, og_url: opts.og_url,
2651
+ body: body,
2469
2652
  });
2470
2653
  }
2471
2654
 
@@ -3218,7 +3401,15 @@ function renderProduct(opts) {
3218
3401
  asset_css_main: opts.theme.assetUrl("css/main.css"),
3219
3402
  });
3220
3403
  }
3404
+ // Truthful availability + shipping shape — drives the on-page badges
3405
+ // AND the JSON-LD `availability` from the same resolved values so the
3406
+ // two never disagree. `opts.inventory` is the per-SKU map the route
3407
+ // loads ({ sku: { stock_on_hand, stock_held } }); absent it, the
3408
+ // product reads as in stock (never-block-on-missing-inventory stance).
3409
+ var availability = _resolveAvailability(variants, opts.inventory);
3221
3410
  var buyboxHtml = _buildBuyBox(rendered, b.template.escapeHtml);
3411
+ var availabilityHtml = _buildAvailability(availability);
3412
+ var shippingNoteHtml = _pdpShippingNote(availability);
3222
3413
  var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
3223
3414
  var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
3224
3415
  var qaHtml = _buildProductQa(opts.qa_questions, opts.qa_cta);
@@ -3231,7 +3422,9 @@ function renderProduct(opts) {
3231
3422
  description: description,
3232
3423
  })
3233
3424
  .replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
3425
+ .replace("RAW_AVAILABILITY_PLACEHOLDER", availabilityHtml)
3234
3426
  .replace("RAW_BUYBOX_PLACEHOLDER", buyboxHtml)
3427
+ .replace("RAW_SHIPPING_NOTE_PLACEHOLDER", shippingNoteHtml)
3235
3428
  .replace("RAW_QTYBREAK_PLACEHOLDER", qtyBreaksHtml)
3236
3429
  .replace("RAW_WISHLIST_PLACEHOLDER", wishlistHtml)
3237
3430
  .replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
@@ -3243,6 +3436,11 @@ function renderProduct(opts) {
3243
3436
  // the default shop-level description + brand logo.
3244
3437
  var heroMedia = (opts.media && opts.media[0]) || null;
3245
3438
  var ogImage = heroMedia ? ((opts.asset_prefix || "/assets/") + heroMedia.r2_key) : "/assets/brand/logo.png";
3439
+ // Absolute base for the BreadcrumbList `item` URLs — derived from the
3440
+ // PDP's own canonical (origin stripped of the /products/slug path) so
3441
+ // the structured-data trail carries fully-qualified URLs. Falls back to
3442
+ // the shop-name host when the renderer is called without a request URL.
3443
+ var absoluteBase = _absoluteBase(opts.canonical_url, shopName);
3246
3444
 
3247
3445
  // Product + AggregateOffer JSON-LD, with AggregateRating folded in
3248
3446
  // when published reviews exist. Kept byte-compatible with the edge
@@ -3282,7 +3480,9 @@ function renderProduct(opts) {
3282
3480
  "lowPrice": (lowMinor / divisor).toFixed(divisor === 1 ? 0 : 2),
3283
3481
  "highPrice": (hiMinor / divisor).toFixed(divisor === 1 ? 0 : 2),
3284
3482
  "offerCount": variants.length,
3285
- "availability": "https://schema.org/InStock",
3483
+ "availability": availability.in_stock
3484
+ ? "https://schema.org/InStock"
3485
+ : "https://schema.org/OutOfStock",
3286
3486
  },
3287
3487
  });
3288
3488
  }
@@ -3290,8 +3490,8 @@ function renderProduct(opts) {
3290
3490
  "@context": "https://schema.org",
3291
3491
  "@type": "BreadcrumbList",
3292
3492
  "itemListElement": [
3293
- { "@type": "ListItem", "position": 1, "name": "Shop", "item": "/" },
3294
- { "@type": "ListItem", "position": 2, "name": opts.product.title, "item": "/products/" + opts.product.slug },
3493
+ { "@type": "ListItem", "position": 1, "name": "Shop", "item": absoluteBase + "/" },
3494
+ { "@type": "ListItem", "position": 2, "name": opts.product.title, "item": absoluteBase + "/products/" + opts.product.slug },
3295
3495
  ],
3296
3496
  });
3297
3497
  jsonLd = (jsonLd || "") + breadcrumbJsonLd;
@@ -3305,6 +3505,8 @@ function renderProduct(opts) {
3305
3505
  og_title: opts.product.title + " — " + shopName,
3306
3506
  og_description: description || ("Browse " + opts.product.title + " on " + shopName + "."),
3307
3507
  og_image: ogImage,
3508
+ canonical_url: opts.canonical_url,
3509
+ og_url: opts.og_url,
3308
3510
  body: body + jsonLd,
3309
3511
  }, _currencyWrapOpts(opts)));
3310
3512
  }
@@ -5838,7 +6040,7 @@ function mount(router, deps) {
5838
6040
  }));
5839
6041
  }
5840
6042
  var ccy = await _currencyForReq(req);
5841
- var html = renderHome(Object.assign({ products: products, shop_name: shopName, theme: theme }, ccy));
6043
+ var html = renderHome(Object.assign({ products: products, shop_name: shopName, theme: theme }, _requestUrls(req), ccy));
5842
6044
  _send(res, 200, html);
5843
6045
  });
5844
6046
 
@@ -5969,7 +6171,7 @@ function mount(router, deps) {
5969
6171
  corrected_query: correctedQ,
5970
6172
  shop_name: shopName,
5971
6173
  cart_count: cartCount,
5972
- }, ccy)));
6174
+ }, _requestUrls(req), ccy)));
5973
6175
  });
5974
6176
 
5975
6177
  router.get("/products/:slug", async function (req, res) {
@@ -5979,9 +6181,22 @@ function mount(router, deps) {
5979
6181
  if (!product) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
5980
6182
  var variants = await deps.catalog.variants.listForProduct(product.id);
5981
6183
  var prices = {};
6184
+ // Per-SKU inventory map driving the truthful availability badge +
6185
+ // JSON-LD. Best-effort: a SKU with no inventory row (or a read
6186
+ // failure) is omitted from the map, which the renderer treats as
6187
+ // available — the never-block-on-missing-inventory stance the
6188
+ // cart-hold path already takes. Only populated when the operator has
6189
+ // wired the inventory primitive.
6190
+ var inventory = {};
5982
6191
  for (var i = 0; i < variants.length; i += 1) {
5983
6192
  var p = await deps.catalog.prices.current(variants[i].id, "USD");
5984
6193
  if (p) prices[variants[i].id] = p;
6194
+ if (deps.catalog.inventory && typeof deps.catalog.inventory.get === "function") {
6195
+ try {
6196
+ var invRow = await deps.catalog.inventory.get(variants[i].sku);
6197
+ if (invRow) inventory[variants[i].sku] = invRow;
6198
+ } catch (_e) { /* drop-silent — missing inventory reads as available */ }
6199
+ }
5985
6200
  }
5986
6201
  // Media — first row drives the hero image, the next three feed
5987
6202
  // the thumbnail strip. `listForProduct` is product-level only;
@@ -6068,6 +6283,7 @@ function mount(router, deps) {
6068
6283
  variants: variants,
6069
6284
  prices: prices,
6070
6285
  media: media,
6286
+ inventory: inventory,
6071
6287
  review_summary: reviewSummary,
6072
6288
  reviews: reviewRows,
6073
6289
  review_cta: reviewCta,
@@ -6079,7 +6295,7 @@ function mount(router, deps) {
6079
6295
  shop_name: shopName,
6080
6296
  cart_count: cartCount,
6081
6297
  theme: theme,
6082
- }, ccy));
6298
+ }, _requestUrls(req), ccy));
6083
6299
  _send(res, 200, html);
6084
6300
  });
6085
6301
 
@@ -6354,9 +6570,9 @@ function mount(router, deps) {
6354
6570
  if (cats[i].active) active.push(cats[i]);
6355
6571
  }
6356
6572
  var cartCount = await _cartCountForReq(req);
6357
- _send(res, 200, renderCategoryIndex({
6573
+ _send(res, 200, renderCategoryIndex(Object.assign({
6358
6574
  categories: active, shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
6359
- }));
6575
+ }, _requestUrls(req))));
6360
6576
  });
6361
6577
 
6362
6578
  router.get("/categories/:slug", async function (req, res) {
@@ -6386,10 +6602,10 @@ function mount(router, deps) {
6386
6602
  if (children[i].active) activeChildren.push(children[i]);
6387
6603
  }
6388
6604
  var cartCount = await _cartCountForReq(req);
6389
- _send(res, 200, renderCategory({
6605
+ _send(res, 200, renderCategory(Object.assign({
6390
6606
  category: cat, breadcrumbs: crumbs, children: activeChildren,
6391
6607
  shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
6392
- }));
6608
+ }, _requestUrls(req))));
6393
6609
  });
6394
6610
  }
6395
6611
 
@@ -9103,8 +9319,12 @@ function mount(router, deps) {
9103
9319
  var hostHeader = req.headers && (req.headers.host || req.headers.Host) || "";
9104
9320
  var origin = hostHeader ? ("https://" + hostHeader) : "";
9105
9321
  var urls = [];
9106
- urls.push({ loc: origin + "/", changefreq: "daily", priority: "1.0" });
9107
- urls.push({ loc: origin + "/admin", changefreq: "monthly", priority: "0.3" });
9322
+ // The home page + the public legal pages. `/admin` is intentionally
9323
+ // omitted robots.txt disallows it, so listing it in the sitemap
9324
+ // would contradict the crawl policy.
9325
+ urls.push({ loc: origin + "/", changefreq: "daily", priority: "1.0" });
9326
+ urls.push({ loc: origin + "/privacy", changefreq: "yearly", priority: "0.3" });
9327
+ urls.push({ loc: origin + "/terms", changefreq: "yearly", priority: "0.3" });
9108
9328
  try {
9109
9329
  var page = await deps.catalog.products.list({ status: "active", limit: 1000 });
9110
9330
  for (var i = 0; i < page.rows.length; i += 1) {
@@ -9118,6 +9338,38 @@ function mount(router, deps) {
9118
9338
  });
9119
9339
  }
9120
9340
  } catch (_e) { /* drop-silent — catalog unreachable means sitemap drops product rows */ }
9341
+ // Active category browse pages, when the categoryNavigation primitive
9342
+ // is wired. `tree({})` returns the full nested active tree (archived
9343
+ // rows already dropped); we flatten it to one URL per category. Best-
9344
+ // effort — a read failure drops the category rows rather than 500-ing
9345
+ // the sitemap.
9346
+ if (deps.categoryNavigation && typeof deps.categoryNavigation.tree === "function") {
9347
+ try {
9348
+ var catTree = await deps.categoryNavigation.tree({});
9349
+ var stack = Array.isArray(catTree) ? catTree.slice() : [];
9350
+ while (stack.length) {
9351
+ var node = stack.pop();
9352
+ if (node && node.active && node.slug) {
9353
+ urls.push({ loc: origin + "/categories/" + node.slug, changefreq: "weekly", priority: "0.6" });
9354
+ }
9355
+ if (node && Array.isArray(node.children)) {
9356
+ for (var cj = 0; cj < node.children.length; cj += 1) stack.push(node.children[cj]);
9357
+ }
9358
+ }
9359
+ } catch (_e) { /* drop-silent — categories unreachable drops those rows */ }
9360
+ }
9361
+ // Active collection browse pages, when the collections primitive is
9362
+ // wired. Same best-effort discipline.
9363
+ if (deps.collections && typeof deps.collections.list === "function") {
9364
+ try {
9365
+ var cols = await deps.collections.list({ active_only: true });
9366
+ for (var coi = 0; coi < cols.length; coi += 1) {
9367
+ if (cols[coi] && cols[coi].slug) {
9368
+ urls.push({ loc: origin + "/collections/" + cols[coi].slug, changefreq: "weekly", priority: "0.6" });
9369
+ }
9370
+ }
9371
+ } catch (_e) { /* drop-silent — collections unreachable drops those rows */ }
9372
+ }
9121
9373
  var body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
9122
9374
  "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
9123
9375
  for (var u = 0; u < urls.length; u += 1) {
@@ -593,6 +593,7 @@ var CHROME_DEFAULTS = Object.freeze({
593
593
  footer_shop_sale: "On sale",
594
594
  footer_shop_compare: "Compare",
595
595
  footer_shop_cart: "Cart",
596
+ footer_shop_shipping: "Shipping & returns",
596
597
 
597
598
  footer_framework_heading: "Framework",
598
599
  footer_framework_source: "Source on GitHub",
@@ -600,10 +601,9 @@ var CHROME_DEFAULTS = Object.freeze({
600
601
  footer_framework_security: "Security policy",
601
602
  footer_framework_changelog: "Changelog",
602
603
 
603
- footer_operators_heading: "Operators",
604
+ footer_operators_heading: "Your account",
604
605
  footer_operators_account: "Account",
605
606
  footer_operators_orders: "Orders",
606
- footer_operators_admin: "Admin",
607
607
  footer_operators_contact: "Contact",
608
608
 
609
609
  footer_copy_suffix: "built on blamejs · Apache 2.0 licensed.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {