@blamejs/blamejs-shop 0.3.53 → 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
@@ -4101,6 +4101,57 @@ function renderReturns(opts) {
4101
4101
  });
4102
4102
  }
4103
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
+
4104
4155
  // Customer-facing phrasing for a return label's carrier-scan status. The
4105
4156
  // return-labels module's FSM enum (issued / shipped / in_transit /
4106
4157
  // delivered / exception) is operator/carrier vocabulary; these read for
@@ -6294,6 +6345,53 @@ function _loyaltyCheckoutField(bal, perUsd) {
6294
6345
  "</label></div>";
6295
6346
  }
6296
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
+
6297
6395
  function renderCheckoutForm(opts) {
6298
6396
  if (!opts) throw new TypeError("storefront.renderCheckoutForm: opts required");
6299
6397
  var lines = opts.lines || [];
@@ -6365,6 +6463,17 @@ function renderCheckoutForm(opts) {
6365
6463
  if (checkoutCaptcha) {
6366
6464
  body = body.replace("</form>", checkoutCaptcha + "</form>");
6367
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
+ }
6368
6477
  // Signed-in customer with a spendable points balance — surface a
6369
6478
  // redeem-at-checkout field. The block is appended as raw HTML (the
6370
6479
  // balance + value are numbers we control, the conversion ratio is the
@@ -6608,6 +6717,8 @@ var ORDER_PAGE =
6608
6717
  " <tbody>{{line_rows}}</tbody>\n" +
6609
6718
  " </table>\n" +
6610
6719
  " </div>\n" +
6720
+ " RAW_ORDER_PICKUP" +
6721
+ " RAW_ORDER_GIFT" +
6611
6722
  " RAW_ORDER_ACTIONS" +
6612
6723
  " RAW_ORDER_RATING" +
6613
6724
  " </div>\n" +
@@ -6945,6 +7056,60 @@ function _ratingNoticeFor(code) {
6945
7056
  return null;
6946
7057
  }
6947
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
+
6948
7113
  function renderOrder(opts) {
6949
7114
  if (!opts || !opts.order) throw new TypeError("storefront.renderOrder: opts.order required");
6950
7115
  var o = opts.order;
@@ -7006,6 +7171,8 @@ function renderOrder(opts) {
7006
7171
  ship_to: o.ship_to || null,
7007
7172
  timeline_html: timelineHtml,
7008
7173
  tracking_html: trackingHtml,
7174
+ pickup_html: _orderPickupBlock(opts.pickup),
7175
+ gift_html: _orderGiftBlock(opts.gift_options),
7009
7176
  actions_html: actionsHtml,
7010
7177
  rating_html: ratingHtml,
7011
7178
  can_return: _orderEligibleForReturn(o.status),
@@ -7057,8 +7224,13 @@ function renderOrder(opts) {
7057
7224
  .replace("RAW_REORDER_NOTICE", reorderNotice + cancelNotice)
7058
7225
  .replace("RAW_ORDER_TIMELINE", timelineHtml)
7059
7226
  .replace("RAW_ORDER_TRACKING", trackingHtml)
7227
+ .replace("RAW_ORDER_PICKUP", _orderPickupBlock(opts.pickup))
7060
7228
  .replace("RAW_ORDER_ACTIONS", actionsHtml)
7061
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));
7062
7234
  // The rating panel carries customer/operator free text (already escaped
7063
7235
  // into comment_html / response_html, but a `$` in that text would still
7064
7236
  // trip String.replace's dollar substitution) — splice it via the
@@ -7241,6 +7413,41 @@ function _buildCartTotalsRows(t, fmt, subtotalStr, totalStr) {
7241
7413
  return rows;
7242
7414
  }
7243
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
+
7244
7451
  function renderCart(opts) {
7245
7452
  if (!opts) throw new TypeError("storefront.renderCart: opts required");
7246
7453
  var lines = opts.lines || [];
@@ -7373,6 +7580,13 @@ function renderCart(opts) {
7373
7580
  .replace("RAW_TOTALS_ROWS", totalsRows)
7374
7581
  .replace("RAW_CHECKOUT_CTA", checkoutCta)
7375
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);
7376
7590
  }
7377
7591
  return _wrap(Object.assign({
7378
7592
  title: "Cart",
@@ -8394,6 +8608,8 @@ var ACCOUNT_DASH_PAGE =
8394
8608
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
8395
8609
  " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
8396
8610
  " <a class=\"btn-secondary\" href=\"/account/exchanges\">Exchanges</a>\n" +
8611
+ " RAW_PICKUPS_LINK\n" +
8612
+ " RAW_PAYMENT_METHODS_LINK\n" +
8397
8613
  " <a class=\"btn-secondary\" href=\"/account/support\">Support</a>\n" +
8398
8614
  " <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
8399
8615
  " <a class=\"btn-secondary\" href=\"/account/credit\">Store credit</a>\n" +
@@ -8511,6 +8727,15 @@ function renderAccount(opts) {
8511
8727
  // links to a 404.
8512
8728
  .replace("RAW_PREORDER_LINK", opts.preorders_enabled
8513
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>"
8514
8739
  : "");
8515
8740
  return _wrap({
8516
8741
  title: "Account",
@@ -8671,6 +8896,120 @@ function renderPasskeyRemoveConfirm(opts) {
8671
8896
  });
8672
8897
  }
8673
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
+
8674
9013
  // ---- profile edit ------------------------------------------------------
8675
9014
  //
8676
9015
  // Display-name edit for the signed-in customer. Email is stored hash-only
@@ -11345,6 +11684,26 @@ function mount(router, deps) {
11345
11684
  hero_media: media.length ? media[0] : null,
11346
11685
  };
11347
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
+ }
11348
11707
  _send(res, 200, renderCart(Object.assign({
11349
11708
  lines: lines,
11350
11709
  totals: totals,
@@ -11353,6 +11712,8 @@ function mount(router, deps) {
11353
11712
  product_lookup: productLookup,
11354
11713
  can_save: !!(deps.saveForLater && deps.customers),
11355
11714
  checkout_available: !!(deps.checkout && deps.order),
11715
+ gift_wraps: giftWraps,
11716
+ gift_wrap_in_cart: giftWrapInCart,
11356
11717
  added: added,
11357
11718
  shop_name: shopName,
11358
11719
  theme: theme,
@@ -11462,6 +11823,28 @@ function mount(router, deps) {
11462
11823
  }
11463
11824
  } catch (_e) { prefill = null; }
11464
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
+ }
11465
11848
  return {
11466
11849
  lines: lines, totals: totals, totals_detail: totalsDetail,
11467
11850
  shop_name: shopName, theme: theme,
@@ -11469,6 +11852,9 @@ function mount(router, deps) {
11469
11852
  paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
11470
11853
  loyalty_balance: loyaltyBalance,
11471
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,
11472
11858
  prefill: prefill,
11473
11859
  inline_error: inlineError || null,
11474
11860
  // CAPTCHA widget props — set only when a provider is active, so the
@@ -11492,6 +11878,72 @@ function mount(router, deps) {
11492
11878
  res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(keys));
11493
11879
  }
11494
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
+
11495
11947
  router.get("/checkout", async function (req, res) {
11496
11948
  var sid = _readSidCookie(req);
11497
11949
  if (!sid) return _send(res, 303, "<a href=\"/cart\">Cart is empty</a>"), res.setHeader && res.setHeader("location", "/cart");
@@ -11580,6 +12032,13 @@ function mount(router, deps) {
11580
12032
  loyalty_redeem_points: _parseRedeemPoints(body.loyalty_redeem_points),
11581
12033
  idempotency_key: "checkout:" + c.id + ":" + b.uuid.v7(),
11582
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);
11583
12042
  // When a gift card fully covered the order there's no Stripe
11584
12043
  // intent — the order is already paid. Skip the pay-cookie +
11585
12044
  // pay page and land the customer straight on the confirmation.
@@ -11870,11 +12329,28 @@ function mount(router, deps) {
11870
12329
  // Operator trust badges at the order_confirmation placement (container-
11871
12330
  // only; drop-silent → "" on any failure).
11872
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
+ }
11873
12347
  _send(res, 200, renderOrder({
11874
12348
  order: o,
11875
12349
  product_lookup: productLookup,
11876
12350
  recommendations: recommendations,
11877
12351
  shipments: shipments,
12352
+ pickup: pickup,
12353
+ gift_options: giftOptionsRow,
11878
12354
  trust_badges_html: ordTrustBadges,
11879
12355
  rating: ratingRow,
11880
12356
  rating_eligible: ratingEligible,
@@ -12388,6 +12864,8 @@ function mount(router, deps) {
12388
12864
  order_product_lookup: orderProductLookup,
12389
12865
  passkey_count: passkeyCount,
12390
12866
  preorders_enabled: !!preorder,
12867
+ pickups_enabled: !!deps.clickAndCollect,
12868
+ payment_methods_enabled: !!(deps.paymentMethods && deps.payment),
12391
12869
  shop_name: shopName,
12392
12870
  cart_count: cartCount,
12393
12871
  }));
@@ -12658,6 +13136,181 @@ function mount(router, deps) {
12658
13136
  }
12659
13137
  });
12660
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
+
12661
13314
  // Profile edit — display-name only. Email is hash-only + the OAuth
12662
13315
  // linking key, so the primitive refuses an email change without a
12663
13316
  // verification ceremony; the form shows it read-only. PRG with a
@@ -14935,6 +15588,26 @@ function mount(router, deps) {
14935
15588
  });
14936
15589
  }
14937
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
+
14938
15611
  // Support tickets — the signed-in customer raises a ticket, lists
14939
15612
  // their own, reads a thread, and replies. EVERY route is login-gated
14940
15613
  // AND scoped to the session customer's id: the support primitive
@@ -16083,6 +16756,53 @@ function mount(router, deps) {
16083
16756
  res.end ? res.end() : res.send("");
16084
16757
  });
16085
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
+
16086
16806
  // POST /cart/bundle — add every member of a bundle to the cart at the
16087
16807
  // bundle price, atomically. Reads `bundle_sku` from the form body.
16088
16808
  // The price is recomputed server-side from the catalog + the bundle
@@ -16912,6 +17632,9 @@ module.exports = {
16912
17632
  renderAccount: renderAccount,
16913
17633
  renderPasskeys: renderPasskeys,
16914
17634
  renderPasskeyRemoveConfirm: renderPasskeyRemoveConfirm,
17635
+ renderPaymentMethods: renderPaymentMethods,
17636
+ renderAddPaymentMethod: renderAddPaymentMethod,
17637
+ renderAccountPickups: renderAccountPickups,
16915
17638
  renderProfile: renderProfile,
16916
17639
  renderAccountSubscriptions: renderAccountSubscriptions,
16917
17640
  renderCookiePreferences: renderCookiePreferences,