@blamejs/blamejs-shop 0.2.10 → 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,10 @@ 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
+
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.
14
+
11
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.
12
16
 
13
17
  - v0.2.9 (2026-05-27) — **Storefront read pages stay fast for every visitor — the cart badge updates client-side.** Restores full edge-render speed for returning and signed-in shoppers. Every storefront read page (home, product listing, search, blog) now renders at the edge for all visitors again; the nav cart badge renders server-side and a tiny client island corrects the number from a new lightweight endpoint. The result: the fast cached page AND an accurate cart count, without sending a shopper-with-a-cart through the slower origin on every page. **Added:** *`GET /cart/count` + a cart-count island* — A new endpoint returns the current session's cart line count as JSON (`{"count": N}`), cached `no-store`, with no product hydration. A small deferred same-origin script (`assets/js/cart-count.js`) reads it on load and updates the nav badge text and its aria-label. A visitor with JavaScript off keeps the server-rendered badge. The script is a same-origin external file, so the existing `script-src 'self'` policy already permits it — no CSP change required. **Fixed:** *Fast read pages for shoppers who have a session* — A visitor carrying a cart or sign-in cookie is once again served the edge-rendered storefront pages instead of being routed to the origin container, so the home page and other read pages load quickly for returning visitors, not just first-time guests. The nav cart count stays correct via the island below.
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.2.10",
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-989Th+84GsswrtPWc4g7DImIYKE/nUSgpZcQMeYxhPN1tg3lrgBhjQNKHNqwz7kb",
10
- "fingerprinted": "css/main.4504ec0c542c3225.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" +
@@ -186,7 +187,7 @@ var LAYOUT =
186
187
  " </form>\n" +
187
188
  " <nav class=\"site-nav\" aria-label=\"Primary\">\n" +
188
189
  " <a class=\"site-nav__link\" href=\"/\">{{nav_shop}}</a>\n" +
189
- " <a class=\"site-nav__link\" href=\"#framework\">{{nav_framework}}</a>\n" +
190
+ " <a class=\"site-nav__link\" href=\"/#framework\">{{nav_framework}}</a>\n" +
190
191
  " <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"{{nav_account}}\"><svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg></a>\n" +
191
192
  " <a class=\"cart-pill\" href=\"/cart\" aria-label=\"{{nav_cart_aria}}\"><svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M3 4h2l2.4 12.1a2 2 0 0 0 2 1.6h7.6a2 2 0 0 0 1.95-1.55L21 8H6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"10\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/><circle cx=\"17\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/></svg><span class=\"cart-pill__count\">{{cart_count}}</span></a>\n" +
192
193
  " </nav>\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" +
@@ -2150,7 +2322,9 @@ function _addrField(name, labelText, value, opts) {
2150
2322
  if (opts.maxlength) attrs += " maxlength=\"" + opts.maxlength + "\"";
2151
2323
  if (opts.pattern) attrs += " pattern=\"" + esc(opts.pattern) + "\"";
2152
2324
  if (opts.autocomplete) attrs += " autocomplete=\"" + esc(opts.autocomplete) + "\"";
2153
- var req = opts.required ? " <span class=\"form-field__req\" aria-hidden=\"true\">*</span>" : "";
2325
+ // The `*` is a color-only visual cue; pair it with a visually-hidden
2326
+ // "(required)" so a screen reader announces the field's requiredness.
2327
+ var req = opts.required ? " <span class=\"form-field__req\" aria-hidden=\"true\">*</span><span class=\"sr-only\">(required)</span>" : "";
2154
2328
  return "<label class=\"form-field\">" +
2155
2329
  "<span class=\"form-field__label\">" + esc(labelText) + req + "</span>" +
2156
2330
  "<input type=\"text\" name=\"" + esc(name) + "\" value=\"" + esc(value == null ? "" : String(value)) + "\"" + attrs + ">" +
@@ -2396,7 +2570,10 @@ function renderCategoryIndex(opts) {
2396
2570
  "</section>";
2397
2571
  return _wrap({
2398
2572
  title: "Categories", shop_name: opts.shop_name || "blamejs.shop",
2399
- 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,
2400
2577
  });
2401
2578
  }
2402
2579
 
@@ -2461,9 +2638,17 @@ function renderCategory(opts) {
2461
2638
  hero +
2462
2639
  grid +
2463
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") + ".");
2464
2646
  return _wrap({
2465
2647
  title: cat.title, shop_name: opts.shop_name || "blamejs.shop",
2466
- 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,
2467
2652
  });
2468
2653
  }
2469
2654
 
@@ -3216,7 +3401,15 @@ function renderProduct(opts) {
3216
3401
  asset_css_main: opts.theme.assetUrl("css/main.css"),
3217
3402
  });
3218
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);
3219
3410
  var buyboxHtml = _buildBuyBox(rendered, b.template.escapeHtml);
3411
+ var availabilityHtml = _buildAvailability(availability);
3412
+ var shippingNoteHtml = _pdpShippingNote(availability);
3220
3413
  var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
3221
3414
  var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
3222
3415
  var qaHtml = _buildProductQa(opts.qa_questions, opts.qa_cta);
@@ -3229,7 +3422,9 @@ function renderProduct(opts) {
3229
3422
  description: description,
3230
3423
  })
3231
3424
  .replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
3425
+ .replace("RAW_AVAILABILITY_PLACEHOLDER", availabilityHtml)
3232
3426
  .replace("RAW_BUYBOX_PLACEHOLDER", buyboxHtml)
3427
+ .replace("RAW_SHIPPING_NOTE_PLACEHOLDER", shippingNoteHtml)
3233
3428
  .replace("RAW_QTYBREAK_PLACEHOLDER", qtyBreaksHtml)
3234
3429
  .replace("RAW_WISHLIST_PLACEHOLDER", wishlistHtml)
3235
3430
  .replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
@@ -3241,6 +3436,11 @@ function renderProduct(opts) {
3241
3436
  // the default shop-level description + brand logo.
3242
3437
  var heroMedia = (opts.media && opts.media[0]) || null;
3243
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);
3244
3444
 
3245
3445
  // Product + AggregateOffer JSON-LD, with AggregateRating folded in
3246
3446
  // when published reviews exist. Kept byte-compatible with the edge
@@ -3280,7 +3480,9 @@ function renderProduct(opts) {
3280
3480
  "lowPrice": (lowMinor / divisor).toFixed(divisor === 1 ? 0 : 2),
3281
3481
  "highPrice": (hiMinor / divisor).toFixed(divisor === 1 ? 0 : 2),
3282
3482
  "offerCount": variants.length,
3283
- "availability": "https://schema.org/InStock",
3483
+ "availability": availability.in_stock
3484
+ ? "https://schema.org/InStock"
3485
+ : "https://schema.org/OutOfStock",
3284
3486
  },
3285
3487
  });
3286
3488
  }
@@ -3288,8 +3490,8 @@ function renderProduct(opts) {
3288
3490
  "@context": "https://schema.org",
3289
3491
  "@type": "BreadcrumbList",
3290
3492
  "itemListElement": [
3291
- { "@type": "ListItem", "position": 1, "name": "Shop", "item": "/" },
3292
- { "@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 },
3293
3495
  ],
3294
3496
  });
3295
3497
  jsonLd = (jsonLd || "") + breadcrumbJsonLd;
@@ -3303,6 +3505,8 @@ function renderProduct(opts) {
3303
3505
  og_title: opts.product.title + " — " + shopName,
3304
3506
  og_description: description || ("Browse " + opts.product.title + " on " + shopName + "."),
3305
3507
  og_image: ogImage,
3508
+ canonical_url: opts.canonical_url,
3509
+ og_url: opts.og_url,
3306
3510
  body: body + jsonLd,
3307
3511
  }, _currencyWrapOpts(opts)));
3308
3512
  }
@@ -3382,7 +3586,7 @@ function _checkoutShippingFields(p) {
3382
3586
  var esc = b.template.escapeHtml;
3383
3587
  var email =
3384
3588
  "<label class=\"form-field\">" +
3385
- "<span class=\"form-field__label\">Email <span class=\"form-field__req\" aria-hidden=\"true\">*</span></span>" +
3589
+ "<span class=\"form-field__label\">Email <span class=\"form-field__req\" aria-hidden=\"true\">*</span><span class=\"sr-only\">(required)</span></span>" +
3386
3590
  "<input type=\"email\" name=\"email\" value=\"" + esc(p.email == null ? "" : String(p.email)) + "\" required autocomplete=\"email\">" +
3387
3591
  "</label>";
3388
3592
  return email +
@@ -3417,6 +3621,12 @@ function _shipToFromBody(body) {
3417
3621
  };
3418
3622
  }
3419
3623
 
3624
+ // Checkout mirrors the cart's two-column shell: the shipping form on the
3625
+ // left, a sticky order-summary rail on the right. The summary lists the
3626
+ // cart line items + the Subtotal, then the cart's exact honest microcopy
3627
+ // — tax + shipping can't be computed until the address is entered, so no
3628
+ // fabricated "Total" is shown. An "Edit cart" link near the summary lets
3629
+ // the shopper change quantities without losing form data.
3420
3630
  var CHECKOUT_PAGE =
3421
3631
  "<section class=\"checkout-page\">\n" +
3422
3632
  " <header class=\"section-head\">\n" +
@@ -3424,20 +3634,54 @@ var CHECKOUT_PAGE =
3424
3634
  " <h1 class=\"section-head__title\">Shipping details</h1>\n" +
3425
3635
  " <p class=\"section-head__lede\">Enter where the order should ship. Payment runs through Stripe on the next step.</p>\n" +
3426
3636
  " </header>\n" +
3427
- " <form method=\"post\" action=\"/checkout\" class=\"form-stack\">\n" +
3428
- " RAW_SHIPPING_FIELDS" +
3429
- " <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Gift card code <span class=\"small\">(optional)</span></span><input type=\"text\" name=\"gift_card_code\" autocomplete=\"off\" placeholder=\"XXXX-XXXX-XXXX-XXXX\" maxlength=\"24\"></label></div>\n" +
3430
- " <div class=\"checkout-summary\">\n" +
3431
- " <h3>Order summary</h3>\n" +
3432
- " <dl>\n" +
3433
- " <div><dt>Subtotal</dt><dd>{{subtotal}}</dd></div>\n" +
3434
- " <div class=\"checkout-summary__total\"><dt>Total <span class=\"small\">(plus tax + shipping)</span></dt><dd>{{subtotal}}</dd></div>\n" +
3435
- " </dl>\n" +
3637
+ " <div class=\"cart-page__grid\">\n" +
3638
+ " <div class=\"checkout-page__form-col\">\n" +
3639
+ " <form method=\"post\" action=\"/checkout\" class=\"form-stack\">\n" +
3640
+ " RAW_INLINE_ERROR" +
3641
+ " RAW_SHIPPING_FIELDS" +
3642
+ " <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Gift card code <span class=\"small\">(optional)</span></span><input type=\"text\" name=\"gift_card_code\" autocomplete=\"off\" placeholder=\"XXXX-XXXX-XXXX-XXXX\" maxlength=\"24\"></label></div>\n" +
3643
+ " <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Continue to payment <span aria-hidden=\"true\">→</span></button></div>\n" +
3644
+ " </form>\n" +
3436
3645
  " </div>\n" +
3437
- " <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Continue to payment <span aria-hidden=\"true\">→</span></button></div>\n" +
3438
- " </form>\n" +
3646
+ " <aside class=\"cart-page__summary\">\n" +
3647
+ " <div class=\"checkout-page__summary-head\">\n" +
3648
+ " <h2 class=\"pdp__variants-title\">Order summary</h2>\n" +
3649
+ " <a href=\"/cart\" class=\"checkout-page__edit-cart\">Edit cart</a>\n" +
3650
+ " </div>\n" +
3651
+ " <ul class=\"checkout-page__lines\">RAW_SUMMARY_LINES</ul>\n" +
3652
+ " <dl class=\"totals-list\">\n" +
3653
+ " <div class=\"totals-list__grand\"><dt>Subtotal</dt><dd>{{subtotal}}</dd></div>\n" +
3654
+ " </dl>\n" +
3655
+ " <p class=\"cart-page__note\">Tax and shipping are calculated on the next step. Payment runs through Stripe.</p>\n" +
3656
+ " </aside>\n" +
3657
+ " </div>\n" +
3439
3658
  "</section>\n";
3440
3659
 
3660
+ // One order-summary line item in the checkout rail: thumbnail + title +
3661
+ // qty + line total, mirroring the cart row but compact. `l` is the raw
3662
+ // cart line; `lookup` is the variant_id → { product, hero_media } map the
3663
+ // route passes (same shape renderCart uses). Missing lookup entries fall
3664
+ // back to the SKU title + a dashed placeholder tile.
3665
+ function _checkoutSummaryLine(l, lookup, assetPrefix, fmt) {
3666
+ var esc = b.template.escapeHtml;
3667
+ var match = lookup[l.variant_id] || null;
3668
+ var prod = match && match.product;
3669
+ var hero = match && match.hero_media;
3670
+ var title = (prod && prod.title) || l.sku;
3671
+ var lineTotal = fmt(l.line_total_minor || (l.qty * l.unit_amount_minor), l.unit_currency);
3672
+ var thumb = (hero && hero.r2_key)
3673
+ ? "<span class=\"checkout-line__thumb\"><img src=\"" + esc(assetPrefix + hero.r2_key) + "\" alt=\"" + esc(hero.alt_text || title) + "\" loading=\"lazy\"></span>"
3674
+ : "<span class=\"checkout-line__thumb checkout-line__thumb--empty\" aria-hidden=\"true\"></span>";
3675
+ return "<li class=\"checkout-line\">" +
3676
+ thumb +
3677
+ "<span class=\"checkout-line__meta\">" +
3678
+ "<span class=\"checkout-line__title\">" + esc(title) + "</span>" +
3679
+ "<span class=\"checkout-line__qty\">Qty " + esc(String(l.qty)) + "</span>" +
3680
+ "</span>" +
3681
+ "<span class=\"checkout-line__total price\">" + esc(lineTotal) + "</span>" +
3682
+ "</li>";
3683
+ }
3684
+
3441
3685
  // Redeem-points-at-checkout field — appended to the checkout form for a
3442
3686
  // signed-in customer with a spendable balance. The customer types how
3443
3687
  // many points to spend; the server caps the credit at the order total
@@ -3465,6 +3709,8 @@ function renderCheckoutForm(opts) {
3465
3709
  var lines = opts.lines || [];
3466
3710
  var totals = opts.totals || { subtotal_minor: 0, currency: "USD" };
3467
3711
  var shopName = opts.shop_name || "blamejs.shop";
3712
+ var assetPrefix = opts.asset_prefix || "/assets/";
3713
+ var lookup = opts.product_lookup || {};
3468
3714
  var subtotal = pricing.format(totals.subtotal_minor, totals.currency);
3469
3715
  if (opts.theme) {
3470
3716
  return opts.theme.render("checkout", {
@@ -3475,8 +3721,23 @@ function renderCheckoutForm(opts) {
3475
3721
  asset_css_main: opts.theme.assetUrl("css/main.css"),
3476
3722
  });
3477
3723
  }
3724
+ // Order-summary line items in the sticky rail — same thumbnail + title
3725
+ // pattern as the cart, compact. Formatted in the order's own currency
3726
+ // (the checkout total is computed server-side; no display-currency
3727
+ // conversion happens at this step).
3728
+ var summaryLines = lines.map(function (l) {
3729
+ return _checkoutSummaryLine(l, lookup, assetPrefix, pricing.format);
3730
+ }).join("");
3731
+ // A coded gift-card / loyalty error from a rejected POST re-renders the
3732
+ // form with the message inline (role="alert") rather than dead-ending on
3733
+ // a separate error page — the shopper fixes the bad code in place.
3734
+ var inlineError = opts.inline_error
3735
+ ? "<p class=\"auth-form__message auth-form__message--err\" role=\"alert\">" + b.template.escapeHtml(String(opts.inline_error)) + "</p>"
3736
+ : "";
3478
3737
  var body = _render(CHECKOUT_PAGE, { subtotal: subtotal })
3479
- .replace("RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill));
3738
+ .replace("RAW_INLINE_ERROR", inlineError)
3739
+ .replace("RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill))
3740
+ .replace("RAW_SUMMARY_LINES", summaryLines);
3480
3741
  // Signed-in customer with a spendable points balance — surface a
3481
3742
  // redeem-at-checkout field. The block is appended as raw HTML (the
3482
3743
  // balance + value are numbers we control, the conversion ratio is the
@@ -3839,6 +4100,7 @@ var CART_PAGE =
3839
4100
  " <li aria-current=\"page\">Cart</li>\n" +
3840
4101
  " </ol>\n" +
3841
4102
  " </nav>\n" +
4103
+ "RAW_CART_NOTICE" +
3842
4104
  " <header class=\"section-head\">\n" +
3843
4105
  " <p class=\"eyebrow\">Your cart</p>\n" +
3844
4106
  " <h1 class=\"section-head__title\">Review your items</h1>\n" +
@@ -3851,6 +4113,7 @@ var CART_PAGE =
3851
4113
  " <tbody>{{line_rows}}</tbody>\n" +
3852
4114
  " </table>\n" +
3853
4115
  " </div>\n" +
4116
+ " <a href=\"/\" class=\"btn-ghost cart-page__continue\">← Continue shopping</a>\n" +
3854
4117
  " </div>\n" +
3855
4118
  " <aside class=\"cart-page__summary\">\n" +
3856
4119
  " <h2 class=\"pdp__variants-title\">Order summary</h2>\n" +
@@ -3863,6 +4126,16 @@ var CART_PAGE =
3863
4126
  " </div>\n" +
3864
4127
  "</section>\n";
3865
4128
 
4129
+ // The "added to cart" status banner shown after a POST /cart/lines
4130
+ // redirect (`?added=1`). role="status" so a screen reader announces it
4131
+ // without stealing focus; the dismiss link drops the query param so a
4132
+ // refresh doesn't re-announce. Container-only — the edge serves the
4133
+ // cookie-less empty-cart view, never a post-add render.
4134
+ var CART_ADDED_NOTICE =
4135
+ " <p class=\"cart-page__added\" role=\"status\">\n" +
4136
+ " <span>Added to cart.</span> <a href=\"/cart\">Dismiss</a>\n" +
4137
+ " </p>\n";
4138
+
3866
4139
  var CART_EMPTY_PAGE =
3867
4140
  "<section class=\"cart-page cart-page--empty\">\n" +
3868
4141
  " <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
@@ -3885,6 +4158,40 @@ var CART_EMPTY_PAGE =
3885
4158
  " </div>\n" +
3886
4159
  "</section>\n";
3887
4160
 
4161
+ // Styled, recoverable error page for a cart/checkout dead-end (a bad add,
4162
+ // a session that vanished, a non-coded checkout failure). Reuses the
4163
+ // cart-empty card so the failure reads as a designed state, not raw text —
4164
+ // with the human-readable reason + a recovery link. `opts.reason` is the
4165
+ // message, `opts.back_href`/`opts.back_label` the recovery action (default
4166
+ // "Back to cart"), and `opts.eyebrow` the kicker (default "Cart").
4167
+ function renderCheckoutError(opts) {
4168
+ opts = opts || {};
4169
+ var esc = b.template.escapeHtml;
4170
+ var shopName = opts.shop_name || "blamejs.shop";
4171
+ var eyebrow = opts.eyebrow || "Cart";
4172
+ var title = opts.title_text || "Something went wrong";
4173
+ var reason = opts.reason || "We couldn't process that request. Your cart is saved — please try again.";
4174
+ var backHref = opts.back_href || "/cart";
4175
+ var backLabel = opts.back_label || "Back to cart";
4176
+ var secondary = opts.secondary_href
4177
+ ? " <a href=\"" + esc(opts.secondary_href) + "\" class=\"btn-ghost\">" + esc(opts.secondary_label || "Keep browsing") + "</a>"
4178
+ : " <a href=\"/\" class=\"btn-ghost\">Keep browsing</a>";
4179
+ var body =
4180
+ "<section class=\"cart-page cart-page--empty\"><div class=\"cart-empty\"><div class=\"cart-empty__card\">" +
4181
+ "<p class=\"eyebrow cart-empty__eyebrow\">" + esc(eyebrow) + "</p>" +
4182
+ "<h1 class=\"cart-empty__title\">" + esc(title) + "</h1>" +
4183
+ "<p class=\"cart-empty__lede\">" + esc(reason) + "</p>" +
4184
+ "<div class=\"cart-empty__cta\"><a class=\"btn-primary\" href=\"" + esc(backHref) + "\">" + esc(backLabel) + "</a>" + secondary + "</div>" +
4185
+ "</div></div></section>";
4186
+ return _wrap({
4187
+ title: opts.title || "Checkout",
4188
+ shop_name: shopName,
4189
+ theme_css: opts.theme_css,
4190
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
4191
+ body: body,
4192
+ });
4193
+ }
4194
+
3888
4195
  function renderCart(opts) {
3889
4196
  if (!opts) throw new TypeError("storefront.renderCart: opts required");
3890
4197
  var lines = opts.lines || [];
@@ -3965,11 +4272,15 @@ function renderCart(opts) {
3965
4272
  " <p class=\"cart-page__note\">Tax and shipping are calculated on the next step. Payment runs through Stripe.</p>\n"
3966
4273
  : " <button type=\"button\" class=\"btn-primary cart-page__checkout\" disabled aria-disabled=\"true\">Checkout unavailable</button>\n" +
3967
4274
  " <p class=\"cart-page__note cart-page__note--warn\" role=\"status\">Online checkout isn't set up for this store yet — payments aren't configured. Your cart is saved; please check back soon.</p>\n";
4275
+ // Post-add confirmation banner — rendered only on the `?added=1`
4276
+ // redirect from POST /cart/lines so the shopper gets explicit feedback
4277
+ // their item landed (the audit found the silent 303 left no cue).
4278
+ var notice = opts.added ? CART_ADDED_NOTICE : "";
3968
4279
  body = _render(CART_PAGE, {
3969
4280
  line_rows: "RAW_LINES",
3970
4281
  subtotal: subtotal,
3971
4282
  total: total,
3972
- }).replace("RAW_LINES", rows).replace("RAW_CHECKOUT_CTA", checkoutCta);
4283
+ }).replace("RAW_LINES", rows).replace("RAW_CHECKOUT_CTA", checkoutCta).replace("RAW_CART_NOTICE", notice);
3973
4284
  }
3974
4285
  return _wrap(Object.assign({
3975
4286
  title: "Cart",
@@ -5729,7 +6040,7 @@ function mount(router, deps) {
5729
6040
  }));
5730
6041
  }
5731
6042
  var ccy = await _currencyForReq(req);
5732
- 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));
5733
6044
  _send(res, 200, html);
5734
6045
  });
5735
6046
 
@@ -5860,7 +6171,7 @@ function mount(router, deps) {
5860
6171
  corrected_query: correctedQ,
5861
6172
  shop_name: shopName,
5862
6173
  cart_count: cartCount,
5863
- }, ccy)));
6174
+ }, _requestUrls(req), ccy)));
5864
6175
  });
5865
6176
 
5866
6177
  router.get("/products/:slug", async function (req, res) {
@@ -5870,9 +6181,22 @@ function mount(router, deps) {
5870
6181
  if (!product) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
5871
6182
  var variants = await deps.catalog.variants.listForProduct(product.id);
5872
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 = {};
5873
6191
  for (var i = 0; i < variants.length; i += 1) {
5874
6192
  var p = await deps.catalog.prices.current(variants[i].id, "USD");
5875
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
+ }
5876
6200
  }
5877
6201
  // Media — first row drives the hero image, the next three feed
5878
6202
  // the thumbnail strip. `listForProduct` is product-level only;
@@ -5959,6 +6283,7 @@ function mount(router, deps) {
5959
6283
  variants: variants,
5960
6284
  prices: prices,
5961
6285
  media: media,
6286
+ inventory: inventory,
5962
6287
  review_summary: reviewSummary,
5963
6288
  reviews: reviewRows,
5964
6289
  review_cta: reviewCta,
@@ -5970,7 +6295,7 @@ function mount(router, deps) {
5970
6295
  shop_name: shopName,
5971
6296
  cart_count: cartCount,
5972
6297
  theme: theme,
5973
- }, ccy));
6298
+ }, _requestUrls(req), ccy));
5974
6299
  _send(res, 200, html);
5975
6300
  });
5976
6301
 
@@ -6245,9 +6570,9 @@ function mount(router, deps) {
6245
6570
  if (cats[i].active) active.push(cats[i]);
6246
6571
  }
6247
6572
  var cartCount = await _cartCountForReq(req);
6248
- _send(res, 200, renderCategoryIndex({
6573
+ _send(res, 200, renderCategoryIndex(Object.assign({
6249
6574
  categories: active, shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
6250
- }));
6575
+ }, _requestUrls(req))));
6251
6576
  });
6252
6577
 
6253
6578
  router.get("/categories/:slug", async function (req, res) {
@@ -6277,10 +6602,10 @@ function mount(router, deps) {
6277
6602
  if (children[i].active) activeChildren.push(children[i]);
6278
6603
  }
6279
6604
  var cartCount = await _cartCountForReq(req);
6280
- _send(res, 200, renderCategory({
6605
+ _send(res, 200, renderCategory(Object.assign({
6281
6606
  category: cat, breadcrumbs: crumbs, children: activeChildren,
6282
6607
  shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
6283
- }));
6608
+ }, _requestUrls(req))));
6284
6609
  });
6285
6610
  }
6286
6611
 
@@ -6302,6 +6627,12 @@ function mount(router, deps) {
6302
6627
  router.get("/cart", async function (req, res) {
6303
6628
  var ccy = await _currencyForReq(req);
6304
6629
  var sid = _readSidCookie(req);
6630
+ // `?added=1` after a POST /cart/lines redirect — drives the
6631
+ // "Added to cart" status banner. Read from the parsed query when the
6632
+ // router populated it, else from the raw URL.
6633
+ var cartUrl = req.url ? new URL(req.url, "http://localhost") : null;
6634
+ var added = (req.query && req.query.added === "1") ||
6635
+ (cartUrl && cartUrl.searchParams.get("added") === "1") || false;
6305
6636
  if (!sid) {
6306
6637
  return _send(res, 200, renderCart(Object.assign({
6307
6638
  lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
@@ -6346,6 +6677,7 @@ function mount(router, deps) {
6346
6677
  product_lookup: productLookup,
6347
6678
  can_save: !!(deps.saveForLater && deps.customers),
6348
6679
  checkout_available: !!(deps.checkout && deps.order),
6680
+ added: added,
6349
6681
  shop_name: shopName,
6350
6682
  theme: theme,
6351
6683
  }, ccy)));
@@ -6387,20 +6719,26 @@ function mount(router, deps) {
6387
6719
  }
6388
6720
 
6389
6721
  if (deps.checkout && deps.order) {
6390
- router.get("/checkout", async function (req, res) {
6391
- var sid = _readSidCookie(req);
6392
- if (!sid) return _send(res, 303, "<a href=\"/cart\">Cart is empty</a>"), res.setHeader && res.setHeader("location", "/cart");
6393
- var c = await deps.cart.bySession(sid);
6394
- if (!c) {
6395
- res.status(303); res.setHeader && res.setHeader("location", "/cart");
6396
- return res.end ? res.end() : res.send("");
6397
- }
6398
- var lines = await deps.cart.listLines(c.id);
6399
- if (!lines.length) {
6400
- res.status(303); res.setHeader && res.setHeader("location", "/cart");
6401
- return res.end ? res.end() : res.send("");
6402
- }
6722
+ // Build the renderCheckoutForm() opts for an active cart `c`: repriced
6723
+ // lines, totals, the thumbnail lookup, and the signed-in customer's
6724
+ // loyalty balance + prefill. Shared by the GET handler and the POST
6725
+ // catch (so a rejected gift-card / loyalty code re-renders the same
6726
+ // form inline instead of dead-ending). `inlineError` is the optional
6727
+ // message to surface at the top of the form.
6728
+ async function _checkoutRenderOpts(req, c, lines, inlineError) {
6403
6729
  var totals = pricing.totals(c, lines, {});
6730
+ // variant_id → { product, hero_media } lookup for the summary
6731
+ // thumbnails + titles — same shape (and caching) the cart route uses.
6732
+ var checkoutLookup = {};
6733
+ for (var li = 0; li < lines.length; li += 1) {
6734
+ var lvId = lines[li].variant_id;
6735
+ if (checkoutLookup[lvId]) continue;
6736
+ var lv = await deps.catalog.variants.get(lvId);
6737
+ if (!lv) { checkoutLookup[lvId] = null; continue; }
6738
+ var lprod = await deps.catalog.products.get(lv.product_id);
6739
+ var lmedia = await deps.catalog.media.listForProduct(lv.product_id);
6740
+ checkoutLookup[lvId] = { product: lprod, hero_media: lmedia.length ? lmedia[0] : null };
6741
+ }
6404
6742
  // A signed-in customer drives two best-effort lookups, both keyed
6405
6743
  // off the same auth env: the loyalty balance (for the redeem field)
6406
6744
  // and the default shipping address (to pre-fill the form). Either
@@ -6438,24 +6776,54 @@ function mount(router, deps) {
6438
6776
  }
6439
6777
  } catch (_e) { prefill = null; }
6440
6778
  }
6441
- _send(res, 200, renderCheckoutForm({
6779
+ return {
6442
6780
  lines: lines, totals: totals, shop_name: shopName, theme: theme,
6781
+ product_lookup: checkoutLookup,
6443
6782
  paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
6444
6783
  loyalty_balance: loyaltyBalance,
6445
6784
  loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
6446
6785
  prefill: prefill,
6447
- }));
6786
+ inline_error: inlineError || null,
6787
+ };
6788
+ }
6789
+
6790
+ router.get("/checkout", async function (req, res) {
6791
+ var sid = _readSidCookie(req);
6792
+ if (!sid) return _send(res, 303, "<a href=\"/cart\">Cart is empty</a>"), res.setHeader && res.setHeader("location", "/cart");
6793
+ var c = await deps.cart.bySession(sid);
6794
+ if (!c) {
6795
+ res.status(303); res.setHeader && res.setHeader("location", "/cart");
6796
+ return res.end ? res.end() : res.send("");
6797
+ }
6798
+ var rawLines = await deps.cart.listLines(c.id);
6799
+ if (!rawLines.length) {
6800
+ res.status(303); res.setHeader && res.setHeader("location", "/cart");
6801
+ return res.end ? res.end() : res.send("");
6802
+ }
6803
+ // Reprice the lines through the active quantity-break (same as the
6804
+ // cart page) so the summary's per-line totals + subtotal match what
6805
+ // the shopper saw on /cart.
6806
+ var lines = await _repriceCartLines(rawLines);
6807
+ _send(res, 200, renderCheckoutForm(await _checkoutRenderOpts(req, c, lines, null)));
6448
6808
  });
6449
6809
 
6450
6810
  router.post("/checkout", async function (req, res) {
6451
6811
  var body = req.body || {};
6452
6812
  var sid = _readSidCookie(req);
6453
6813
  if (!sid) {
6454
- res.status(400); return res.end ? res.end("No session") : res.send("No session");
6814
+ return _send(res, 400, renderCheckoutError({
6815
+ shop_name: shopName, theme: theme, eyebrow: "Checkout",
6816
+ title_text: "Your session expired",
6817
+ reason: "We couldn't find an active cart session. Open your cart and continue from there.",
6818
+ }));
6455
6819
  }
6456
6820
  var c = await deps.cart.bySession(sid);
6457
6821
  if (!c) {
6458
- res.status(400); return res.end ? res.end("No cart") : res.send("No cart");
6822
+ return _send(res, 400, renderCheckoutError({
6823
+ shop_name: shopName, theme: theme, eyebrow: "Checkout",
6824
+ title_text: "Your cart couldn't be found",
6825
+ reason: "We couldn't find an active cart for this session. Open your cart and continue from there.",
6826
+ }));
6459
6827
  }
6460
6828
  // Defensive cart-state guard — if the cart has already been
6461
6829
  // converted (e.g. duplicate-submit on POST refresh), redirect
@@ -6509,10 +6877,33 @@ function mount(router, deps) {
6509
6877
  // GIFTCARD_* code and the loyalty errors a LOYALTY_* code so a
6510
6878
  // fat-fingered value re-prompts rather than 500-ing checkout.
6511
6879
  var code = (e && typeof e.code === "string") ? e.code : "";
6512
- var clientErr = (e instanceof TypeError) || code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0;
6513
- res.status(clientErr ? 400 : 500);
6514
6880
  var msg = (e && e.message) || "checkout failed";
6515
- return res.end ? res.end(msg) : res.send(msg);
6881
+ // A coded gift-card / loyalty error is something the shopper can
6882
+ // fix in place — re-render the checkout form with the message
6883
+ // inline (preserving the cart + their prefilled fields where
6884
+ // possible) rather than dead-ending on a separate page.
6885
+ if (code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0) {
6886
+ try {
6887
+ var coLines = await _repriceCartLines(await deps.cart.listLines(c.id));
6888
+ if (coLines.length) {
6889
+ return _send(res, 400, renderCheckoutForm(await _checkoutRenderOpts(req, c, coLines, msg)));
6890
+ }
6891
+ } catch (_re) { /* fall through to the styled error page */ }
6892
+ }
6893
+ // A malformed address shape (TypeError) is still the shopper's to
6894
+ // fix; anything else is a server-side failure. Either way, render a
6895
+ // styled, recoverable page rather than raw text — back to the cart,
6896
+ // or back to the shipping form to re-enter the address.
6897
+ var clientErr = (e instanceof TypeError);
6898
+ return _send(res, clientErr ? 400 : 500, renderCheckoutError({
6899
+ shop_name: shopName, theme: theme, eyebrow: "Checkout",
6900
+ title_text: clientErr ? "We couldn't process your shipping details" : "Checkout didn't go through",
6901
+ reason: clientErr
6902
+ ? "Some shipping details couldn't be read: " + msg + " Go back and check the address fields."
6903
+ : "Something went wrong completing your order. Your cart is saved — please try again. (" + msg + ")",
6904
+ back_href: "/checkout", back_label: "Edit shipping",
6905
+ secondary_href: "/cart", secondary_label: "Back to cart",
6906
+ }));
6516
6907
  }
6517
6908
  });
6518
6909
 
@@ -8510,18 +8901,32 @@ function mount(router, deps) {
8510
8901
  var qtyRaw = body.qty;
8511
8902
  var qty = parseInt(qtyRaw, 10);
8512
8903
  if (!variantId || !Number.isFinite(qty) || qty < 1 || qty > 99) {
8513
- res.status(400);
8514
- return res.end ? res.end("Invalid request") : res.send("Invalid request");
8904
+ return _send(res, 400, renderCheckoutError({
8905
+ shop_name: shopName, theme: theme,
8906
+ title_text: "We couldn't add that item",
8907
+ reason: "The product or quantity in that request wasn't valid. Pick the item again from its page.",
8908
+ back_href: "/cart", back_label: "Back to cart",
8909
+ }));
8515
8910
  }
8516
8911
  var resolved = await _getOrCreateCart(req, res, "USD");
8517
8912
  try {
8518
8913
  await deps.cart.addLine(resolved.cart.id, { variant_id: variantId, qty: qty });
8519
8914
  } catch (e) {
8520
- res.status(e instanceof TypeError ? 400 : 500);
8521
- return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
8915
+ var addMsg = (e && e.message) || "Error";
8916
+ return _send(res, e instanceof TypeError ? 400 : 500, renderCheckoutError({
8917
+ shop_name: shopName, theme: theme,
8918
+ title_text: "We couldn't add that item",
8919
+ reason: (e instanceof TypeError)
8920
+ ? "That item couldn't be added: " + addMsg
8921
+ : "Something went wrong adding that item to your cart. Please try again.",
8922
+ back_href: "/cart", back_label: "Back to cart",
8923
+ }));
8522
8924
  }
8925
+ // `?added=1` so the cart page can confirm the item landed (the page
8926
+ // surfaces an "Added to cart" status banner). Still a 303 so a refresh
8927
+ // re-issues the GET, not the POST.
8523
8928
  res.status(303);
8524
- res.setHeader && res.setHeader("location", "/cart");
8929
+ res.setHeader && res.setHeader("location", "/cart?added=1");
8525
8930
  res.end ? res.end() : res.send("");
8526
8931
  });
8527
8932
 
@@ -8914,8 +9319,12 @@ function mount(router, deps) {
8914
9319
  var hostHeader = req.headers && (req.headers.host || req.headers.Host) || "";
8915
9320
  var origin = hostHeader ? ("https://" + hostHeader) : "";
8916
9321
  var urls = [];
8917
- urls.push({ loc: origin + "/", changefreq: "daily", priority: "1.0" });
8918
- 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" });
8919
9328
  try {
8920
9329
  var page = await deps.catalog.products.list({ status: "active", limit: 1000 });
8921
9330
  for (var i = 0; i < page.rows.length; i += 1) {
@@ -8929,6 +9338,38 @@ function mount(router, deps) {
8929
9338
  });
8930
9339
  }
8931
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
+ }
8932
9373
  var body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
8933
9374
  "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
8934
9375
  for (var u = 0; u < urls.length; u += 1) {
@@ -8985,6 +9426,7 @@ module.exports = {
8985
9426
  renderProduct: renderProduct,
8986
9427
  renderCart: renderCart,
8987
9428
  renderCheckoutForm: renderCheckoutForm,
9429
+ renderCheckoutError: renderCheckoutError,
8988
9430
  renderGiftCardBalance: renderGiftCardBalance,
8989
9431
  renderPayPage: renderPayPage,
8990
9432
  renderOrder: renderOrder,
@@ -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.10",
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": {