@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 +4 -0
- package/lib/asset-manifest.json +3 -3
- package/lib/storefront.js +506 -64
- package/lib/translations.js +2 -2
- package/package.json +1 -1
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.
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
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-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
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=\"
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 & 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
|
-
"
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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":
|
|
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
|
-
" <
|
|
3428
|
-
"
|
|
3429
|
-
"
|
|
3430
|
-
"
|
|
3431
|
-
"
|
|
3432
|
-
"
|
|
3433
|
-
" <div><
|
|
3434
|
-
"
|
|
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
|
-
" <
|
|
3438
|
-
"
|
|
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("
|
|
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
|
-
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8514
|
-
|
|
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
|
-
|
|
8521
|
-
return res
|
|
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
|
-
|
|
8918
|
-
|
|
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,
|
package/lib/translations.js
CHANGED
|
@@ -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: "
|
|
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