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