@blamejs/blamejs-shop 0.3.51 → 0.3.53

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/lib/storefront.js CHANGED
@@ -1894,16 +1894,35 @@ function _buildBuyBox(variants, escAttr, availability, headlinePrice) {
1894
1894
  var soldOutRowBtn =
1895
1895
  "<button type=\"submit\" class=\"btn-primary btn-primary--sm\" disabled aria-disabled=\"true\">Out of stock</button>";
1896
1896
 
1897
+ // "Notify me when back in stock" — a cookie-less, edge-cache-safe form
1898
+ // posting to the DISTINCT CSRF-exempt action `/stock-alert/subscribe`
1899
+ // (NOT /products/:slug/notify — that would over-exempt the token-required
1900
+ // review + question POSTs). The action is in EDGE_POST_PATHS, so the
1901
+ // container's `_injectCsrfFields` leaves it un-tokened, keeping this markup
1902
+ // byte-identical to the worker twin. sku/variant are catalog data; escape
1903
+ // anyway (escape-by-default). DUAL-RENDER: this helper is byte-identical to
1904
+ // worker/render/product.js#_buildBuyBox._notifyForm.
1905
+ function _notifyForm(sku, variantId) {
1906
+ return "<form class=\"pdp__notify\" method=\"post\" action=\"/stock-alert/subscribe\">\n" +
1907
+ " <input type=\"hidden\" name=\"sku\" value=\"" + escAttr(sku) + "\">\n" +
1908
+ (variantId ? " <input type=\"hidden\" name=\"variant_id\" value=\"" + escAttr(variantId) + "\">\n" : "") +
1909
+ " <label class=\"pdp__notify-label\" for=\"notify-email-" + escAttr(sku) + "\">Email me when this is back in stock</label>\n" +
1910
+ " <input id=\"notify-email-" + escAttr(sku) + "\" type=\"email\" name=\"email\" required placeholder=\"you@example.com\" autocomplete=\"email\">\n" +
1911
+ " <button type=\"submit\" class=\"btn-secondary btn-primary--sm\">Notify me</button>\n" +
1912
+ " </form>";
1913
+ }
1914
+
1897
1915
  // Many variants → keep the compact table (still a per-row add form).
1898
1916
  if (variants.length > BUYBOX_CHIP_LIMIT) {
1899
1917
  var rows = variants.map(function (v) {
1900
1918
  var row = _render(VARIANT_ROW, { title: v.title, sku: v.sku, price: v.price, variant_id: v.id });
1901
1919
  // When the product is out of stock, swap each per-row add button for
1902
- // the disabled control so no row offers an active purchase.
1920
+ // the disabled control + a per-row notify form so no row offers an
1921
+ // active purchase but every sold-out SKU offers the alert.
1903
1922
  if (!inStock) {
1904
1923
  row = row.replace(
1905
1924
  "<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Add to cart</button>",
1906
- soldOutRowBtn);
1925
+ soldOutRowBtn + _notifyForm(v.sku, v.id));
1907
1926
  }
1908
1927
  return row;
1909
1928
  }).join("");
@@ -1947,6 +1966,13 @@ function _buildBuyBox(variants, escAttr, availability, headlinePrice) {
1947
1966
  ? "<button type=\"submit\" class=\"btn-primary cart-page__checkout\">$ add to cart</button>"
1948
1967
  : soldOutBtn;
1949
1968
 
1969
+ // Out-of-stock chip/single buy box → the notify form sits OUTSIDE the
1970
+ // add-to-cart form (its own form element, distinct action), after the
1971
+ // sold-out control. Keyed to the LEAD variant's SKU — the chip radio does
1972
+ // not JS-swap the hidden field (Trusted Types / no innerHTML island), so a
1973
+ // shopper subscribes against the lead SKU. Documented limitation.
1974
+ var notifyBlock = inStock ? "" : ("\n " + _notifyForm(lead.sku, lead.id));
1975
+
1950
1976
  var headline = (headlinePrice != null && headlinePrice !== "") ? headlinePrice : lead.price;
1951
1977
  return "<div class=\"pdp__buybox\">\n" +
1952
1978
  " <p class=\"featured-product__price\">" + escAttr(headline) + "</p>\n" +
@@ -1955,7 +1981,7 @@ function _buildBuyBox(variants, escAttr, availability, headlinePrice) {
1955
1981
  " <label class=\"pdp__variants-title\" for=\"buybox-qty\">Quantity</label>\n" +
1956
1982
  " <input id=\"buybox-qty\" type=\"number\" name=\"qty\" value=\"1\" min=\"1\" max=\"99\" class=\"variant-row__qty\" aria-label=\"Quantity\">\n" +
1957
1983
  " " + addControl + "\n" +
1958
- " </form>\n" +
1984
+ " </form>" + notifyBlock + "\n" +
1959
1985
  " </div>\n" +
1960
1986
  " " + trustLine;
1961
1987
  }
@@ -6552,12 +6578,16 @@ function renderPayPage(opts) {
6552
6578
  pk: opts.publishable_key,
6553
6579
  client_secret: opts.client_secret,
6554
6580
  }).replace("RAW_PAY_SCRIPT", _islandScript("pay.js"));
6581
+ // Operator trust badges at the checkout placement (container-only — the pay
6582
+ // page isn't edge-cached). Pre-resolved + sanitized by the route; appended
6583
+ // after the pay card. Empty string when no badges / no dep.
6584
+ var payTrustBadges = typeof opts.trust_badges_html === "string" ? opts.trust_badges_html : "";
6555
6585
  return _wrap({
6556
6586
  title: "Pay",
6557
6587
  shop_name: shopName,
6558
6588
  cart_count: cartCount,
6559
6589
  theme_css: opts.theme_css,
6560
- body: body,
6590
+ body: body + payTrustBadges,
6561
6591
  });
6562
6592
  }
6563
6593
 
@@ -7046,12 +7076,18 @@ function renderOrder(opts) {
7046
7076
  "<div class=\"grid\">" + railCards + "</div>" +
7047
7077
  "</section>";
7048
7078
  }
7079
+ // Operator-authored trust badges at the order_confirmation placement
7080
+ // (container-only — this page isn't edge-cached). Pre-resolved + escaped by
7081
+ // the route (via _trustBadgesHtml); spliced raw so a `$` in a sanitized SVG
7082
+ // can't trip String.replace dollar substitution. Empty string when no badges
7083
+ // / no dep.
7084
+ var trustBadgesHtml = typeof opts.trust_badges_html === "string" ? opts.trust_badges_html : "";
7049
7085
  return _wrap({
7050
7086
  title: "Order " + o.id,
7051
7087
  shop_name: shopName,
7052
7088
  cart_count: cartCount,
7053
7089
  theme_css: opts.theme_css,
7054
- body: body + railHtml,
7090
+ body: body + railHtml + trustBadgesHtml,
7055
7091
  });
7056
7092
  }
7057
7093
 
@@ -7562,6 +7598,171 @@ function renderUnsubscribeResult(opts) {
7562
7598
  });
7563
7599
  }
7564
7600
 
7601
+ // ---- back-in-stock "Notify me" pages -----------------------------------
7602
+ //
7603
+ // Container-only (no edge twin) — these aren't edge-cached. Every page is
7604
+ // server-rendered, no auth, escape-by-default. The thank-you page renders
7605
+ // the SAME copy for new / already-pending / already-confirmed so the page
7606
+ // never reveals whether the address was already subscribed beyond friendly
7607
+ // copy. The confirm/unsubscribe pages render a clean outcome on a null/
7608
+ // invalid result rather than a leak or a 500.
7609
+
7610
+ function renderStockAlertThanks(opts) {
7611
+ opts = opts || {};
7612
+ var body =
7613
+ "<section class=\"newsletter-thanks\">" +
7614
+ "<div class=\"newsletter-thanks__card\">" +
7615
+ "<p class=\"eyebrow\">Back-in-stock alert</p>" +
7616
+ "<h1 class=\"newsletter-thanks__title\">Check your email.</h1>" +
7617
+ "<p class=\"newsletter-thanks__lede\">If that address can receive mail, we've sent a confirmation link. Click it and we'll email you once — the moment this item is back in stock. Already confirmed? You're all set.</p>" +
7618
+ "<div class=\"newsletter-thanks__cta\">" +
7619
+ "<a href=\"/\" class=\"btn-primary\">Back to the shop <span aria-hidden=\"true\">→</span></a>" +
7620
+ "</div>" +
7621
+ "</div>" +
7622
+ "</section>";
7623
+ return _wrap({
7624
+ title: "Back-in-stock alert",
7625
+ shop_name: opts.shop_name || "blamejs.shop",
7626
+ cart_count: opts.cart_count,
7627
+ theme_css: opts.theme_css,
7628
+ robots: "noindex",
7629
+ body: body,
7630
+ });
7631
+ }
7632
+
7633
+ function renderStockAlertError(opts) {
7634
+ opts = opts || {};
7635
+ var body =
7636
+ "<section class=\"newsletter-thanks\">" +
7637
+ "<div class=\"newsletter-thanks__card\">" +
7638
+ "<p class=\"eyebrow\">Back-in-stock alert</p>" +
7639
+ "<h1 class=\"newsletter-thanks__title\">Couldn't set that alert.</h1>" +
7640
+ "<p class=\"newsletter-thanks__lede\">" + b.template.escapeHtml(String(opts.message || "Check the email address and try again — only RFC-shape email addresses are accepted.")) + "</p>" +
7641
+ "<div class=\"newsletter-thanks__cta\">" +
7642
+ "<a href=\"/\" class=\"btn-primary\">Back to the shop <span aria-hidden=\"true\">→</span></a>" +
7643
+ "</div>" +
7644
+ "</div>" +
7645
+ "</section>";
7646
+ return _wrap({
7647
+ title: "Back-in-stock alert",
7648
+ shop_name: opts.shop_name || "blamejs.shop",
7649
+ cart_count: opts.cart_count,
7650
+ theme_css: opts.theme_css,
7651
+ robots: "noindex",
7652
+ body: body,
7653
+ });
7654
+ }
7655
+
7656
+ // The confirm-link landing page. `opts.outcome` is one of: "confirmed"
7657
+ // (we just stamped confirmed_at), "already-notified" (the alert already
7658
+ // fired — terminal), "invalid" (null / expired / unknown token). No token
7659
+ // is echoed back; the outcome page reveals nothing beyond friendly copy.
7660
+ function renderStockAlertConfirm(opts) {
7661
+ opts = opts || {};
7662
+ var heading, lede;
7663
+ if (opts.outcome === "confirmed") {
7664
+ heading = "You're all set.";
7665
+ lede = "Your back-in-stock alert is confirmed. We'll email you once — the moment this item returns. Nothing else.";
7666
+ } else if (opts.outcome === "already-notified") {
7667
+ heading = "You've already been notified.";
7668
+ lede = "This item was already back in stock and we emailed you about it. There's nothing more to do.";
7669
+ } else {
7670
+ heading = "This link is no longer valid.";
7671
+ lede = "We couldn't match this confirmation link to an alert. It may have expired, or the link may be incomplete. You can set a fresh alert from the product page.";
7672
+ }
7673
+ var body =
7674
+ "<section class=\"newsletter-thanks\">" +
7675
+ "<div class=\"newsletter-thanks__card\">" +
7676
+ "<p class=\"eyebrow\">Back-in-stock alert</p>" +
7677
+ "<h1 class=\"newsletter-thanks__title\">" + heading + "</h1>" +
7678
+ "<p class=\"newsletter-thanks__lede\">" + lede + "</p>" +
7679
+ "<div class=\"newsletter-thanks__cta\">" +
7680
+ "<a href=\"/\" class=\"btn-primary\">Back to the shop <span aria-hidden=\"true\">→</span></a>" +
7681
+ "</div>" +
7682
+ "</div>" +
7683
+ "</section>";
7684
+ return _wrap({
7685
+ title: "Back-in-stock alert",
7686
+ shop_name: opts.shop_name || "blamejs.shop",
7687
+ cart_count: opts.cart_count,
7688
+ theme_css: opts.theme_css,
7689
+ robots: "noindex",
7690
+ body: body,
7691
+ });
7692
+ }
7693
+
7694
+ // The unsubscribe confirm page (GET). A no-JS form POSTs the (email, sku,
7695
+ // variant) tuple back to /stock-alert/unsubscribe — a deliberate "yes,
7696
+ // stop these alerts" step (no link-prefetcher unsubscribe). The tuple
7697
+ // rides in hidden fields (HTML-escaped). This page is container-rendered
7698
+ // from a GET, so it CAN carry the `_csrf` token — the action is NOT in
7699
+ // EDGE_POST_PATHS, so `_injectCsrfFields` tokens it automatically.
7700
+ function renderStockAlertUnsubscribeConfirm(opts) {
7701
+ opts = opts || {};
7702
+ var email = typeof opts.email === "string" ? opts.email : "";
7703
+ var sku = typeof opts.sku === "string" ? opts.sku : "";
7704
+ var variantId = typeof opts.variant_id === "string" ? opts.variant_id : "";
7705
+ var body =
7706
+ "<section class=\"newsletter-thanks\">" +
7707
+ "<div class=\"newsletter-thanks__card\">" +
7708
+ "<p class=\"eyebrow\">Back-in-stock alert</p>" +
7709
+ "<h1 class=\"newsletter-thanks__title\">Stop this alert?</h1>" +
7710
+ "<p class=\"newsletter-thanks__lede\">Confirm below and we'll cancel your back-in-stock alert for this item. You can set it again any time from the product page.</p>" +
7711
+ "<form class=\"newsletter-unsub__form\" method=\"post\" action=\"/stock-alert/unsubscribe\">" +
7712
+ "<input type=\"hidden\" name=\"email\" value=\"" + _escAttr(email) + "\">" +
7713
+ "<input type=\"hidden\" name=\"sku\" value=\"" + _escAttr(sku) + "\">" +
7714
+ (variantId ? "<input type=\"hidden\" name=\"variant_id\" value=\"" + _escAttr(variantId) + "\">" : "") +
7715
+ "<div class=\"newsletter-thanks__cta\">" +
7716
+ "<button type=\"submit\" class=\"btn-primary\">Stop this alert</button>" +
7717
+ "<a href=\"/\" class=\"btn-ghost\">Keep me subscribed</a>" +
7718
+ "</div>" +
7719
+ "</form>" +
7720
+ "</div>" +
7721
+ "</section>";
7722
+ return _wrap({
7723
+ title: "Unsubscribe",
7724
+ shop_name: opts.shop_name || "blamejs.shop",
7725
+ cart_count: opts.cart_count,
7726
+ theme_css: opts.theme_css,
7727
+ robots: "noindex",
7728
+ body: body,
7729
+ });
7730
+ }
7731
+
7732
+ // The unsubscribe outcome page (POST). Idempotent — a missing row reads as
7733
+ // "you're unsubscribed" (re-clicking the link twice is not an error). A bad
7734
+ // shape reads as the generic non-leaking copy.
7735
+ function renderStockAlertUnsubscribeResult(opts) {
7736
+ opts = opts || {};
7737
+ var heading, lede;
7738
+ if (opts.outcome === "invalid") {
7739
+ heading = "This link isn't valid.";
7740
+ lede = "We couldn't match this unsubscribe link to an alert. It may already have been used, or the link may be incomplete.";
7741
+ } else {
7742
+ heading = "You're unsubscribed.";
7743
+ lede = "We won't email you about this item coming back in stock. Changed your mind? Set a fresh alert from the product page.";
7744
+ }
7745
+ var body =
7746
+ "<section class=\"newsletter-thanks\">" +
7747
+ "<div class=\"newsletter-thanks__card\">" +
7748
+ "<p class=\"eyebrow\">Back-in-stock alert</p>" +
7749
+ "<h1 class=\"newsletter-thanks__title\">" + heading + "</h1>" +
7750
+ "<p class=\"newsletter-thanks__lede\">" + lede + "</p>" +
7751
+ "<div class=\"newsletter-thanks__cta\">" +
7752
+ "<a href=\"/\" class=\"btn-primary\">Back to the shop <span aria-hidden=\"true\">→</span></a>" +
7753
+ "</div>" +
7754
+ "</div>" +
7755
+ "</section>";
7756
+ return _wrap({
7757
+ title: "Unsubscribe",
7758
+ shop_name: opts.shop_name || "blamejs.shop",
7759
+ cart_count: opts.cart_count,
7760
+ theme_css: opts.theme_css,
7761
+ robots: "noindex",
7762
+ body: body,
7763
+ });
7764
+ }
7765
+
7565
7766
  // ---- cookie preference center ------------------------------------------
7566
7767
 
7567
7768
  // The four toggleable categories + their operator-facing copy, mirroring
@@ -9065,6 +9266,42 @@ function mount(router, deps) {
9065
9266
  return lines.length;
9066
9267
  }
9067
9268
 
9269
+ // Resolve the active trust badges for a container-only placement and
9270
+ // concatenate each one's sanitized renderHtml. Fires an impression per
9271
+ // rendered badge (fire-and-forget — the counter is drop-silent on the hot
9272
+ // path; never await it into the response). Drop-silent on ANY read failure
9273
+ // (returns "") — badges are supplementary; an absent dep / unmigrated table
9274
+ // / read error must never 500 a checkout or order page. Placements wired
9275
+ // today: "checkout", "order_confirmation". The svg_payload was sanitized at
9276
+ // define time via b.guardSvg, so renderHtml's inline emit is safe.
9277
+ async function _trustBadgesHtml(placement, req) {
9278
+ if (!deps.trustBadges) return "";
9279
+ try {
9280
+ var active = await deps.trustBadges.activeForPlacement({ placement: placement });
9281
+ if (!Array.isArray(active) || active.length === 0) return "";
9282
+ var sid = null;
9283
+ try { sid = _readSidCookie(req); } catch (_e) { sid = null; }
9284
+ var parts = [];
9285
+ for (var i = 0; i < active.length; i += 1) {
9286
+ var slug = active[i].slug;
9287
+ var html;
9288
+ try { html = await deps.trustBadges.renderHtml({ slug: slug }); }
9289
+ catch (_e) { continue; /* drop-silent — skip a badge that fails to render */ }
9290
+ parts.push(html);
9291
+ // Fire-and-forget impression — the method is already drop-silent.
9292
+ try {
9293
+ var imp = deps.trustBadges.recordImpression({ slug: slug, placement: placement, session_id: sid || undefined });
9294
+ if (imp && typeof imp.then === "function") imp.then(function () {}, function () {});
9295
+ } catch (_e) { /* drop-silent — impression bump must not affect render */ }
9296
+ }
9297
+ if (!parts.length) return "";
9298
+ return "<section class=\"trust-badges trust-badges--" + b.template.escapeHtml(placement) +
9299
+ "\" aria-label=\"Trust badges\">" + parts.join("") + "</section>";
9300
+ } catch (_e) {
9301
+ return ""; // drop-silent — supplementary; never 500 the page
9302
+ }
9303
+ }
9304
+
9068
9305
  // ---- multi-currency display -------------------------------------------
9069
9306
  //
9070
9307
  // Opt-in: wired only when the operator supplies `deps.currencyDisplay`
@@ -10381,6 +10618,54 @@ function mount(router, deps) {
10381
10618
  return (await deps.catalog.batch.searchDecorate({ terms: terms, currency: "USD" })).rows;
10382
10619
  }
10383
10620
 
10621
+ // Operator-tunable rerank of the matched search universe through
10622
+ // searchRanking.applyToResults (pins first, then weighted score DESC).
10623
+ // The decorated rows carry `id` + `in_stock` (bool) + `price_minor` but NOT
10624
+ // `product_id`, which applyToResults requires — project each row with
10625
+ // `product_id: row.id` + a `signals` bag built from the decorated fields.
10626
+ // applyToResults returns rows that preserve every original field plus
10627
+ // product_id/_score/_pinned, so the returned array IS the reranked universe
10628
+ // (the renderer reads its existing fields; the extra keys are inert).
10629
+ //
10630
+ // NEVER-500: ranking is supplementary to search. A missing dep, no active
10631
+ // weight set, a bad/archived weight slug, or any throw → return the
10632
+ // original universe unchanged (drop-silent defensive read). applyToResults
10633
+ // with no active set + no slug is a safe no-op that preserves input order,
10634
+ // so the common "operator hasn't configured ranking" path is inert.
10635
+ //
10636
+ // NOT dual-render — the edge /search (worker/render/search.js) does NOT
10637
+ // rerank. Edge-cached search serves the default order; the container path
10638
+ // serves the ranked order. This deliberate non-parity matches the
10639
+ // synonyms/facets precedent (both container-only at the edge) — search order
10640
+ // is not a price/legal contract, so order differences are acceptable.
10641
+ async function _rerankUniverse(universe, query) {
10642
+ if (!deps.searchRanking || !Array.isArray(universe) || universe.length === 0) {
10643
+ return universe;
10644
+ }
10645
+ try {
10646
+ var projected = universe.map(function (r) {
10647
+ return Object.assign({}, r, {
10648
+ product_id: r.id,
10649
+ signals: {
10650
+ in_stock: r.in_stock === true,
10651
+ // price_minor is a pass-through integer signal (never divided —
10652
+ // no money arithmetic); a null price contributes nothing.
10653
+ price_minor: (typeof r.price_minor === "number" && isFinite(r.price_minor)) ? r.price_minor : 0,
10654
+ },
10655
+ });
10656
+ });
10657
+ var ranked = await deps.searchRanking.applyToResults({
10658
+ query: (typeof query === "string" && query.trim().length) ? query : null,
10659
+ results: projected,
10660
+ });
10661
+ return Array.isArray(ranked) && ranked.length === universe.length ? ranked : universe;
10662
+ } catch (_e) {
10663
+ // Bad weight slug, archived set, or any ranking failure → un-ranked
10664
+ // fallback. Never 500 the search page.
10665
+ return universe;
10666
+ }
10667
+ }
10668
+
10384
10669
  router.get("/search", async function (req, res) {
10385
10670
  var url = req.url ? new URL(req.url, "http://localhost") : null;
10386
10671
  var qRaw = url && url.searchParams.get("q");
@@ -10425,6 +10710,12 @@ function mount(router, deps) {
10425
10710
  // in server.js (the searchFacets primitive's `create`, with
10426
10711
  // the DB query handle pre-bound).
10427
10712
  var universe = await _facetableUniverse(terms);
10713
+ // Operator-tunable rerank — applied to the FULL matched universe
10714
+ // BEFORE the facet adapter windows it, so pins + weights reorder
10715
+ // across ALL pages (searchFacets.previewQuery filters in input order
10716
+ // and slices, preserving the reranked order). Container-only; never
10717
+ // 500s the search page (see _rerankUniverse).
10718
+ universe = await _rerankUniverse(universe, q);
10428
10719
  var facetCatalog = {
10429
10720
  // The primitive narrows in-memory and drops the focal facet
10430
10721
  // per option, so the adapter ignores applied_filters and
@@ -10454,6 +10745,8 @@ function mount(router, deps) {
10454
10745
  // matched universe gives the honest total; the page is the
10455
10746
  // windowed slice (the same page size the faceted path uses).
10456
10747
  var flatUniverse = await _facetableUniverse(terms);
10748
+ // Operator-tunable rerank before windowing — full control of order.
10749
+ flatUniverse = await _rerankUniverse(flatUniverse, q);
10457
10750
  totalCount = flatUniverse.length;
10458
10751
  page = _clampPage(page, totalCount, SEARCH_PAGE_SIZE);
10459
10752
  products = flatUniverse.slice((page - 1) * SEARCH_PAGE_SIZE, page * SEARCH_PAGE_SIZE);
@@ -11453,12 +11746,16 @@ function mount(router, deps) {
11453
11746
  // governs every OTHER route. setHeader OVERWRITES the app-level header
11454
11747
  // for this response only.
11455
11748
  res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(["stripe"]));
11749
+ // Operator trust badges at the checkout placement (container-only;
11750
+ // drop-silent → "" on any failure).
11751
+ var payTrustBadges = await _trustBadgesHtml("checkout", req);
11456
11752
  _send(res, 200, renderPayPage({
11457
11753
  order: o,
11458
11754
  client_secret: clientSecret,
11459
11755
  publishable_key: pk,
11460
11756
  shop_name: shopName,
11461
11757
  theme: theme,
11758
+ trust_badges_html: payTrustBadges,
11462
11759
  }));
11463
11760
  });
11464
11761
 
@@ -11570,11 +11867,15 @@ function mount(router, deps) {
11570
11867
  } catch (_e) { ratingRow = null; ratingEligible = false; }
11571
11868
  }
11572
11869
  var ordUrl = req.url ? new URL(req.url, "http://localhost") : null;
11870
+ // Operator trust badges at the order_confirmation placement (container-
11871
+ // only; drop-silent → "" on any failure).
11872
+ var ordTrustBadges = await _trustBadgesHtml("order_confirmation", req);
11573
11873
  _send(res, 200, renderOrder({
11574
11874
  order: o,
11575
11875
  product_lookup: productLookup,
11576
11876
  recommendations: recommendations,
11577
11877
  shipments: shipments,
11878
+ trust_badges_html: ordTrustBadges,
11578
11879
  rating: ratingRow,
11579
11880
  rating_eligible: ratingEligible,
11580
11881
  rating_notice: ordUrl ? _ratingNoticeFor(ordUrl.searchParams.get("rate_err")) : null,
@@ -16081,6 +16382,150 @@ function mount(router, deps) {
16081
16382
  });
16082
16383
  }
16083
16384
 
16385
+ // ---- back-in-stock "Notify me" ------------------------------------------
16386
+ //
16387
+ // The PDP buy box shows a "Notify me when back in stock" form on every
16388
+ // sold-out SKU (dual-rendered, edge-cache-safe). It posts to the distinct
16389
+ // CSRF-exempt action /stock-alert/subscribe (in EDGE_POST_PATHS). Double
16390
+ // opt-in: subscribe writes a pending row + returns a one-time plaintext
16391
+ // token, the confirmation email carries /stock-alert/confirm/<token>, and
16392
+ // the Worker cron sweep emails once when stock returns. Mount only when the
16393
+ // stockAlerts primitive is wired.
16394
+ if (deps.stockAlerts) {
16395
+ // Confirm-link base — SHOP_ORIGIN when present (set into sfDeps as
16396
+ // shop_origin in server.js), else the request Host (the email is best-
16397
+ // effort anyway; a relative path still resolves in a browser).
16398
+ function _stockAlertOrigin(req) {
16399
+ if (typeof deps.shop_origin === "string" && deps.shop_origin) {
16400
+ return deps.shop_origin.replace(/\/$/, "");
16401
+ }
16402
+ var host = req && req.headers && req.headers.host;
16403
+ return host ? "https://" + host : "";
16404
+ }
16405
+
16406
+ router.post("/stock-alert/subscribe", async function (req, res) {
16407
+ var body = req.body || {};
16408
+ var cartCount = 0;
16409
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
16410
+ try {
16411
+ var result = await deps.stockAlerts.subscribe({
16412
+ email: body.email,
16413
+ sku: body.sku,
16414
+ variant_id: (body.variant_id != null && body.variant_id !== "") ? body.variant_id : null,
16415
+ });
16416
+ // Only a brand-new subscription carries a plaintext token. Send the
16417
+ // confirmation email best-effort; a mailer hiccup must not 500 the
16418
+ // form (the row is already written). already-pending / already-
16419
+ // confirmed render the SAME thank-you copy — never reveal prior state.
16420
+ if (result && result.status === "subscribed" && result.confirmation_token && deps.email) {
16421
+ var origin = _stockAlertOrigin(req);
16422
+ var confirmUrl = origin + "/stock-alert/confirm/" + encodeURIComponent(result.confirmation_token);
16423
+ // Resolve the product title for the SKU (cheap indexed read; drop-
16424
+ // silent → fall back to the SKU as the title).
16425
+ var titleForSku = body.sku;
16426
+ try {
16427
+ if (deps.catalog && deps.catalog.products && typeof deps.catalog.products.bySku === "function") {
16428
+ var prodForSku = await deps.catalog.products.bySku(body.sku);
16429
+ if (prodForSku && prodForSku.title) titleForSku = prodForSku.title;
16430
+ }
16431
+ } catch (_e) { /* drop-silent — fall back to the SKU as the title */ }
16432
+ try {
16433
+ await deps.email.sendStockAlertConfirmation({
16434
+ to: body.email,
16435
+ product_title: titleForSku,
16436
+ sku: body.sku,
16437
+ confirm_url: confirmUrl,
16438
+ });
16439
+ } catch (_mailErr) { /* drop-silent — the row is written; the cron still fires on confirm */ }
16440
+ }
16441
+ return _send(res, 200, renderStockAlertThanks({ shop_name: shopName, cart_count: cartCount }));
16442
+ } catch (e) {
16443
+ // TypeError == operator/customer-fault input refusal (bad email / sku
16444
+ // shape) → 400 friendly page; everything else (D1 unreachable) → 500
16445
+ // non-leaking page.
16446
+ var isInputError = e instanceof TypeError;
16447
+ return _send(res, isInputError ? 400 : 500, renderStockAlertError({
16448
+ shop_name: shopName,
16449
+ cart_count: cartCount,
16450
+ message: isInputError ? "That doesn't look like a valid email address. Check the format and try again." : null,
16451
+ }));
16452
+ }
16453
+ });
16454
+
16455
+ router.get("/stock-alert/confirm/:token", async function (req, res) {
16456
+ var token = req.params && req.params.token;
16457
+ var cartCount = 0;
16458
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
16459
+ var outcome;
16460
+ try {
16461
+ var row = await deps.stockAlerts.confirm({ token: token });
16462
+ if (!row) outcome = "invalid";
16463
+ else if (row.notified_at != null) outcome = "already-notified";
16464
+ else outcome = "confirmed";
16465
+ } catch (e) {
16466
+ // A bad-shape token throws TypeError — render the generic non-leaking
16467
+ // "invalid" page rather than a 500 on a public, unauthenticated route.
16468
+ if (e instanceof TypeError) outcome = "invalid";
16469
+ else {
16470
+ _log.error("storefront stock-alert confirm failed", {
16471
+ route: "/stock-alert/confirm", request_id: (req && req.requestId) || null,
16472
+ err: (e && e.message) || String(e),
16473
+ });
16474
+ outcome = "invalid";
16475
+ }
16476
+ }
16477
+ return _send(res, 200, renderStockAlertConfirm({
16478
+ shop_name: shopName, cart_count: cartCount, outcome: outcome,
16479
+ }));
16480
+ });
16481
+
16482
+ // Unsubscribe — by (email, sku, variant) tuple carried in the link query
16483
+ // (stockAlerts has no unsubscribe-token API). GET renders a friendly
16484
+ // confirm page (token-carrying container form); POST consumes it.
16485
+ router.get("/stock-alert/unsubscribe", async function (req, res) {
16486
+ var q = (req.query && typeof req.query === "object") ? req.query : {};
16487
+ var cartCount = 0;
16488
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
16489
+ return _send(res, 200, renderStockAlertUnsubscribeConfirm({
16490
+ shop_name: shopName,
16491
+ cart_count: cartCount,
16492
+ email: typeof q.email === "string" ? q.email : "",
16493
+ sku: typeof q.sku === "string" ? q.sku : "",
16494
+ variant_id: typeof q.variant_id === "string" ? q.variant_id : "",
16495
+ }));
16496
+ });
16497
+
16498
+ router.post("/stock-alert/unsubscribe", async function (req, res) {
16499
+ var body = req.body || {};
16500
+ var cartCount = 0;
16501
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
16502
+ var outcome;
16503
+ try {
16504
+ // Idempotent — a missing row returns { removed: false }, which still
16505
+ // reads as "you're unsubscribed" (clicking the link twice is fine).
16506
+ await deps.stockAlerts.unsubscribe({
16507
+ email: body.email,
16508
+ sku: body.sku,
16509
+ variant_id: (body.variant_id != null && body.variant_id !== "") ? body.variant_id : null,
16510
+ });
16511
+ outcome = "unsubscribed";
16512
+ } catch (e) {
16513
+ // A bad-shape tuple throws TypeError → generic non-leaking page; a
16514
+ // real fault degrades the same way rather than 500-ing a public route.
16515
+ outcome = "invalid";
16516
+ if (!(e instanceof TypeError)) {
16517
+ _log.error("storefront stock-alert unsubscribe failed", {
16518
+ route: "/stock-alert/unsubscribe", request_id: (req && req.requestId) || null,
16519
+ err: (e && e.message) || String(e),
16520
+ });
16521
+ }
16522
+ }
16523
+ return _send(res, 200, renderStockAlertUnsubscribeResult({
16524
+ shop_name: shopName, cart_count: cartCount, outcome: outcome,
16525
+ }));
16526
+ });
16527
+ }
16528
+
16084
16529
  // ---- cookie consent -----------------------------------------------------
16085
16530
  //
16086
16531
  // GDPR (EU 2016/679 art. 6 + 7) + ePrivacy (2002/58/EC art. 5(3)) opt-in