@blamejs/blamejs-shop 0.3.52 → 0.3.54

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
  }
@@ -4075,6 +4101,57 @@ function renderReturns(opts) {
4075
4101
  });
4076
4102
  }
4077
4103
 
4104
+ // The signed-in customer's pickup (BOPIS) list. Each row links the parent
4105
+ // order and shows the FSM status + the scheduled window. Reuses the
4106
+ // return-card layout classes — no new CSS. location_code is operator free
4107
+ // text; the status is a fixed FSM enum — both escaped at the sink.
4108
+ function renderAccountPickups(opts) {
4109
+ opts = opts || {};
4110
+ var esc = b.template.escapeHtml;
4111
+ var list = opts.pickups || [];
4112
+ var rowsHtml = "";
4113
+ for (var i = 0; i < list.length; i += 1) {
4114
+ var p = list[i];
4115
+ var when = p.scheduled_window_start
4116
+ ? new Date(Number(p.scheduled_window_start)).toISOString().slice(0, 16).replace("T", " ")
4117
+ : "";
4118
+ rowsHtml +=
4119
+ "<li class=\"return-card\">" +
4120
+ "<div class=\"return-card__head\">" +
4121
+ "<a class=\"return-card__rma\" href=\"/orders/" + esc(String(p.order_id)) + "\"><code>" + esc(String(p.order_id).slice(0, 8)) + "</code></a>" +
4122
+ "<span class=\"return-status\">" + esc(_pickupStatusLabel(p.status)) + "</span>" +
4123
+ "</div>" +
4124
+ "<p class=\"return-card__meta\">" +
4125
+ "Location <code>" + esc(String(p.location_code)) + "</code>" +
4126
+ (when ? " &middot; <time>" + esc(when) + " UTC</time>" : "") +
4127
+ "</p>" +
4128
+ "</li>";
4129
+ }
4130
+ var inner = rowsHtml
4131
+ ? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
4132
+ : "<div class=\"account-empty\">" +
4133
+ "<p class=\"account-empty__lede\">No pickups yet. Choose “pick up in store” at checkout to schedule one.</p>" +
4134
+ "<a class=\"btn-secondary\" href=\"/account/orders\">View your orders &rarr;</a>" +
4135
+ "</div>";
4136
+ var body =
4137
+ "<section class=\"account-returns\">" +
4138
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
4139
+ "<li><a href=\"/account\">Account</a></li>" +
4140
+ "<li aria-current=\"page\">Pickups</li>" +
4141
+ "</ol></nav>" +
4142
+ "<h1 class=\"account-returns__title\">Pickups</h1>" +
4143
+ inner +
4144
+ "</section>";
4145
+ return _wrap({
4146
+ title: "Pickups",
4147
+ shop_name: opts.shop_name || "blamejs.shop",
4148
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
4149
+ theme_css: opts.theme_css,
4150
+ robots: "noindex",
4151
+ body: body,
4152
+ });
4153
+ }
4154
+
4078
4155
  // Customer-facing phrasing for a return label's carrier-scan status. The
4079
4156
  // return-labels module's FSM enum (issued / shipped / in_transit /
4080
4157
  // delivered / exception) is operator/carrier vocabulary; these read for
@@ -6268,6 +6345,53 @@ function _loyaltyCheckoutField(bal, perUsd) {
6268
6345
  "</label></div>";
6269
6346
  }
6270
6347
 
6348
+ // The gift message / recipient / hide-prices fields appended into the
6349
+ // checkout form when the gift-options primitive is wired. These persist
6350
+ // post-commit via setForOrder (they need the order id). The wrap itself is
6351
+ // already a cart line (selected on /cart). All values are echoed nowhere
6352
+ // pre-submit (empty inputs), so no escaping is needed here, but the labels
6353
+ // are static framework copy. Returns "" when gift options aren't wired.
6354
+ function _checkoutGiftFields(opts) {
6355
+ if (!opts.gift_enabled) return "";
6356
+ var hasWrap = !!opts.gift_wrap_sku_in_cart;
6357
+ return "<fieldset class=\"checkout-gift\">" +
6358
+ "<legend>Gift options</legend>" +
6359
+ (hasWrap
6360
+ ? "<p class=\"checkout-gift__note\">A gift wrap is in your cart. Add a message and recipient below.</p>"
6361
+ : "<p class=\"checkout-gift__note\">Sending this as a gift? Add a message and recipient. (Choose a gift wrap on the cart page.)</p>") +
6362
+ "<label class=\"form-field\"><span>Recipient name <small>(optional)</small></span>" +
6363
+ "<input type=\"text\" name=\"gift_recipient_name\" maxlength=\"120\" autocomplete=\"off\"></label>" +
6364
+ "<label class=\"form-field\"><span>Gift message <small>(optional)</small></span>" +
6365
+ "<textarea name=\"gift_message\" rows=\"3\" maxlength=\"500\" placeholder=\"Add a note for the recipient\"></textarea></label>" +
6366
+ "<label class=\"form-field form-field--check\"><input type=\"checkbox\" name=\"gift_hide_prices\" value=\"1\">" +
6367
+ "<span>Hide prices on the packing slip (gift receipt)</span></label>" +
6368
+ "</fieldset>";
6369
+ }
6370
+
6371
+ // The "pick up in store" location picker appended into the checkout form
6372
+ // when the click-and-collect primitive is wired AND at least one active
6373
+ // pickup location exists. The default is ship-to-me (empty value); choosing
6374
+ // a location schedules a pickup post-commit. location name/code are operator
6375
+ // free text — escaped at the sink. Returns "" when no locations.
6376
+ function _checkoutPickupPicker(opts) {
6377
+ var locs = opts.pickup_locations || [];
6378
+ if (!locs.length) return "";
6379
+ var esc = b.template.escapeHtml;
6380
+ var options = "<option value=\"\">Ship to my address</option>" +
6381
+ locs.map(function (l) {
6382
+ var addr = l.address || {};
6383
+ var addrLine = [addr.city, addr.country].filter(Boolean).map(String).join(", ");
6384
+ return "<option value=\"" + esc(String(l.code)) + "\">" +
6385
+ esc(String(l.name)) + (addrLine ? " — " + esc(addrLine) : "") + "</option>";
6386
+ }).join("");
6387
+ return "<fieldset class=\"checkout-pickup\">" +
6388
+ "<legend>Delivery method</legend>" +
6389
+ "<label class=\"form-field\"><span>How would you like to get your order?</span>" +
6390
+ "<select name=\"pickup_location_code\">" + options + "</select></label>" +
6391
+ "<p class=\"checkout-pickup__note\">Choose a store to pick up in person, or ship to your address. Shipping is still quoted on the total.</p>" +
6392
+ "</fieldset>";
6393
+ }
6394
+
6271
6395
  function renderCheckoutForm(opts) {
6272
6396
  if (!opts) throw new TypeError("storefront.renderCheckoutForm: opts required");
6273
6397
  var lines = opts.lines || [];
@@ -6339,6 +6463,17 @@ function renderCheckoutForm(opts) {
6339
6463
  if (checkoutCaptcha) {
6340
6464
  body = body.replace("</form>", checkoutCaptcha + "</form>");
6341
6465
  }
6466
+ // Pick-up-in-store location picker + gift personalization fields, appended
6467
+ // into the form (container-only). Spliced via _spliceRaw so an operator
6468
+ // free-text location name carrying a `$` can't trip dollar substitution.
6469
+ var checkoutPickup = _checkoutPickupPicker(opts);
6470
+ if (checkoutPickup) {
6471
+ body = _spliceRaw(body, "</form>", checkoutPickup + "</form>");
6472
+ }
6473
+ var checkoutGift = _checkoutGiftFields(opts);
6474
+ if (checkoutGift) {
6475
+ body = body.replace("</form>", checkoutGift + "</form>");
6476
+ }
6342
6477
  // Signed-in customer with a spendable points balance — surface a
6343
6478
  // redeem-at-checkout field. The block is appended as raw HTML (the
6344
6479
  // balance + value are numbers we control, the conversion ratio is the
@@ -6552,12 +6687,16 @@ function renderPayPage(opts) {
6552
6687
  pk: opts.publishable_key,
6553
6688
  client_secret: opts.client_secret,
6554
6689
  }).replace("RAW_PAY_SCRIPT", _islandScript("pay.js"));
6690
+ // Operator trust badges at the checkout placement (container-only — the pay
6691
+ // page isn't edge-cached). Pre-resolved + sanitized by the route; appended
6692
+ // after the pay card. Empty string when no badges / no dep.
6693
+ var payTrustBadges = typeof opts.trust_badges_html === "string" ? opts.trust_badges_html : "";
6555
6694
  return _wrap({
6556
6695
  title: "Pay",
6557
6696
  shop_name: shopName,
6558
6697
  cart_count: cartCount,
6559
6698
  theme_css: opts.theme_css,
6560
- body: body,
6699
+ body: body + payTrustBadges,
6561
6700
  });
6562
6701
  }
6563
6702
 
@@ -6578,6 +6717,8 @@ var ORDER_PAGE =
6578
6717
  " <tbody>{{line_rows}}</tbody>\n" +
6579
6718
  " </table>\n" +
6580
6719
  " </div>\n" +
6720
+ " RAW_ORDER_PICKUP" +
6721
+ " RAW_ORDER_GIFT" +
6581
6722
  " RAW_ORDER_ACTIONS" +
6582
6723
  " RAW_ORDER_RATING" +
6583
6724
  " </div>\n" +
@@ -6915,6 +7056,60 @@ function _ratingNoticeFor(code) {
6915
7056
  return null;
6916
7057
  }
6917
7058
 
7059
+ // Human label for a pickup FSM status, for the customer order page.
7060
+ function _pickupStatusLabel(status) {
7061
+ if (status === "scheduled") return "Scheduled for pickup";
7062
+ if (status === "ready") return "Ready for pickup";
7063
+ if (status === "picked_up") return "Picked up";
7064
+ if (status === "no_show") return "Missed pickup";
7065
+ if (status === "cancelled") return "Pickup cancelled";
7066
+ return status;
7067
+ }
7068
+
7069
+ // Customer-facing pickup (BOPIS) status panel on /orders/:id. The window
7070
+ // times come straight from the schedule row (operator-set epoch ms); the
7071
+ // location_code is operator-defined free text — escaped at the sink.
7072
+ // Returns "" when there's no schedule (the common no-pickup order).
7073
+ function _orderPickupBlock(pickup) {
7074
+ if (!pickup) return "";
7075
+ var esc = b.template.escapeHtml;
7076
+ var when = "";
7077
+ if (pickup.scheduled_window_start) {
7078
+ var start = new Date(Number(pickup.scheduled_window_start));
7079
+ var end = pickup.scheduled_window_end ? new Date(Number(pickup.scheduled_window_end)) : null;
7080
+ when = "<p class=\"order-pickup__window\">Window: " + esc(start.toISOString().slice(0, 16).replace("T", " ")) +
7081
+ (end ? " – " + esc(end.toISOString().slice(11, 16)) : "") + " UTC</p>";
7082
+ }
7083
+ return "<section class=\"order-pickup\">" +
7084
+ "<h2 class=\"pdp__variants-title\">Pickup</h2>" +
7085
+ "<p class=\"order-pickup__status\"><span class=\"pdp__badge\">" + esc(_pickupStatusLabel(pickup.status)) + "</span></p>" +
7086
+ "<p class=\"order-pickup__loc\">Location: <code class=\"inline-code\">" + esc(String(pickup.location_code)) + "</code></p>" +
7087
+ when +
7088
+ "</section>";
7089
+ }
7090
+
7091
+ // Customer-facing gift-options display on /orders/:id. The wrap_sku /
7092
+ // gift_message / recipient_name are customer (and operator) free text —
7093
+ // escaped at the sink. hide_prices is shown as a plain note. Returns ""
7094
+ // when no gift options are set.
7095
+ function _orderGiftBlock(gift) {
7096
+ if (!gift || (!gift.wrap_sku && !gift.gift_message && !gift.recipient_name && !gift.hide_prices)) return "";
7097
+ var esc = b.template.escapeHtml;
7098
+ var parts = [];
7099
+ if (gift.recipient_name) parts.push("<p class=\"order-gift__recipient\">To: " + esc(String(gift.recipient_name)) + "</p>");
7100
+ if (gift.wrap_sku) parts.push("<p class=\"order-gift__wrap\">Gift wrap: <code class=\"inline-code\">" + esc(String(gift.wrap_sku)) + "</code></p>");
7101
+ if (gift.gift_message) {
7102
+ // The message may be multi-line — split on LF and escape each line.
7103
+ var lines = String(gift.gift_message).replace(/\r\n/g, "\n").split("\n")
7104
+ .map(function (ln) { return esc(ln); }).join("<br>");
7105
+ parts.push("<blockquote class=\"order-gift__message\">" + lines + "</blockquote>");
7106
+ }
7107
+ if (gift.hide_prices) parts.push("<p class=\"order-gift__hide\">Prices hidden on the packing slip (gift receipt).</p>");
7108
+ return "<section class=\"order-gift\">" +
7109
+ "<h2 class=\"pdp__variants-title\">Gift options</h2>" + parts.join("") +
7110
+ "</section>";
7111
+ }
7112
+
6918
7113
  function renderOrder(opts) {
6919
7114
  if (!opts || !opts.order) throw new TypeError("storefront.renderOrder: opts.order required");
6920
7115
  var o = opts.order;
@@ -6976,6 +7171,8 @@ function renderOrder(opts) {
6976
7171
  ship_to: o.ship_to || null,
6977
7172
  timeline_html: timelineHtml,
6978
7173
  tracking_html: trackingHtml,
7174
+ pickup_html: _orderPickupBlock(opts.pickup),
7175
+ gift_html: _orderGiftBlock(opts.gift_options),
6979
7176
  actions_html: actionsHtml,
6980
7177
  rating_html: ratingHtml,
6981
7178
  can_return: _orderEligibleForReturn(o.status),
@@ -7027,8 +7224,13 @@ function renderOrder(opts) {
7027
7224
  .replace("RAW_REORDER_NOTICE", reorderNotice + cancelNotice)
7028
7225
  .replace("RAW_ORDER_TIMELINE", timelineHtml)
7029
7226
  .replace("RAW_ORDER_TRACKING", trackingHtml)
7227
+ .replace("RAW_ORDER_PICKUP", _orderPickupBlock(opts.pickup))
7030
7228
  .replace("RAW_ORDER_ACTIONS", actionsHtml)
7031
7229
  .replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
7230
+ // The gift block carries customer free text (escaped, but a `$` in it would
7231
+ // still trip String.replace's dollar substitution) — splice it via the
7232
+ // replacer-function helper, never a replacement-string .replace.
7233
+ body = _spliceRaw(body, "RAW_ORDER_GIFT", _orderGiftBlock(opts.gift_options));
7032
7234
  // The rating panel carries customer/operator free text (already escaped
7033
7235
  // into comment_html / response_html, but a `$` in that text would still
7034
7236
  // trip String.replace's dollar substitution) — splice it via the
@@ -7046,12 +7248,18 @@ function renderOrder(opts) {
7046
7248
  "<div class=\"grid\">" + railCards + "</div>" +
7047
7249
  "</section>";
7048
7250
  }
7251
+ // Operator-authored trust badges at the order_confirmation placement
7252
+ // (container-only — this page isn't edge-cached). Pre-resolved + escaped by
7253
+ // the route (via _trustBadgesHtml); spliced raw so a `$` in a sanitized SVG
7254
+ // can't trip String.replace dollar substitution. Empty string when no badges
7255
+ // / no dep.
7256
+ var trustBadgesHtml = typeof opts.trust_badges_html === "string" ? opts.trust_badges_html : "";
7049
7257
  return _wrap({
7050
7258
  title: "Order " + o.id,
7051
7259
  shop_name: shopName,
7052
7260
  cart_count: cartCount,
7053
7261
  theme_css: opts.theme_css,
7054
- body: body + railHtml,
7262
+ body: body + railHtml + trustBadgesHtml,
7055
7263
  });
7056
7264
  }
7057
7265
 
@@ -7205,6 +7413,41 @@ function _buildCartTotalsRows(t, fmt, subtotalStr, totalStr) {
7205
7413
  return rows;
7206
7414
  }
7207
7415
 
7416
+ // The cart-page gift disclosure (CONTAINER-ONLY — the edge cart renders
7417
+ // only the cookie-less empty shell, never a cart with lines, so this block
7418
+ // is never reached at the edge; see [[storefront-dual-render]]). It offers a
7419
+ // wrap <select> (POST /cart/gift adds the wrap as a real cart LINE so the
7420
+ // fee flows through the quote + is charged) plus a remove control when a
7421
+ // wrap is already in the cart. The message / recipient / hide-prices fields
7422
+ // are collected at the checkout step (they need the order id to persist), so
7423
+ // the cart block is the wrap selector only. Wrap titles are operator free
7424
+ // text — escaped at the sink. Returns "" when no active wraps are defined.
7425
+ function _cartGiftBlock(opts) {
7426
+ var wraps = opts.gift_wraps || [];
7427
+ if (!wraps.length) return "";
7428
+ var esc = b.template.escapeHtml;
7429
+ var current = opts.gift_wrap_in_cart || "";
7430
+ var options = "<option value=\"\">No gift wrap</option>" +
7431
+ wraps.map(function (w) {
7432
+ var feeStr = pricing.format(w.fee_minor, (opts.totals && opts.totals.currency) || "USD");
7433
+ return "<option value=\"" + esc(String(w.wrap_sku)) + "\"" +
7434
+ (w.wrap_sku === current ? " selected" : "") + ">" +
7435
+ esc(String(w.title)) + " (" + esc(feeStr) + ")</option>";
7436
+ }).join("");
7437
+ return "<section class=\"cart-gift\">" +
7438
+ "<details class=\"cart-gift__details\"" + (current ? " open" : "") + ">" +
7439
+ "<summary class=\"cart-gift__summary\">Add a gift wrap</summary>" +
7440
+ "<form method=\"post\" action=\"/cart/gift\" class=\"cart-gift__form\">" +
7441
+ "<label class=\"form-field\"><span>Gift wrap</span>" +
7442
+ "<select name=\"wrap_sku\">" + options + "</select>" +
7443
+ "</label>" +
7444
+ "<p class=\"cart-gift__note\">Add a gift message and recipient at checkout. The wrap fee is charged as a line on your order.</p>" +
7445
+ "<button type=\"submit\" class=\"btn-secondary\">Update gift wrap</button>" +
7446
+ "</form>" +
7447
+ "</details>" +
7448
+ "</section>";
7449
+ }
7450
+
7208
7451
  function renderCart(opts) {
7209
7452
  if (!opts) throw new TypeError("storefront.renderCart: opts required");
7210
7453
  var lines = opts.lines || [];
@@ -7337,6 +7580,13 @@ function renderCart(opts) {
7337
7580
  .replace("RAW_TOTALS_ROWS", totalsRows)
7338
7581
  .replace("RAW_CHECKOUT_CTA", checkoutCta)
7339
7582
  .replace("RAW_CART_NOTICE", notice);
7583
+ // CONTAINER-ONLY gift wrap disclosure, appended after the cart grid (a
7584
+ // separate <section>, never spliced into the shared CART_PAGE markup the
7585
+ // edge twin mirrors). Only reached for a cart with lines, which the edge
7586
+ // never renders. Spliced via the body concat (the block carries operator
7587
+ // free text already escaped; appending — not String.replace — so a `$`
7588
+ // in a wrap title can't trip dollar substitution).
7589
+ body = body + _cartGiftBlock(opts);
7340
7590
  }
7341
7591
  return _wrap(Object.assign({
7342
7592
  title: "Cart",
@@ -7562,6 +7812,171 @@ function renderUnsubscribeResult(opts) {
7562
7812
  });
7563
7813
  }
7564
7814
 
7815
+ // ---- back-in-stock "Notify me" pages -----------------------------------
7816
+ //
7817
+ // Container-only (no edge twin) — these aren't edge-cached. Every page is
7818
+ // server-rendered, no auth, escape-by-default. The thank-you page renders
7819
+ // the SAME copy for new / already-pending / already-confirmed so the page
7820
+ // never reveals whether the address was already subscribed beyond friendly
7821
+ // copy. The confirm/unsubscribe pages render a clean outcome on a null/
7822
+ // invalid result rather than a leak or a 500.
7823
+
7824
+ function renderStockAlertThanks(opts) {
7825
+ opts = opts || {};
7826
+ var body =
7827
+ "<section class=\"newsletter-thanks\">" +
7828
+ "<div class=\"newsletter-thanks__card\">" +
7829
+ "<p class=\"eyebrow\">Back-in-stock alert</p>" +
7830
+ "<h1 class=\"newsletter-thanks__title\">Check your email.</h1>" +
7831
+ "<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>" +
7832
+ "<div class=\"newsletter-thanks__cta\">" +
7833
+ "<a href=\"/\" class=\"btn-primary\">Back to the shop <span aria-hidden=\"true\">→</span></a>" +
7834
+ "</div>" +
7835
+ "</div>" +
7836
+ "</section>";
7837
+ return _wrap({
7838
+ title: "Back-in-stock alert",
7839
+ shop_name: opts.shop_name || "blamejs.shop",
7840
+ cart_count: opts.cart_count,
7841
+ theme_css: opts.theme_css,
7842
+ robots: "noindex",
7843
+ body: body,
7844
+ });
7845
+ }
7846
+
7847
+ function renderStockAlertError(opts) {
7848
+ opts = opts || {};
7849
+ var body =
7850
+ "<section class=\"newsletter-thanks\">" +
7851
+ "<div class=\"newsletter-thanks__card\">" +
7852
+ "<p class=\"eyebrow\">Back-in-stock alert</p>" +
7853
+ "<h1 class=\"newsletter-thanks__title\">Couldn't set that alert.</h1>" +
7854
+ "<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>" +
7855
+ "<div class=\"newsletter-thanks__cta\">" +
7856
+ "<a href=\"/\" class=\"btn-primary\">Back to the shop <span aria-hidden=\"true\">→</span></a>" +
7857
+ "</div>" +
7858
+ "</div>" +
7859
+ "</section>";
7860
+ return _wrap({
7861
+ title: "Back-in-stock alert",
7862
+ shop_name: opts.shop_name || "blamejs.shop",
7863
+ cart_count: opts.cart_count,
7864
+ theme_css: opts.theme_css,
7865
+ robots: "noindex",
7866
+ body: body,
7867
+ });
7868
+ }
7869
+
7870
+ // The confirm-link landing page. `opts.outcome` is one of: "confirmed"
7871
+ // (we just stamped confirmed_at), "already-notified" (the alert already
7872
+ // fired — terminal), "invalid" (null / expired / unknown token). No token
7873
+ // is echoed back; the outcome page reveals nothing beyond friendly copy.
7874
+ function renderStockAlertConfirm(opts) {
7875
+ opts = opts || {};
7876
+ var heading, lede;
7877
+ if (opts.outcome === "confirmed") {
7878
+ heading = "You're all set.";
7879
+ lede = "Your back-in-stock alert is confirmed. We'll email you once — the moment this item returns. Nothing else.";
7880
+ } else if (opts.outcome === "already-notified") {
7881
+ heading = "You've already been notified.";
7882
+ lede = "This item was already back in stock and we emailed you about it. There's nothing more to do.";
7883
+ } else {
7884
+ heading = "This link is no longer valid.";
7885
+ 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.";
7886
+ }
7887
+ var body =
7888
+ "<section class=\"newsletter-thanks\">" +
7889
+ "<div class=\"newsletter-thanks__card\">" +
7890
+ "<p class=\"eyebrow\">Back-in-stock alert</p>" +
7891
+ "<h1 class=\"newsletter-thanks__title\">" + heading + "</h1>" +
7892
+ "<p class=\"newsletter-thanks__lede\">" + lede + "</p>" +
7893
+ "<div class=\"newsletter-thanks__cta\">" +
7894
+ "<a href=\"/\" class=\"btn-primary\">Back to the shop <span aria-hidden=\"true\">→</span></a>" +
7895
+ "</div>" +
7896
+ "</div>" +
7897
+ "</section>";
7898
+ return _wrap({
7899
+ title: "Back-in-stock alert",
7900
+ shop_name: opts.shop_name || "blamejs.shop",
7901
+ cart_count: opts.cart_count,
7902
+ theme_css: opts.theme_css,
7903
+ robots: "noindex",
7904
+ body: body,
7905
+ });
7906
+ }
7907
+
7908
+ // The unsubscribe confirm page (GET). A no-JS form POSTs the (email, sku,
7909
+ // variant) tuple back to /stock-alert/unsubscribe — a deliberate "yes,
7910
+ // stop these alerts" step (no link-prefetcher unsubscribe). The tuple
7911
+ // rides in hidden fields (HTML-escaped). This page is container-rendered
7912
+ // from a GET, so it CAN carry the `_csrf` token — the action is NOT in
7913
+ // EDGE_POST_PATHS, so `_injectCsrfFields` tokens it automatically.
7914
+ function renderStockAlertUnsubscribeConfirm(opts) {
7915
+ opts = opts || {};
7916
+ var email = typeof opts.email === "string" ? opts.email : "";
7917
+ var sku = typeof opts.sku === "string" ? opts.sku : "";
7918
+ var variantId = typeof opts.variant_id === "string" ? opts.variant_id : "";
7919
+ var body =
7920
+ "<section class=\"newsletter-thanks\">" +
7921
+ "<div class=\"newsletter-thanks__card\">" +
7922
+ "<p class=\"eyebrow\">Back-in-stock alert</p>" +
7923
+ "<h1 class=\"newsletter-thanks__title\">Stop this alert?</h1>" +
7924
+ "<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>" +
7925
+ "<form class=\"newsletter-unsub__form\" method=\"post\" action=\"/stock-alert/unsubscribe\">" +
7926
+ "<input type=\"hidden\" name=\"email\" value=\"" + _escAttr(email) + "\">" +
7927
+ "<input type=\"hidden\" name=\"sku\" value=\"" + _escAttr(sku) + "\">" +
7928
+ (variantId ? "<input type=\"hidden\" name=\"variant_id\" value=\"" + _escAttr(variantId) + "\">" : "") +
7929
+ "<div class=\"newsletter-thanks__cta\">" +
7930
+ "<button type=\"submit\" class=\"btn-primary\">Stop this alert</button>" +
7931
+ "<a href=\"/\" class=\"btn-ghost\">Keep me subscribed</a>" +
7932
+ "</div>" +
7933
+ "</form>" +
7934
+ "</div>" +
7935
+ "</section>";
7936
+ return _wrap({
7937
+ title: "Unsubscribe",
7938
+ shop_name: opts.shop_name || "blamejs.shop",
7939
+ cart_count: opts.cart_count,
7940
+ theme_css: opts.theme_css,
7941
+ robots: "noindex",
7942
+ body: body,
7943
+ });
7944
+ }
7945
+
7946
+ // The unsubscribe outcome page (POST). Idempotent — a missing row reads as
7947
+ // "you're unsubscribed" (re-clicking the link twice is not an error). A bad
7948
+ // shape reads as the generic non-leaking copy.
7949
+ function renderStockAlertUnsubscribeResult(opts) {
7950
+ opts = opts || {};
7951
+ var heading, lede;
7952
+ if (opts.outcome === "invalid") {
7953
+ heading = "This link isn't valid.";
7954
+ lede = "We couldn't match this unsubscribe link to an alert. It may already have been used, or the link may be incomplete.";
7955
+ } else {
7956
+ heading = "You're unsubscribed.";
7957
+ lede = "We won't email you about this item coming back in stock. Changed your mind? Set a fresh alert from the product page.";
7958
+ }
7959
+ var body =
7960
+ "<section class=\"newsletter-thanks\">" +
7961
+ "<div class=\"newsletter-thanks__card\">" +
7962
+ "<p class=\"eyebrow\">Back-in-stock alert</p>" +
7963
+ "<h1 class=\"newsletter-thanks__title\">" + heading + "</h1>" +
7964
+ "<p class=\"newsletter-thanks__lede\">" + lede + "</p>" +
7965
+ "<div class=\"newsletter-thanks__cta\">" +
7966
+ "<a href=\"/\" class=\"btn-primary\">Back to the shop <span aria-hidden=\"true\">→</span></a>" +
7967
+ "</div>" +
7968
+ "</div>" +
7969
+ "</section>";
7970
+ return _wrap({
7971
+ title: "Unsubscribe",
7972
+ shop_name: opts.shop_name || "blamejs.shop",
7973
+ cart_count: opts.cart_count,
7974
+ theme_css: opts.theme_css,
7975
+ robots: "noindex",
7976
+ body: body,
7977
+ });
7978
+ }
7979
+
7565
7980
  // ---- cookie preference center ------------------------------------------
7566
7981
 
7567
7982
  // The four toggleable categories + their operator-facing copy, mirroring
@@ -8193,6 +8608,8 @@ var ACCOUNT_DASH_PAGE =
8193
8608
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
8194
8609
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
8195
8610
  " <a class=\"btn-secondary\" href=\"/account/exchanges\">Exchanges</a>\n" +
8611
+ " RAW_PICKUPS_LINK\n" +
8612
+ " RAW_PAYMENT_METHODS_LINK\n" +
8196
8613
  " <a class=\"btn-secondary\" href=\"/account/support\">Support</a>\n" +
8197
8614
  " <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
8198
8615
  " <a class=\"btn-secondary\" href=\"/account/credit\">Store credit</a>\n" +
@@ -8310,6 +8727,15 @@ function renderAccount(opts) {
8310
8727
  // links to a 404.
8311
8728
  .replace("RAW_PREORDER_LINK", opts.preorders_enabled
8312
8729
  ? "<a class=\"btn-secondary\" href=\"/account/preorders\">Pre-orders</a>"
8730
+ : "")
8731
+ // The Pickups + Payment-methods links render only when those routes are
8732
+ // mounted (the primitive is wired), so a deploy without them never links
8733
+ // to a 404.
8734
+ .replace("RAW_PICKUPS_LINK", opts.pickups_enabled
8735
+ ? "<a class=\"btn-secondary\" href=\"/account/pickups\">Pickups</a>"
8736
+ : "")
8737
+ .replace("RAW_PAYMENT_METHODS_LINK", opts.payment_methods_enabled
8738
+ ? "<a class=\"btn-secondary\" href=\"/account/payment-methods\">Payment methods</a>"
8313
8739
  : "");
8314
8740
  return _wrap({
8315
8741
  title: "Account",
@@ -8470,6 +8896,120 @@ function renderPasskeyRemoveConfirm(opts) {
8470
8896
  });
8471
8897
  }
8472
8898
 
8899
+ // ---- saved payment methods --------------------------------------------
8900
+ //
8901
+ // The signed-in customer's vaulted cards (Stripe pm_… tokens — the shop
8902
+ // never holds the PAN/CVV). Each card shows brand + last4 + expiry, a
8903
+ // Default badge, a Set-default form, and a Remove form; an "Add a card"
8904
+ // CTA links to the SetupIntent page. Brand/last4 are processor-supplied
8905
+ // short strings but escaped at the sink for consistency.
8906
+ function renderPaymentMethods(opts) {
8907
+ opts = opts || {};
8908
+ var esc = b.template.escapeHtml;
8909
+ var list = opts.payment_methods || [];
8910
+ var notice = opts.notice
8911
+ ? "<p class=\"form-notice form-notice--err\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
8912
+ : "";
8913
+ var success = opts.success
8914
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(String(opts.success)) + "</p>"
8915
+ : "";
8916
+ var rowsHtml = "";
8917
+ for (var i = 0; i < list.length; i += 1) {
8918
+ var pm = list[i];
8919
+ var isDefault = Number(pm.is_default) === 1 || pm.is_default === true;
8920
+ var expiry = (pm.exp_month != null && pm.exp_year != null)
8921
+ ? esc(String(pm.exp_month).padStart ? String(pm.exp_month).padStart(2, "0") : String(pm.exp_month)) + "/" + esc(String(pm.exp_year))
8922
+ : "";
8923
+ rowsHtml +=
8924
+ "<li class=\"pm-card\">" +
8925
+ "<div class=\"pm-card__head\">" +
8926
+ "<span class=\"pm-card__brand\">" + esc(String(pm.brand)) + " &middot; &middot;&middot;&middot;&middot; " + esc(String(pm.last4)) + "</span>" +
8927
+ (isDefault ? "<span class=\"pdp__badge pdp__badge--ok\">Default</span>" : "") +
8928
+ "</div>" +
8929
+ (expiry ? "<p class=\"pm-card__exp\">Expires " + expiry + "</p>" : "") +
8930
+ "<div class=\"pm-card__actions\">" +
8931
+ (isDefault ? "" :
8932
+ "<form method=\"post\" action=\"/account/payment-methods/" + esc(String(pm.id)) + "/default\">" +
8933
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Set as default</button></form>") +
8934
+ "<form method=\"post\" action=\"/account/payment-methods/" + esc(String(pm.id)) + "/archive\">" +
8935
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button></form>" +
8936
+ "</div>" +
8937
+ "</li>";
8938
+ }
8939
+ var inner = rowsHtml
8940
+ ? "<ul class=\"pm-list\">" + rowsHtml + "</ul>"
8941
+ : "<div class=\"account-empty\"><p class=\"account-empty__lede\">No saved cards yet. Add one for faster checkout.</p></div>";
8942
+ var body =
8943
+ "<section class=\"account-payment-methods\">" +
8944
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
8945
+ "<li><a href=\"/account\">Account</a></li>" +
8946
+ "<li aria-current=\"page\">Payment methods</li>" +
8947
+ "</ol></nav>" +
8948
+ "<h1 class=\"account-returns__title\">Payment methods</h1>" +
8949
+ success + notice +
8950
+ inner +
8951
+ "<div class=\"account-payment-methods__cta\">" +
8952
+ "<a class=\"btn-primary\" href=\"/account/payment-methods/add\">Add a card</a>" +
8953
+ "</div>" +
8954
+ "</section>";
8955
+ return _wrap({
8956
+ title: "Payment methods",
8957
+ shop_name: opts.shop_name || "blamejs.shop",
8958
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
8959
+ theme_css: opts.theme_css,
8960
+ robots: "noindex",
8961
+ body: body,
8962
+ });
8963
+ }
8964
+
8965
+ // The add-card page — a Stripe SetupIntent collected through the Payment
8966
+ // Element. Mirrors the pay page: external Stripe.js (admitted by the
8967
+ // route-scoped CSP set on GET) + the same-origin saved-card.js island. No
8968
+ // inline script. The publishable key rides an HTML-attribute-escaped data-*
8969
+ // attribute on the mount div, read by the island. The SetupIntent
8970
+ // client_secret is fetched by the island from POST
8971
+ // /account/payment-methods/setup-intent (so it's per-session, never cached
8972
+ // in the page HTML).
8973
+ var ADD_PAYMENT_METHOD_PAGE =
8974
+ "<section class=\"pay-page account-add-card\">\n" +
8975
+ " <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>\n" +
8976
+ " <li><a href=\"/account\">Account</a></li>\n" +
8977
+ " <li><a href=\"/account/payment-methods\">Payment methods</a></li>\n" +
8978
+ " <li aria-current=\"page\">Add a card</li>\n" +
8979
+ " </ol></nav>\n" +
8980
+ " <header class=\"section-head\">\n" +
8981
+ " <p class=\"eyebrow\">Secure · Stripe</p>\n" +
8982
+ " <h1 class=\"section-head__title\">Add a card</h1>\n" +
8983
+ " <p class=\"section-head__lede\">Your card is stored securely with Stripe — this store never sees the full number.</p>\n" +
8984
+ " </header>\n" +
8985
+ " <div class=\"pay-card\" id=\"add-card-island\" data-pk=\"{{pk}}\">\n" +
8986
+ " <form id=\"add-card-form\" method=\"post\" action=\"/account/payment-methods\">\n" +
8987
+ " <input type=\"hidden\" name=\"setup_intent_id\" id=\"add-card-si\">\n" +
8988
+ " <div id=\"payment-element\" class=\"pay-card__element\"></div>\n" +
8989
+ " <button id=\"add-card-submit\" type=\"button\" class=\"btn-primary pay-card__submit\">Save card</button>\n" +
8990
+ " <p id=\"add-card-message\" class=\"pay-card__message\" role=\"status\"></p>\n" +
8991
+ " </form>\n" +
8992
+ " </div>\n" +
8993
+ " <script src=\"https://js.stripe.com/v3/\"></script>\n" +
8994
+ " RAW_ADD_CARD_SCRIPT\n" +
8995
+ "</section>\n";
8996
+
8997
+ function renderAddPaymentMethod(opts) {
8998
+ opts = opts || {};
8999
+ if (!opts.publishable_key) throw new TypeError("storefront.renderAddPaymentMethod: opts.publishable_key required");
9000
+ var body = _render(ADD_PAYMENT_METHOD_PAGE, {
9001
+ pk: opts.publishable_key,
9002
+ }).replace("RAW_ADD_CARD_SCRIPT", _islandScript("saved-card.js"));
9003
+ return _wrap({
9004
+ title: "Add a card",
9005
+ shop_name: opts.shop_name || "blamejs.shop",
9006
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
9007
+ theme_css: opts.theme_css,
9008
+ robots: "noindex",
9009
+ body: body,
9010
+ });
9011
+ }
9012
+
8473
9013
  // ---- profile edit ------------------------------------------------------
8474
9014
  //
8475
9015
  // Display-name edit for the signed-in customer. Email is stored hash-only
@@ -9065,6 +9605,42 @@ function mount(router, deps) {
9065
9605
  return lines.length;
9066
9606
  }
9067
9607
 
9608
+ // Resolve the active trust badges for a container-only placement and
9609
+ // concatenate each one's sanitized renderHtml. Fires an impression per
9610
+ // rendered badge (fire-and-forget — the counter is drop-silent on the hot
9611
+ // path; never await it into the response). Drop-silent on ANY read failure
9612
+ // (returns "") — badges are supplementary; an absent dep / unmigrated table
9613
+ // / read error must never 500 a checkout or order page. Placements wired
9614
+ // today: "checkout", "order_confirmation". The svg_payload was sanitized at
9615
+ // define time via b.guardSvg, so renderHtml's inline emit is safe.
9616
+ async function _trustBadgesHtml(placement, req) {
9617
+ if (!deps.trustBadges) return "";
9618
+ try {
9619
+ var active = await deps.trustBadges.activeForPlacement({ placement: placement });
9620
+ if (!Array.isArray(active) || active.length === 0) return "";
9621
+ var sid = null;
9622
+ try { sid = _readSidCookie(req); } catch (_e) { sid = null; }
9623
+ var parts = [];
9624
+ for (var i = 0; i < active.length; i += 1) {
9625
+ var slug = active[i].slug;
9626
+ var html;
9627
+ try { html = await deps.trustBadges.renderHtml({ slug: slug }); }
9628
+ catch (_e) { continue; /* drop-silent — skip a badge that fails to render */ }
9629
+ parts.push(html);
9630
+ // Fire-and-forget impression — the method is already drop-silent.
9631
+ try {
9632
+ var imp = deps.trustBadges.recordImpression({ slug: slug, placement: placement, session_id: sid || undefined });
9633
+ if (imp && typeof imp.then === "function") imp.then(function () {}, function () {});
9634
+ } catch (_e) { /* drop-silent — impression bump must not affect render */ }
9635
+ }
9636
+ if (!parts.length) return "";
9637
+ return "<section class=\"trust-badges trust-badges--" + b.template.escapeHtml(placement) +
9638
+ "\" aria-label=\"Trust badges\">" + parts.join("") + "</section>";
9639
+ } catch (_e) {
9640
+ return ""; // drop-silent — supplementary; never 500 the page
9641
+ }
9642
+ }
9643
+
9068
9644
  // ---- multi-currency display -------------------------------------------
9069
9645
  //
9070
9646
  // Opt-in: wired only when the operator supplies `deps.currencyDisplay`
@@ -10381,6 +10957,54 @@ function mount(router, deps) {
10381
10957
  return (await deps.catalog.batch.searchDecorate({ terms: terms, currency: "USD" })).rows;
10382
10958
  }
10383
10959
 
10960
+ // Operator-tunable rerank of the matched search universe through
10961
+ // searchRanking.applyToResults (pins first, then weighted score DESC).
10962
+ // The decorated rows carry `id` + `in_stock` (bool) + `price_minor` but NOT
10963
+ // `product_id`, which applyToResults requires — project each row with
10964
+ // `product_id: row.id` + a `signals` bag built from the decorated fields.
10965
+ // applyToResults returns rows that preserve every original field plus
10966
+ // product_id/_score/_pinned, so the returned array IS the reranked universe
10967
+ // (the renderer reads its existing fields; the extra keys are inert).
10968
+ //
10969
+ // NEVER-500: ranking is supplementary to search. A missing dep, no active
10970
+ // weight set, a bad/archived weight slug, or any throw → return the
10971
+ // original universe unchanged (drop-silent defensive read). applyToResults
10972
+ // with no active set + no slug is a safe no-op that preserves input order,
10973
+ // so the common "operator hasn't configured ranking" path is inert.
10974
+ //
10975
+ // NOT dual-render — the edge /search (worker/render/search.js) does NOT
10976
+ // rerank. Edge-cached search serves the default order; the container path
10977
+ // serves the ranked order. This deliberate non-parity matches the
10978
+ // synonyms/facets precedent (both container-only at the edge) — search order
10979
+ // is not a price/legal contract, so order differences are acceptable.
10980
+ async function _rerankUniverse(universe, query) {
10981
+ if (!deps.searchRanking || !Array.isArray(universe) || universe.length === 0) {
10982
+ return universe;
10983
+ }
10984
+ try {
10985
+ var projected = universe.map(function (r) {
10986
+ return Object.assign({}, r, {
10987
+ product_id: r.id,
10988
+ signals: {
10989
+ in_stock: r.in_stock === true,
10990
+ // price_minor is a pass-through integer signal (never divided —
10991
+ // no money arithmetic); a null price contributes nothing.
10992
+ price_minor: (typeof r.price_minor === "number" && isFinite(r.price_minor)) ? r.price_minor : 0,
10993
+ },
10994
+ });
10995
+ });
10996
+ var ranked = await deps.searchRanking.applyToResults({
10997
+ query: (typeof query === "string" && query.trim().length) ? query : null,
10998
+ results: projected,
10999
+ });
11000
+ return Array.isArray(ranked) && ranked.length === universe.length ? ranked : universe;
11001
+ } catch (_e) {
11002
+ // Bad weight slug, archived set, or any ranking failure → un-ranked
11003
+ // fallback. Never 500 the search page.
11004
+ return universe;
11005
+ }
11006
+ }
11007
+
10384
11008
  router.get("/search", async function (req, res) {
10385
11009
  var url = req.url ? new URL(req.url, "http://localhost") : null;
10386
11010
  var qRaw = url && url.searchParams.get("q");
@@ -10425,6 +11049,12 @@ function mount(router, deps) {
10425
11049
  // in server.js (the searchFacets primitive's `create`, with
10426
11050
  // the DB query handle pre-bound).
10427
11051
  var universe = await _facetableUniverse(terms);
11052
+ // Operator-tunable rerank — applied to the FULL matched universe
11053
+ // BEFORE the facet adapter windows it, so pins + weights reorder
11054
+ // across ALL pages (searchFacets.previewQuery filters in input order
11055
+ // and slices, preserving the reranked order). Container-only; never
11056
+ // 500s the search page (see _rerankUniverse).
11057
+ universe = await _rerankUniverse(universe, q);
10428
11058
  var facetCatalog = {
10429
11059
  // The primitive narrows in-memory and drops the focal facet
10430
11060
  // per option, so the adapter ignores applied_filters and
@@ -10454,6 +11084,8 @@ function mount(router, deps) {
10454
11084
  // matched universe gives the honest total; the page is the
10455
11085
  // windowed slice (the same page size the faceted path uses).
10456
11086
  var flatUniverse = await _facetableUniverse(terms);
11087
+ // Operator-tunable rerank before windowing — full control of order.
11088
+ flatUniverse = await _rerankUniverse(flatUniverse, q);
10457
11089
  totalCount = flatUniverse.length;
10458
11090
  page = _clampPage(page, totalCount, SEARCH_PAGE_SIZE);
10459
11091
  products = flatUniverse.slice((page - 1) * SEARCH_PAGE_SIZE, page * SEARCH_PAGE_SIZE);
@@ -11052,6 +11684,26 @@ function mount(router, deps) {
11052
11684
  hero_media: media.length ? media[0] : null,
11053
11685
  };
11054
11686
  }
11687
+ // Gift options — the active wrap catalog for the cart-page gift UI, plus
11688
+ // which wrap (if any) is already in the cart as a line so the selector
11689
+ // can pre-select it. Drop-silent: an unmigrated gift_wraps table → no UI.
11690
+ var giftWraps = [];
11691
+ var giftWrapInCart = null;
11692
+ if (deps.giftOptions) {
11693
+ try {
11694
+ giftWraps = await deps.giftOptions.listWraps({ active_only: true });
11695
+ if (giftWraps.length) {
11696
+ // A wrap is "in the cart" when one of its variant SKUs matches a
11697
+ // cart line's sku — the wrap rides as a real line (see POST
11698
+ // /cart/gift), so removing it removes the line.
11699
+ var wrapSkuSet = {};
11700
+ for (var gi = 0; gi < giftWraps.length; gi += 1) wrapSkuSet[giftWraps[gi].wrap_sku] = true;
11701
+ for (var ci = 0; ci < lines.length; ci += 1) {
11702
+ if (wrapSkuSet[lines[ci].sku]) { giftWrapInCart = lines[ci].sku; break; }
11703
+ }
11704
+ }
11705
+ } catch (_e) { giftWraps = []; giftWrapInCart = null; }
11706
+ }
11055
11707
  _send(res, 200, renderCart(Object.assign({
11056
11708
  lines: lines,
11057
11709
  totals: totals,
@@ -11060,6 +11712,8 @@ function mount(router, deps) {
11060
11712
  product_lookup: productLookup,
11061
11713
  can_save: !!(deps.saveForLater && deps.customers),
11062
11714
  checkout_available: !!(deps.checkout && deps.order),
11715
+ gift_wraps: giftWraps,
11716
+ gift_wrap_in_cart: giftWrapInCart,
11063
11717
  added: added,
11064
11718
  shop_name: shopName,
11065
11719
  theme: theme,
@@ -11169,6 +11823,28 @@ function mount(router, deps) {
11169
11823
  }
11170
11824
  } catch (_e) { prefill = null; }
11171
11825
  }
11826
+ // Gift options — does the cart carry a wrap line? When it does (or the
11827
+ // gift primitive is wired at all), surface the message/recipient/hide-
11828
+ // prices fields so the customer can personalize the gift; they persist
11829
+ // post-commit via setForOrder. Drop-silent on a read failure.
11830
+ var giftWrapSkuInCart = null;
11831
+ if (deps.giftOptions) {
11832
+ try {
11833
+ var coWraps = await deps.giftOptions.listWraps({ active_only: true });
11834
+ var coWrapSet = {};
11835
+ for (var wi = 0; wi < coWraps.length; wi += 1) coWrapSet[coWraps[wi].wrap_sku] = true;
11836
+ for (var cli = 0; cli < lines.length; cli += 1) {
11837
+ if (coWrapSet[lines[cli].sku]) { giftWrapSkuInCart = lines[cli].sku; break; }
11838
+ }
11839
+ } catch (_e) { giftWrapSkuInCart = null; }
11840
+ }
11841
+ // Click-and-collect — the active pickup locations for the "pick up in
11842
+ // store" option. Drop-silent: an unmigrated table → no picker.
11843
+ var pickupLocations = [];
11844
+ if (deps.clickAndCollect) {
11845
+ try { pickupLocations = await deps.clickAndCollect.availableLocations({ limit: 50 }); }
11846
+ catch (_e) { pickupLocations = []; }
11847
+ }
11172
11848
  return {
11173
11849
  lines: lines, totals: totals, totals_detail: totalsDetail,
11174
11850
  shop_name: shopName, theme: theme,
@@ -11176,6 +11852,9 @@ function mount(router, deps) {
11176
11852
  paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
11177
11853
  loyalty_balance: loyaltyBalance,
11178
11854
  loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
11855
+ gift_enabled: !!deps.giftOptions,
11856
+ gift_wrap_sku_in_cart: giftWrapSkuInCart,
11857
+ pickup_locations: pickupLocations,
11179
11858
  prefill: prefill,
11180
11859
  inline_error: inlineError || null,
11181
11860
  // CAPTCHA widget props — set only when a provider is active, so the
@@ -11199,6 +11878,72 @@ function mount(router, deps) {
11199
11878
  res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(keys));
11200
11879
  }
11201
11880
 
11881
+ // Persist the gift options + schedule a store pickup for a just-placed
11882
+ // order. POST-COMMIT + drop-silent (each in its own try/catch): both run
11883
+ // AFTER the charge and never change the amount — the wrap fee was already
11884
+ // a real cart line in the quote. The wrap_sku written here is read off the
11885
+ // cart's wrap line (so getForOrder shows it on the order page); the
11886
+ // message/recipient/hide-prices come from the checkout form. A failure
11887
+ // must NOT roll back the paid order.
11888
+ async function _persistGiftAndPickup(cart, placedOrder, body) {
11889
+ if (!placedOrder || !placedOrder.id) return;
11890
+ // Gift options.
11891
+ if (deps.giftOptions) {
11892
+ try {
11893
+ var orderLines = placedOrder.lines || [];
11894
+ var wrapSku = null;
11895
+ // Find the wrap line on the placed order (its sku matches an active
11896
+ // wrap) so the order page can show "Gift wrap: <sku>".
11897
+ var activeWraps = await deps.giftOptions.listWraps({ active_only: true });
11898
+ var wrapSet = {};
11899
+ for (var i = 0; i < activeWraps.length; i += 1) wrapSet[activeWraps[i].wrap_sku] = true;
11900
+ for (var li = 0; li < orderLines.length; li += 1) {
11901
+ if (wrapSet[orderLines[li].sku]) { wrapSku = orderLines[li].sku; break; }
11902
+ }
11903
+ var giftMessage = (typeof body.gift_message === "string" && body.gift_message.trim()) ? body.gift_message : null;
11904
+ var recipientName = (typeof body.gift_recipient_name === "string" && body.gift_recipient_name.trim()) ? body.gift_recipient_name : null;
11905
+ var hidePrices = body.gift_hide_prices === "1" || body.gift_hide_prices === true || body.gift_hide_prices === "on";
11906
+ // Only write a row when there's something to record (a wrap line, a
11907
+ // message, a recipient, or a hide-prices toggle).
11908
+ if (wrapSku || giftMessage || recipientName || hidePrices) {
11909
+ await deps.giftOptions.setForOrder({
11910
+ order_id: placedOrder.id,
11911
+ wrap_sku: wrapSku || undefined,
11912
+ gift_message: giftMessage || undefined,
11913
+ recipient_name: recipientName || undefined,
11914
+ hide_prices: hidePrices,
11915
+ });
11916
+ }
11917
+ } catch (_e) { /* drop-silent — a gift-options failure must not roll back the paid order */ }
11918
+ }
11919
+ // Pickup scheduling — when the customer chose "pick up in store". A
11920
+ // default 1-hour window starting at the location's lead-time floor (the
11921
+ // primitive enforces lead time + capacity; a refusal is swallowed). v1
11922
+ // keeps shipping as quoted — the pickup choice doesn't suppress the
11923
+ // shipping charge (re-open if operators need that).
11924
+ if (deps.clickAndCollect) {
11925
+ var locationCode = typeof body.pickup_location_code === "string" ? body.pickup_location_code.trim() : "";
11926
+ if (locationCode) {
11927
+ try {
11928
+ var loc = await deps.clickAndCollect.getLocation(locationCode);
11929
+ if (loc) {
11930
+ var leadMs = Number(loc.lead_time_hours || 0) * b.constants.TIME.hours(1);
11931
+ // Start the window one minute past the lead-time floor so the
11932
+ // primitive's strict `< leadFloor` gate accepts it.
11933
+ var start = Date.now() + leadMs + b.constants.TIME.minutes(1);
11934
+ var end = start + b.constants.TIME.hours(1);
11935
+ await deps.clickAndCollect.scheduleAtLocation({
11936
+ order_id: placedOrder.id,
11937
+ location_code: locationCode,
11938
+ scheduled_window_start: start,
11939
+ scheduled_window_end: end,
11940
+ });
11941
+ }
11942
+ } catch (_e) { /* drop-silent — a scheduling failure must not roll back the paid order */ }
11943
+ }
11944
+ }
11945
+ }
11946
+
11202
11947
  router.get("/checkout", async function (req, res) {
11203
11948
  var sid = _readSidCookie(req);
11204
11949
  if (!sid) return _send(res, 303, "<a href=\"/cart\">Cart is empty</a>"), res.setHeader && res.setHeader("location", "/cart");
@@ -11287,6 +12032,13 @@ function mount(router, deps) {
11287
12032
  loyalty_redeem_points: _parseRedeemPoints(body.loyalty_redeem_points),
11288
12033
  idempotency_key: "checkout:" + c.id + ":" + b.uuid.v7(),
11289
12034
  });
12035
+ // POST-COMMIT, drop-silent: gift options + pickup scheduling. Both
12036
+ // run AFTER the charge and DO NOT change the amount — the wrap FEE was
12037
+ // already a real cart line in the quote, so it's charged; only the
12038
+ // gift metadata (message/recipient/hide-prices) and the pickup
12039
+ // schedule land here. A failure must never roll back a paid order, so
12040
+ // each is its own try/catch (mirrors _recordAutoDiscounts).
12041
+ await _persistGiftAndPickup(c, result.order, body);
11290
12042
  // When a gift card fully covered the order there's no Stripe
11291
12043
  // intent — the order is already paid. Skip the pay-cookie +
11292
12044
  // pay page and land the customer straight on the confirmation.
@@ -11453,12 +12205,16 @@ function mount(router, deps) {
11453
12205
  // governs every OTHER route. setHeader OVERWRITES the app-level header
11454
12206
  // for this response only.
11455
12207
  res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(["stripe"]));
12208
+ // Operator trust badges at the checkout placement (container-only;
12209
+ // drop-silent → "" on any failure).
12210
+ var payTrustBadges = await _trustBadgesHtml("checkout", req);
11456
12211
  _send(res, 200, renderPayPage({
11457
12212
  order: o,
11458
12213
  client_secret: clientSecret,
11459
12214
  publishable_key: pk,
11460
12215
  shop_name: shopName,
11461
12216
  theme: theme,
12217
+ trust_badges_html: payTrustBadges,
11462
12218
  }));
11463
12219
  });
11464
12220
 
@@ -11570,11 +12326,32 @@ function mount(router, deps) {
11570
12326
  } catch (_e) { ratingRow = null; ratingEligible = false; }
11571
12327
  }
11572
12328
  var ordUrl = req.url ? new URL(req.url, "http://localhost") : null;
12329
+ // Operator trust badges at the order_confirmation placement (container-
12330
+ // only; drop-silent → "" on any failure).
12331
+ var ordTrustBadges = await _trustBadgesHtml("order_confirmation", req);
12332
+ // Pickup (BOPIS) status — the IDOR gate above already proved ownership
12333
+ // (or this is a guest order on its capability URL). Drop-silent
12334
+ // defensive read: an unmigrated pickup table / bad shape → no panel.
12335
+ var pickup = null;
12336
+ if (deps.clickAndCollect) {
12337
+ try { pickup = await deps.clickAndCollect.getScheduleByOrder(o.id); }
12338
+ catch (e) { if (!(e instanceof TypeError)) throw e; pickup = null; }
12339
+ }
12340
+ // Gift options — the order's wrap / message / recipient / hide-prices.
12341
+ // Drop-silent: an unmigrated gift_options table / bad shape → no panel.
12342
+ var giftOptionsRow = null;
12343
+ if (deps.giftOptions) {
12344
+ try { giftOptionsRow = await deps.giftOptions.getForOrder(o.id); }
12345
+ catch (e) { if (!(e instanceof TypeError)) throw e; giftOptionsRow = null; }
12346
+ }
11573
12347
  _send(res, 200, renderOrder({
11574
12348
  order: o,
11575
12349
  product_lookup: productLookup,
11576
12350
  recommendations: recommendations,
11577
12351
  shipments: shipments,
12352
+ pickup: pickup,
12353
+ gift_options: giftOptionsRow,
12354
+ trust_badges_html: ordTrustBadges,
11578
12355
  rating: ratingRow,
11579
12356
  rating_eligible: ratingEligible,
11580
12357
  rating_notice: ordUrl ? _ratingNoticeFor(ordUrl.searchParams.get("rate_err")) : null,
@@ -12087,6 +12864,8 @@ function mount(router, deps) {
12087
12864
  order_product_lookup: orderProductLookup,
12088
12865
  passkey_count: passkeyCount,
12089
12866
  preorders_enabled: !!preorder,
12867
+ pickups_enabled: !!deps.clickAndCollect,
12868
+ payment_methods_enabled: !!(deps.paymentMethods && deps.payment),
12090
12869
  shop_name: shopName,
12091
12870
  cart_count: cartCount,
12092
12871
  }));
@@ -12357,6 +13136,181 @@ function mount(router, deps) {
12357
13136
  }
12358
13137
  });
12359
13138
 
13139
+ // ---- saved payment methods (Stripe SetupIntent vault) -------------
13140
+ //
13141
+ // List / set-default / archive a customer's saved cards, plus an add-card
13142
+ // flow over a Stripe SetupIntent (the shop never sees the PAN/CVV — only
13143
+ // the opaque pm_… token). Mounts only when BOTH the paymentMethods
13144
+ // primitive AND a Stripe payment handle are wired (the SetupIntent +
13145
+ // payment-method reads need Stripe). The list/set-default/archive
13146
+ // surfaces need no external JS; the add page reuses the route-scoped CSP +
13147
+ // an external same-origin island (saved-card.js), mirroring the pay page.
13148
+ if (deps.paymentMethods && deps.payment) {
13149
+ // IDOR GATE — paymentMethods.get/setDefault/archive are keyed by id
13150
+ // ALONE (no customer scope). Resolve the row by path id and confirm it
13151
+ // belongs to the authed customer; a foreign / unknown / malformed id is
13152
+ // a 404, never a cross-customer reveal or mutation. Mandatory before any
13153
+ // read/mutate. Mirrors _ownedPasskey.
13154
+ async function _ownedPaymentMethod(req, res, auth) {
13155
+ var id = req.params && req.params.id;
13156
+ var row;
13157
+ try { row = await deps.paymentMethods.get(id); }
13158
+ catch (e) {
13159
+ // A malformed id throws TypeError from the uuid guard — a 404, not
13160
+ // a 500 (a bad id is a missing record).
13161
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
13162
+ throw e;
13163
+ }
13164
+ if (!row || row.customer_id !== auth.customer_id) {
13165
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
13166
+ return null;
13167
+ }
13168
+ return row;
13169
+ }
13170
+
13171
+ async function _renderPaymentMethodsPage(req, res, auth, notice, code) {
13172
+ var rows = await deps.paymentMethods.listForCustomer(auth.customer_id);
13173
+ var cartCount = await _cartCountForReq(req);
13174
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
13175
+ var okKind = url ? url.searchParams.get("ok") : null;
13176
+ var successCopy = null;
13177
+ if (okKind === "added") successCopy = "Card saved.";
13178
+ if (okKind === "default") successCopy = "Default card updated.";
13179
+ if (okKind === "archived") successCopy = "Card removed.";
13180
+ if (okKind === "exists") successCopy = "That card is already on file.";
13181
+ _send(res, code || 200, renderPaymentMethods({
13182
+ payment_methods: rows,
13183
+ notice: notice || null,
13184
+ success: successCopy,
13185
+ shop_name: shopName,
13186
+ cart_count: cartCount,
13187
+ }));
13188
+ }
13189
+
13190
+ router.get("/account/payment-methods", async function (req, res) {
13191
+ var auth = _accountAuth(req, res); if (!auth) return;
13192
+ await _renderPaymentMethodsPage(req, res, auth, null);
13193
+ });
13194
+
13195
+ // The add-card page — loads Stripe.js (admitted by the route-scoped
13196
+ // CSP) + the saved-card.js island that mounts a SetupIntent Payment
13197
+ // Element. The publishable key rides a data-* attribute (no inline
13198
+ // script). Requires the publishable key; absent it, a 503 like the pay
13199
+ // page.
13200
+ router.get("/account/payment-methods/add", async function (req, res) {
13201
+ var auth = _accountAuth(req, res); if (!auth) return;
13202
+ var pk = deps.stripe_publishable_key || "";
13203
+ if (!pk) {
13204
+ return _send(res, 503, _wrap({
13205
+ title: "Add a card", shop_name: shopName, theme_css: undefined, cart_count: await _cartCountForReq(req),
13206
+ body: "<section class=\"account-returns\"><h1>Add a card</h1><p>Card storage isn't available right now.</p>" +
13207
+ "<a class=\"btn-secondary\" href=\"/account/payment-methods\">Back</a></section>",
13208
+ }));
13209
+ }
13210
+ // Route-scoped CSP admits js.stripe.com (script/connect/frame) so the
13211
+ // SDK + the same-origin saved-card.js island load — without relaxing
13212
+ // the app-level strict CSP on any other route.
13213
+ res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(["stripe"]));
13214
+ var cartCount = await _cartCountForReq(req);
13215
+ _send(res, 200, renderAddPaymentMethod({
13216
+ publishable_key: pk,
13217
+ shop_name: shopName,
13218
+ cart_count: cartCount,
13219
+ }));
13220
+ });
13221
+
13222
+ // Create (server-side) a SetupIntent for the shopper + return its
13223
+ // client_secret as JSON (CSRF-tokened; NOT an edge path). A fresh
13224
+ // Stripe Customer is minted per add (no stripe_customer_id column on
13225
+ // the customers table; Stripe dedupes by metadata/email) — acceptable
13226
+ // v1, documented in the build spec.
13227
+ router.post("/account/payment-methods/setup-intent", async function (req, res) {
13228
+ var auth = _accountAuth(req, res); if (!auth) return;
13229
+ function _json(status, obj) {
13230
+ res.status(status);
13231
+ res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
13232
+ var s = JSON.stringify(obj);
13233
+ return res.end ? res.end(s) : res.send(s);
13234
+ }
13235
+ try {
13236
+ var customer = await deps.payment.createCustomer({ metadata: { shop_customer_id: auth.customer_id } });
13237
+ if (!customer || !customer.id) return _json(502, { error: "customer-create-failed" });
13238
+ var si = await deps.payment.createSetupIntent({ customer: customer.id });
13239
+ if (!si || !si.client_secret) return _json(502, { error: "setup-intent-failed" });
13240
+ return _json(200, { client_secret: si.client_secret });
13241
+ } catch (e) {
13242
+ return _json(e instanceof TypeError ? 400 : 502, { error: (e && e.message) || "setup-intent-failed" });
13243
+ }
13244
+ });
13245
+
13246
+ // After Elements confirms the SetupIntent client-side, the browser
13247
+ // POSTs the setup_intent_id back; the server reads it → the resulting
13248
+ // pm_… → its display fields → stores via paymentMethods.add. A
13249
+ // duplicate token (already on file) is an idempotent notice, not a 500.
13250
+ router.post("/account/payment-methods", async function (req, res) {
13251
+ var auth = _accountAuth(req, res); if (!auth) return;
13252
+ var body = req.body || {};
13253
+ var setupIntentId = typeof body.setup_intent_id === "string" ? body.setup_intent_id : "";
13254
+ if (!setupIntentId) return _renderPaymentMethodsPage(req, res, auth, "Couldn't add that card — please try again.", 400);
13255
+ try {
13256
+ var si = await deps.payment.retrieveSetupIntent(setupIntentId);
13257
+ var pmId = si && si.payment_method;
13258
+ if (!pmId) return _renderPaymentMethodsPage(req, res, auth, "That card couldn't be confirmed — please try again.", 400);
13259
+ var pm = await deps.payment.retrievePaymentMethod(pmId);
13260
+ var card = (pm && pm.card) || {};
13261
+ if (!card.brand || !card.last4) {
13262
+ return _renderPaymentMethodsPage(req, res, auth, "We couldn't read that card's details — please try again.", 400);
13263
+ }
13264
+ await deps.paymentMethods.add({
13265
+ customer_id: auth.customer_id,
13266
+ processor: "stripe",
13267
+ processor_token: pmId,
13268
+ brand: card.brand,
13269
+ last4: card.last4,
13270
+ exp_month: Number(card.exp_month),
13271
+ exp_year: Number(card.exp_year),
13272
+ });
13273
+ } catch (e) {
13274
+ if (e && e.code === "PAYMENT_METHOD_DUPLICATE_TOKEN") {
13275
+ res.status(303); res.setHeader && res.setHeader("location", "/account/payment-methods?ok=exists");
13276
+ return res.end ? res.end() : res.send("");
13277
+ }
13278
+ if (e instanceof TypeError) return _renderPaymentMethodsPage(req, res, auth, "That card couldn't be saved — please try again.", 400);
13279
+ throw e;
13280
+ }
13281
+ res.status(303); res.setHeader && res.setHeader("location", "/account/payment-methods?ok=added");
13282
+ return res.end ? res.end() : res.send("");
13283
+ });
13284
+
13285
+ router.post("/account/payment-methods/:id/default", async function (req, res) {
13286
+ var auth = _accountAuth(req, res); if (!auth) return;
13287
+ var pm = await _ownedPaymentMethod(req, res, auth); if (!pm) return;
13288
+ try { await deps.paymentMethods.setDefault(pm.id); }
13289
+ catch (e) {
13290
+ if (e instanceof TypeError || (e && typeof e.code === "string" && e.code.indexOf("PAYMENT_METHOD_") === 0)) {
13291
+ return _renderPaymentMethodsPage(req, res, auth, "Couldn't set that as your default card.", 400);
13292
+ }
13293
+ throw e;
13294
+ }
13295
+ res.status(303); res.setHeader && res.setHeader("location", "/account/payment-methods?ok=default");
13296
+ return res.end ? res.end() : res.send("");
13297
+ });
13298
+
13299
+ router.post("/account/payment-methods/:id/archive", async function (req, res) {
13300
+ var auth = _accountAuth(req, res); if (!auth) return;
13301
+ var pm = await _ownedPaymentMethod(req, res, auth); if (!pm) return;
13302
+ try { await deps.paymentMethods.archive({ payment_method_id: pm.id, reason: "customer_request" }); }
13303
+ catch (e) {
13304
+ if (e instanceof TypeError || (e && typeof e.code === "string" && e.code.indexOf("PAYMENT_METHOD_") === 0)) {
13305
+ return _renderPaymentMethodsPage(req, res, auth, "Couldn't remove that card.", 400);
13306
+ }
13307
+ throw e;
13308
+ }
13309
+ res.status(303); res.setHeader && res.setHeader("location", "/account/payment-methods?ok=archived");
13310
+ return res.end ? res.end() : res.send("");
13311
+ });
13312
+ }
13313
+
12360
13314
  // Profile edit — display-name only. Email is hash-only + the OAuth
12361
13315
  // linking key, so the primitive refuses an email change without a
12362
13316
  // verification ceremony; the form shows it read-only. PRG with a
@@ -14634,6 +15588,26 @@ function mount(router, deps) {
14634
15588
  });
14635
15589
  }
14636
15590
 
15591
+ // Click-and-collect — the signed-in customer's pickup list. Scoped via
15592
+ // the primitive's customerSchedules, which resolves the customer→order
15593
+ // linkage through the shared order handle, so a foreign order's pickup
15594
+ // never appears here (the IDOR defense is the order-scoping). Mounts only
15595
+ // when the primitive is wired.
15596
+ if (deps.clickAndCollect) {
15597
+ router.get("/account/pickups", async function (req, res) {
15598
+ var auth = _accountAuth(req, res); if (!auth) return;
15599
+ var schedules = [];
15600
+ try { schedules = await deps.clickAndCollect.customerSchedules(auth.customer_id); }
15601
+ catch (e) { if (!(e instanceof TypeError)) throw e; schedules = []; }
15602
+ var cartCount = await _cartCountForReq(req);
15603
+ _send(res, 200, renderAccountPickups({
15604
+ pickups: schedules,
15605
+ shop_name: shopName,
15606
+ cart_count: cartCount,
15607
+ }));
15608
+ });
15609
+ }
15610
+
14637
15611
  // Support tickets — the signed-in customer raises a ticket, lists
14638
15612
  // their own, reads a thread, and replies. EVERY route is login-gated
14639
15613
  // AND scoped to the session customer's id: the support primitive
@@ -15782,6 +16756,53 @@ function mount(router, deps) {
15782
16756
  res.end ? res.end() : res.send("");
15783
16757
  });
15784
16758
 
16759
+ // POST /cart/gift — set (or clear) the cart's gift wrap. The wrap rides as
16760
+ // a REAL cart line so its fee flows through pricing.totals and is charged
16761
+ // by checkout.confirm (NEVER a post-commit hook — that would mis-charge).
16762
+ // Selecting "No gift wrap" removes any wrap line. Selecting a wrap removes
16763
+ // any prior wrap line then adds the chosen one (qty 1, capped by
16764
+ // max_per_order). The message / recipient are collected at checkout (they
16765
+ // need the order id). Mounts only when the gift-options primitive is wired.
16766
+ if (deps.giftOptions) {
16767
+ router.post("/cart/gift", async function (req, res) {
16768
+ var body = req.body || {};
16769
+ var wrapSku = typeof body.wrap_sku === "string" ? body.wrap_sku.trim() : "";
16770
+ var resolved = await _getOrCreateCart(req, res, "USD");
16771
+ var cartId = resolved.cart.id;
16772
+ try {
16773
+ // Resolve the active wrap catalog so we know every wrap sku (to
16774
+ // remove a stale wrap line) and the selected wrap's cap.
16775
+ var wraps = await deps.giftOptions.listWraps({ active_only: true });
16776
+ var wrapBySku = {};
16777
+ for (var i = 0; i < wraps.length; i += 1) wrapBySku[wraps[i].wrap_sku] = wraps[i];
16778
+ // Remove any existing wrap line first (every active wrap's sku).
16779
+ var existingLines = await deps.cart.listLines(cartId);
16780
+ for (var li = 0; li < existingLines.length; li += 1) {
16781
+ if (wrapBySku[existingLines[li].sku]) {
16782
+ await deps.cart.removeLine(existingLines[li].id, cartId);
16783
+ }
16784
+ }
16785
+ // Add the selected wrap (when one was chosen + it's a real active
16786
+ // wrap). The wrap_sku is a real catalog variant; resolve its variant
16787
+ // id and add it as a line — the price snapshot comes from the catalog
16788
+ // price, so the fee is charged through the normal quote path.
16789
+ if (wrapSku && wrapBySku[wrapSku]) {
16790
+ var variant = await deps.catalog.variants.bySku(wrapSku);
16791
+ if (variant) {
16792
+ await deps.cart.addLine(cartId, { variant_id: variant.id, qty: 1 });
16793
+ }
16794
+ }
16795
+ } catch (e) {
16796
+ if (!(e instanceof TypeError)) throw e;
16797
+ // A bad shape degrades to a silent no-op redirect rather than 500 —
16798
+ // the cart is still valid, the wrap just wasn't changed.
16799
+ }
16800
+ res.status(303);
16801
+ res.setHeader && res.setHeader("location", "/cart");
16802
+ return res.end ? res.end() : res.send("");
16803
+ });
16804
+ }
16805
+
15785
16806
  // POST /cart/bundle — add every member of a bundle to the cart at the
15786
16807
  // bundle price, atomically. Reads `bundle_sku` from the form body.
15787
16808
  // The price is recomputed server-side from the catalog + the bundle
@@ -16081,6 +17102,150 @@ function mount(router, deps) {
16081
17102
  });
16082
17103
  }
16083
17104
 
17105
+ // ---- back-in-stock "Notify me" ------------------------------------------
17106
+ //
17107
+ // The PDP buy box shows a "Notify me when back in stock" form on every
17108
+ // sold-out SKU (dual-rendered, edge-cache-safe). It posts to the distinct
17109
+ // CSRF-exempt action /stock-alert/subscribe (in EDGE_POST_PATHS). Double
17110
+ // opt-in: subscribe writes a pending row + returns a one-time plaintext
17111
+ // token, the confirmation email carries /stock-alert/confirm/<token>, and
17112
+ // the Worker cron sweep emails once when stock returns. Mount only when the
17113
+ // stockAlerts primitive is wired.
17114
+ if (deps.stockAlerts) {
17115
+ // Confirm-link base — SHOP_ORIGIN when present (set into sfDeps as
17116
+ // shop_origin in server.js), else the request Host (the email is best-
17117
+ // effort anyway; a relative path still resolves in a browser).
17118
+ function _stockAlertOrigin(req) {
17119
+ if (typeof deps.shop_origin === "string" && deps.shop_origin) {
17120
+ return deps.shop_origin.replace(/\/$/, "");
17121
+ }
17122
+ var host = req && req.headers && req.headers.host;
17123
+ return host ? "https://" + host : "";
17124
+ }
17125
+
17126
+ router.post("/stock-alert/subscribe", async function (req, res) {
17127
+ var body = req.body || {};
17128
+ var cartCount = 0;
17129
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
17130
+ try {
17131
+ var result = await deps.stockAlerts.subscribe({
17132
+ email: body.email,
17133
+ sku: body.sku,
17134
+ variant_id: (body.variant_id != null && body.variant_id !== "") ? body.variant_id : null,
17135
+ });
17136
+ // Only a brand-new subscription carries a plaintext token. Send the
17137
+ // confirmation email best-effort; a mailer hiccup must not 500 the
17138
+ // form (the row is already written). already-pending / already-
17139
+ // confirmed render the SAME thank-you copy — never reveal prior state.
17140
+ if (result && result.status === "subscribed" && result.confirmation_token && deps.email) {
17141
+ var origin = _stockAlertOrigin(req);
17142
+ var confirmUrl = origin + "/stock-alert/confirm/" + encodeURIComponent(result.confirmation_token);
17143
+ // Resolve the product title for the SKU (cheap indexed read; drop-
17144
+ // silent → fall back to the SKU as the title).
17145
+ var titleForSku = body.sku;
17146
+ try {
17147
+ if (deps.catalog && deps.catalog.products && typeof deps.catalog.products.bySku === "function") {
17148
+ var prodForSku = await deps.catalog.products.bySku(body.sku);
17149
+ if (prodForSku && prodForSku.title) titleForSku = prodForSku.title;
17150
+ }
17151
+ } catch (_e) { /* drop-silent — fall back to the SKU as the title */ }
17152
+ try {
17153
+ await deps.email.sendStockAlertConfirmation({
17154
+ to: body.email,
17155
+ product_title: titleForSku,
17156
+ sku: body.sku,
17157
+ confirm_url: confirmUrl,
17158
+ });
17159
+ } catch (_mailErr) { /* drop-silent — the row is written; the cron still fires on confirm */ }
17160
+ }
17161
+ return _send(res, 200, renderStockAlertThanks({ shop_name: shopName, cart_count: cartCount }));
17162
+ } catch (e) {
17163
+ // TypeError == operator/customer-fault input refusal (bad email / sku
17164
+ // shape) → 400 friendly page; everything else (D1 unreachable) → 500
17165
+ // non-leaking page.
17166
+ var isInputError = e instanceof TypeError;
17167
+ return _send(res, isInputError ? 400 : 500, renderStockAlertError({
17168
+ shop_name: shopName,
17169
+ cart_count: cartCount,
17170
+ message: isInputError ? "That doesn't look like a valid email address. Check the format and try again." : null,
17171
+ }));
17172
+ }
17173
+ });
17174
+
17175
+ router.get("/stock-alert/confirm/:token", async function (req, res) {
17176
+ var token = req.params && req.params.token;
17177
+ var cartCount = 0;
17178
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
17179
+ var outcome;
17180
+ try {
17181
+ var row = await deps.stockAlerts.confirm({ token: token });
17182
+ if (!row) outcome = "invalid";
17183
+ else if (row.notified_at != null) outcome = "already-notified";
17184
+ else outcome = "confirmed";
17185
+ } catch (e) {
17186
+ // A bad-shape token throws TypeError — render the generic non-leaking
17187
+ // "invalid" page rather than a 500 on a public, unauthenticated route.
17188
+ if (e instanceof TypeError) outcome = "invalid";
17189
+ else {
17190
+ _log.error("storefront stock-alert confirm failed", {
17191
+ route: "/stock-alert/confirm", request_id: (req && req.requestId) || null,
17192
+ err: (e && e.message) || String(e),
17193
+ });
17194
+ outcome = "invalid";
17195
+ }
17196
+ }
17197
+ return _send(res, 200, renderStockAlertConfirm({
17198
+ shop_name: shopName, cart_count: cartCount, outcome: outcome,
17199
+ }));
17200
+ });
17201
+
17202
+ // Unsubscribe — by (email, sku, variant) tuple carried in the link query
17203
+ // (stockAlerts has no unsubscribe-token API). GET renders a friendly
17204
+ // confirm page (token-carrying container form); POST consumes it.
17205
+ router.get("/stock-alert/unsubscribe", async function (req, res) {
17206
+ var q = (req.query && typeof req.query === "object") ? req.query : {};
17207
+ var cartCount = 0;
17208
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
17209
+ return _send(res, 200, renderStockAlertUnsubscribeConfirm({
17210
+ shop_name: shopName,
17211
+ cart_count: cartCount,
17212
+ email: typeof q.email === "string" ? q.email : "",
17213
+ sku: typeof q.sku === "string" ? q.sku : "",
17214
+ variant_id: typeof q.variant_id === "string" ? q.variant_id : "",
17215
+ }));
17216
+ });
17217
+
17218
+ router.post("/stock-alert/unsubscribe", async function (req, res) {
17219
+ var body = req.body || {};
17220
+ var cartCount = 0;
17221
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
17222
+ var outcome;
17223
+ try {
17224
+ // Idempotent — a missing row returns { removed: false }, which still
17225
+ // reads as "you're unsubscribed" (clicking the link twice is fine).
17226
+ await deps.stockAlerts.unsubscribe({
17227
+ email: body.email,
17228
+ sku: body.sku,
17229
+ variant_id: (body.variant_id != null && body.variant_id !== "") ? body.variant_id : null,
17230
+ });
17231
+ outcome = "unsubscribed";
17232
+ } catch (e) {
17233
+ // A bad-shape tuple throws TypeError → generic non-leaking page; a
17234
+ // real fault degrades the same way rather than 500-ing a public route.
17235
+ outcome = "invalid";
17236
+ if (!(e instanceof TypeError)) {
17237
+ _log.error("storefront stock-alert unsubscribe failed", {
17238
+ route: "/stock-alert/unsubscribe", request_id: (req && req.requestId) || null,
17239
+ err: (e && e.message) || String(e),
17240
+ });
17241
+ }
17242
+ }
17243
+ return _send(res, 200, renderStockAlertUnsubscribeResult({
17244
+ shop_name: shopName, cart_count: cartCount, outcome: outcome,
17245
+ }));
17246
+ });
17247
+ }
17248
+
16084
17249
  // ---- cookie consent -----------------------------------------------------
16085
17250
  //
16086
17251
  // GDPR (EU 2016/679 art. 6 + 7) + ePrivacy (2002/58/EC art. 5(3)) opt-in
@@ -16467,6 +17632,9 @@ module.exports = {
16467
17632
  renderAccount: renderAccount,
16468
17633
  renderPasskeys: renderPasskeys,
16469
17634
  renderPasskeyRemoveConfirm: renderPasskeyRemoveConfirm,
17635
+ renderPaymentMethods: renderPaymentMethods,
17636
+ renderAddPaymentMethod: renderAddPaymentMethod,
17637
+ renderAccountPickups: renderAccountPickups,
16470
17638
  renderProfile: renderProfile,
16471
17639
  renderAccountSubscriptions: renderAccountSubscriptions,
16472
17640
  renderCookiePreferences: renderCookiePreferences,