@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/CHANGELOG.md +2 -0
- package/lib/admin.js +515 -1
- package/lib/asset-manifest.json +5 -1
- package/lib/click-and-collect.js +35 -5
- package/lib/payment.js +77 -0
- package/lib/storefront.js +723 -0
- package/package.json +1 -1
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 ? " · <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 →</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)) + " · ···· " + 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,
|