@blamejs/blamejs-shop 0.3.53 → 0.3.55
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 +599 -75
- package/lib/asset-manifest.json +9 -5
- package/lib/click-and-collect.js +35 -5
- package/lib/payment.js +77 -0
- package/lib/storefront.js +877 -47
- package/package.json +1 -1
package/lib/storefront.js
CHANGED
|
@@ -2192,6 +2192,49 @@ function _buildPreorderNotice(marker) {
|
|
|
2192
2192
|
return "<p class=\"" + cls + "\" role=\"" + role + "\">" + b.template.escapeHtml(n.copy) + "</p>\n ";
|
|
2193
2193
|
}
|
|
2194
2194
|
|
|
2195
|
+
// Map a thrown validator TypeError to the single form field it rejected, so a
|
|
2196
|
+
// re-render can mark exactly that input with aria-invalid + a per-field error
|
|
2197
|
+
// span (WCAG 3.3.1 Error Identification / 3.3.3 Error Suggestion) — IN ADDITION
|
|
2198
|
+
// to the page-top role="alert" summary banner the routes already render.
|
|
2199
|
+
//
|
|
2200
|
+
// The shop's validators throw "<module>: <field> <reason>" (or
|
|
2201
|
+
// "<module>.<method>: <field> <reason>") where the leading token after the
|
|
2202
|
+
// prefix IS the form `name`. This reads that already-thrown error; it does NOT
|
|
2203
|
+
// re-validate (the backend stays the single validator). `modulePrefix` is the
|
|
2204
|
+
// thrower's prefix (e.g. "addresses", "reviews", "productQA", "supportTickets")
|
|
2205
|
+
// and `formFields` is the set of `name`s the form actually renders, so an error
|
|
2206
|
+
// on a non-form internal (e.g. customer_id) returns null — the page-top banner
|
|
2207
|
+
// still shows, but no input is falsely marked.
|
|
2208
|
+
//
|
|
2209
|
+
// Returns { field, message } with the module prefix stripped from `message`
|
|
2210
|
+
// (so the per-field span carries just the human reason), or null when the
|
|
2211
|
+
// rejected token isn't one of this form's fields.
|
|
2212
|
+
function _fieldFromValidatorError(e, modulePrefix, formFields) {
|
|
2213
|
+
var raw = (e && e.message) || "";
|
|
2214
|
+
var prefixRe = new RegExp("^" + modulePrefix + "(?:\\.\\w+)?:\\s+");
|
|
2215
|
+
var m = new RegExp("^" + modulePrefix + "(?:\\.\\w+)?:\\s+([a-z_]+)\\b").exec(raw);
|
|
2216
|
+
var field = m && m[1];
|
|
2217
|
+
if (!field || !formFields || !Object.prototype.hasOwnProperty.call(formFields, field)) return null;
|
|
2218
|
+
return { field: field, message: raw.replace(prefixRe, "") };
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// Build the aria-invalid + aria-describedby attribute fragment for an input
|
|
2222
|
+
// when `inv` (a { field, message } from _fieldFromValidatorError) names this
|
|
2223
|
+
// field. Empty string otherwise. `idPrefix` + field is the static, escaped id.
|
|
2224
|
+
function _fieldAriaAttr(idPrefix, name, inv) {
|
|
2225
|
+
if (!inv || inv.field !== name) return "";
|
|
2226
|
+
return " aria-invalid=\"true\" aria-describedby=\"" + b.template.escapeHtml(idPrefix + name) + "\"";
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// Build the adjacent per-field error span (role="alert") for the rejected
|
|
2230
|
+
// field; empty string otherwise. The reason is escaped operator-validator
|
|
2231
|
+
// prose; the id is the static, escaped `idPrefix + name`.
|
|
2232
|
+
function _fieldErrorSpan(idPrefix, name, inv) {
|
|
2233
|
+
if (!inv || inv.field !== name) return "";
|
|
2234
|
+
return "<span class=\"form-field__error\" id=\"" + b.template.escapeHtml(idPrefix + name) +
|
|
2235
|
+
"\" role=\"alert\">" + b.template.escapeHtml(inv.message == null ? "" : String(inv.message)) + "</span>";
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2195
2238
|
var PRODUCT_PAGE =
|
|
2196
2239
|
"<section class=\"pdp\">\n" +
|
|
2197
2240
|
" <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
|
|
@@ -2486,17 +2529,20 @@ var REVIEW_FORM_PAGE =
|
|
|
2486
2529
|
" <h1 class=\"review-form-page__title\">Review {{title}}</h1>\n" +
|
|
2487
2530
|
" RAW_NOTICE_PLACEHOLDER\n" +
|
|
2488
2531
|
" <form class=\"review-form\" method=\"post\" action=\"/products/{{slug}}/review\">\n" +
|
|
2489
|
-
" <fieldset class=\"review-form__rating\">\n" +
|
|
2532
|
+
" <fieldset class=\"review-form__rating\"RAW_RATING_ARIA_PLACEHOLDER>\n" +
|
|
2490
2533
|
" <legend>Your rating</legend>\n" +
|
|
2491
2534
|
" RAW_STARS_PLACEHOLDER\n" +
|
|
2535
|
+
" RAW_RATING_ERROR_PLACEHOLDER\n" +
|
|
2492
2536
|
" </fieldset>\n" +
|
|
2493
2537
|
" <label class=\"form-field\">\n" +
|
|
2494
2538
|
" <span class=\"form-field__label\">Title</span>\n" +
|
|
2495
|
-
" <input type=\"text\" name=\"title\" maxlength=\"120\" required autocomplete=\"off\">\n" +
|
|
2539
|
+
" <input type=\"text\" name=\"title\" maxlength=\"120\" required autocomplete=\"off\"RAW_TITLE_ARIA_PLACEHOLDER>\n" +
|
|
2540
|
+
" RAW_TITLE_ERROR_PLACEHOLDER\n" +
|
|
2496
2541
|
" </label>\n" +
|
|
2497
2542
|
" <label class=\"form-field\">\n" +
|
|
2498
2543
|
" <span class=\"form-field__label\">Your review</span>\n" +
|
|
2499
|
-
" <textarea name=\"body\" maxlength=\"4000\" rows=\"6\"></textarea>\n" +
|
|
2544
|
+
" <textarea name=\"body\" maxlength=\"4000\" rows=\"6\"RAW_BODY_ARIA_PLACEHOLDER></textarea>\n" +
|
|
2545
|
+
" RAW_BODY_ERROR_PLACEHOLDER\n" +
|
|
2500
2546
|
" </label>\n" +
|
|
2501
2547
|
" <button type=\"submit\" class=\"btn-primary\">Submit review</button>\n" +
|
|
2502
2548
|
" </form>\n" +
|
|
@@ -2519,12 +2565,17 @@ function renderReviewForm(opts) {
|
|
|
2519
2565
|
var notice = opts.notice
|
|
2520
2566
|
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
2521
2567
|
: "";
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2568
|
+
// On a validation re-render, mark the one rejected control. The rating is a
|
|
2569
|
+
// <fieldset> (the aria goes on the group, per the existing fieldset/legend
|
|
2570
|
+
// shape); title/body are ordinary inputs. ids are static "review-err-<name>".
|
|
2571
|
+
var inv = opts.invalid_field || null;
|
|
2572
|
+
var body = _render(REVIEW_FORM_PAGE, { title: opts.product.title, slug: slug })
|
|
2526
2573
|
.replace("RAW_NOTICE_PLACEHOLDER", notice)
|
|
2527
2574
|
.replace("RAW_STARS_PLACEHOLDER", stars);
|
|
2575
|
+
["rating", "title", "body"].forEach(function (name) {
|
|
2576
|
+
body = _spliceRaw(body, "RAW_" + name.toUpperCase() + "_ARIA_PLACEHOLDER", _fieldAriaAttr("review-err-", name, inv));
|
|
2577
|
+
body = _spliceRaw(body, "RAW_" + name.toUpperCase() + "_ERROR_PLACEHOLDER", _fieldErrorSpan("review-err-", name, inv));
|
|
2578
|
+
});
|
|
2528
2579
|
return _wrap({
|
|
2529
2580
|
title: "Review " + opts.product.title,
|
|
2530
2581
|
shop_name: opts.shop_name || "blamejs.shop",
|
|
@@ -2631,7 +2682,8 @@ var QA_FORM_PAGE =
|
|
|
2631
2682
|
" <form class=\"review-form\" method=\"post\" action=\"/products/{{slug}}/question\">\n" +
|
|
2632
2683
|
" <label class=\"form-field\">\n" +
|
|
2633
2684
|
" <span class=\"form-field__label\">Your question</span>\n" +
|
|
2634
|
-
" <textarea name=\"body\" maxlength=\"4000\" rows=\"6\"
|
|
2685
|
+
" <textarea name=\"body\" maxlength=\"4000\" rows=\"6\" requiredRAW_BODY_ARIA_PLACEHOLDER></textarea>\n" +
|
|
2686
|
+
" RAW_BODY_ERROR_PLACEHOLDER\n" +
|
|
2635
2687
|
" </label>\n" +
|
|
2636
2688
|
" <button type=\"submit\" class=\"btn-primary\">Submit question</button>\n" +
|
|
2637
2689
|
" </form>\n" +
|
|
@@ -2645,11 +2697,16 @@ function renderQuestionForm(opts) {
|
|
|
2645
2697
|
var notice = opts.notice
|
|
2646
2698
|
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
2647
2699
|
: "";
|
|
2700
|
+
// Single field (`body`); on a re-render, mark it with aria-invalid + an
|
|
2701
|
+
// adjacent error span. id is the static "qa-err-body".
|
|
2702
|
+
var inv = opts.invalid_field || null;
|
|
2648
2703
|
var body = _render(QA_FORM_PAGE, {
|
|
2649
2704
|
title: opts.product.title,
|
|
2650
2705
|
slug: opts.product.slug,
|
|
2651
2706
|
})
|
|
2652
2707
|
.replace("RAW_NOTICE_PLACEHOLDER", notice);
|
|
2708
|
+
body = _spliceRaw(body, "RAW_BODY_ARIA_PLACEHOLDER", _fieldAriaAttr("qa-err-", "body", inv));
|
|
2709
|
+
body = _spliceRaw(body, "RAW_BODY_ERROR_PLACEHOLDER", _fieldErrorSpan("qa-err-", "body", inv));
|
|
2653
2710
|
return _wrap({
|
|
2654
2711
|
title: "Ask about " + opts.product.title,
|
|
2655
2712
|
shop_name: opts.shop_name || "blamejs.shop",
|
|
@@ -3545,32 +3602,59 @@ function _addrField(name, labelText, value, opts) {
|
|
|
3545
3602
|
// The `*` is a color-only visual cue; pair it with a visually-hidden
|
|
3546
3603
|
// "(required)" so a screen reader announces the field's requiredness.
|
|
3547
3604
|
var req = opts.required ? " <span class=\"form-field__req\" aria-hidden=\"true\">*</span><span class=\"sr-only\">(required)</span>" : "";
|
|
3605
|
+
// On a validation re-render, the rejected field carries aria-invalid +
|
|
3606
|
+
// aria-describedby pointing at an adjacent error span (WCAG 3.3.1/3.3.3).
|
|
3607
|
+
// The id is a static prefix + field name (attacker-uncontrolled) but is
|
|
3608
|
+
// still escaped for defense-in-depth; the reason is operator-validator
|
|
3609
|
+
// prose, escaped via esc().
|
|
3610
|
+
var errSpan = "";
|
|
3611
|
+
if (opts.invalid) {
|
|
3612
|
+
attrs += " aria-invalid=\"true\" aria-describedby=\"" + esc(opts.error_id) + "\"";
|
|
3613
|
+
errSpan = "<span class=\"form-field__error\" id=\"" + esc(opts.error_id) +
|
|
3614
|
+
"\" role=\"alert\">" + esc(opts.error_msg == null ? "" : String(opts.error_msg)) + "</span>";
|
|
3615
|
+
}
|
|
3548
3616
|
return "<label class=\"form-field\">" +
|
|
3549
3617
|
"<span class=\"form-field__label\">" + esc(labelText) + req + "</span>" +
|
|
3550
3618
|
"<input type=\"text\" name=\"" + esc(name) + "\" value=\"" + esc(value == null ? "" : String(value)) + "\"" + attrs + ">" +
|
|
3619
|
+
errSpan +
|
|
3551
3620
|
"</label>";
|
|
3552
3621
|
}
|
|
3553
3622
|
|
|
3554
3623
|
// Shared add/edit address form. `addr` pre-fills for edit (null = add).
|
|
3555
|
-
|
|
3624
|
+
// `invalidField` (optional) is a { field, message } picked off the
|
|
3625
|
+
// backend-thrown validator error so the one rejected input renders with
|
|
3626
|
+
// aria-invalid + a per-field error span (WCAG 3.3.1/3.3.3). When null, every
|
|
3627
|
+
// field renders byte-identically to the no-error path.
|
|
3628
|
+
function _addressForm(action, addr, submitLabel, invalidField) {
|
|
3556
3629
|
var esc = b.template.escapeHtml;
|
|
3557
3630
|
addr = addr || {};
|
|
3558
3631
|
function _checked(v) { return Number(v) === 1 ? " checked" : ""; }
|
|
3632
|
+
// Merge the per-field invalid marker into a field's opts when it is the one
|
|
3633
|
+
// the validator rejected. The id is a static "addr-err-<name>" so it is
|
|
3634
|
+
// attacker-uncontrolled.
|
|
3635
|
+
function _mark(name, opts) {
|
|
3636
|
+
if (invalidField && invalidField.field === name) {
|
|
3637
|
+
opts.invalid = true;
|
|
3638
|
+
opts.error_id = "addr-err-" + name;
|
|
3639
|
+
opts.error_msg = invalidField.message;
|
|
3640
|
+
}
|
|
3641
|
+
return opts;
|
|
3642
|
+
}
|
|
3559
3643
|
return "<form class=\"address-form form-stack\" method=\"post\" action=\"" + esc(action) + "\">" +
|
|
3560
|
-
_addrField("recipient_name", "Recipient name", addr.recipient_name, { required: true, maxlength: 120, autocomplete: "name" }) +
|
|
3561
|
-
_addrField("label", "Label (e.g. Home, Work)", addr.label, { maxlength: 60 }) +
|
|
3562
|
-
_addrField("company", "Company", addr.company, { maxlength: 120, autocomplete: "organization" }) +
|
|
3563
|
-
_addrField("street_line1", "Street address", addr.street_line1, { required: true, maxlength: 200, autocomplete: "address-line1" }) +
|
|
3564
|
-
_addrField("street_line2", "Apt / suite / unit", addr.street_line2, { maxlength: 200, autocomplete: "address-line2" }) +
|
|
3644
|
+
_addrField("recipient_name", "Recipient name", addr.recipient_name, _mark("recipient_name", { required: true, maxlength: 120, autocomplete: "name" })) +
|
|
3645
|
+
_addrField("label", "Label (e.g. Home, Work)", addr.label, _mark("label", { maxlength: 60 })) +
|
|
3646
|
+
_addrField("company", "Company", addr.company, _mark("company", { maxlength: 120, autocomplete: "organization" })) +
|
|
3647
|
+
_addrField("street_line1", "Street address", addr.street_line1, _mark("street_line1", { required: true, maxlength: 200, autocomplete: "address-line1" })) +
|
|
3648
|
+
_addrField("street_line2", "Apt / suite / unit", addr.street_line2, _mark("street_line2", { maxlength: 200, autocomplete: "address-line2" })) +
|
|
3565
3649
|
"<div class=\"form-row form-row--inline\">" +
|
|
3566
|
-
_addrField("city", "City", addr.city, { required: true, maxlength: 120, autocomplete: "address-level2" }) +
|
|
3567
|
-
_addrField("region", "State / region", addr.region, { maxlength: 120, autocomplete: "address-level1" }) +
|
|
3650
|
+
_addrField("city", "City", addr.city, _mark("city", { required: true, maxlength: 120, autocomplete: "address-level2" })) +
|
|
3651
|
+
_addrField("region", "State / region", addr.region, _mark("region", { maxlength: 120, autocomplete: "address-level1" })) +
|
|
3568
3652
|
"</div>" +
|
|
3569
3653
|
"<div class=\"form-row form-row--inline\">" +
|
|
3570
|
-
_addrField("postal_code", "Postal code", addr.postal_code, { required: true, maxlength: 32, autocomplete: "postal-code" }) +
|
|
3571
|
-
_addrField("country", "Country (ISO 3166-1)", addr.country || "US", { required: true, maxlength: 2, pattern: "[A-Za-z]{2}", autocomplete: "country" }) +
|
|
3654
|
+
_addrField("postal_code", "Postal code", addr.postal_code, _mark("postal_code", { required: true, maxlength: 32, autocomplete: "postal-code" })) +
|
|
3655
|
+
_addrField("country", "Country (ISO 3166-1)", addr.country || "US", _mark("country", { required: true, maxlength: 2, pattern: "[A-Za-z]{2}", autocomplete: "country" })) +
|
|
3572
3656
|
"</div>" +
|
|
3573
|
-
_addrField("phone", "Phone", addr.phone, { maxlength: 40, autocomplete: "tel" }) +
|
|
3657
|
+
_addrField("phone", "Phone", addr.phone, _mark("phone", { maxlength: 40, autocomplete: "tel" })) +
|
|
3574
3658
|
"<label class=\"address-form__check\"><input type=\"checkbox\" name=\"is_default_shipping\" value=\"1\"" + _checked(addr.is_default_shipping) + "> Default shipping address</label>" +
|
|
3575
3659
|
"<label class=\"address-form__check\"><input type=\"checkbox\" name=\"is_default_billing\" value=\"1\"" + _checked(addr.is_default_billing) + "> Default billing address</label>" +
|
|
3576
3660
|
"<button type=\"submit\" class=\"btn-primary\">" + esc(submitLabel) + "</button>" +
|
|
@@ -3642,7 +3726,7 @@ function renderAddresses(opts) {
|
|
|
3642
3726
|
listHtml +
|
|
3643
3727
|
"<h2 class=\"account-addresses__form-title\">" + esc(formHeading) + "</h2>" +
|
|
3644
3728
|
notice +
|
|
3645
|
-
_addressForm(formAction, editing, editing ? "Save changes" : "Add address") +
|
|
3729
|
+
_addressForm(formAction, editing, editing ? "Save changes" : "Add address", opts.invalid_field || null) +
|
|
3646
3730
|
"</section>";
|
|
3647
3731
|
return _wrap({
|
|
3648
3732
|
title: "Addresses",
|
|
@@ -4101,6 +4185,57 @@ function renderReturns(opts) {
|
|
|
4101
4185
|
});
|
|
4102
4186
|
}
|
|
4103
4187
|
|
|
4188
|
+
// The signed-in customer's pickup (BOPIS) list. Each row links the parent
|
|
4189
|
+
// order and shows the FSM status + the scheduled window. Reuses the
|
|
4190
|
+
// return-card layout classes — no new CSS. location_code is operator free
|
|
4191
|
+
// text; the status is a fixed FSM enum — both escaped at the sink.
|
|
4192
|
+
function renderAccountPickups(opts) {
|
|
4193
|
+
opts = opts || {};
|
|
4194
|
+
var esc = b.template.escapeHtml;
|
|
4195
|
+
var list = opts.pickups || [];
|
|
4196
|
+
var rowsHtml = "";
|
|
4197
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
4198
|
+
var p = list[i];
|
|
4199
|
+
var when = p.scheduled_window_start
|
|
4200
|
+
? new Date(Number(p.scheduled_window_start)).toISOString().slice(0, 16).replace("T", " ")
|
|
4201
|
+
: "";
|
|
4202
|
+
rowsHtml +=
|
|
4203
|
+
"<li class=\"return-card\">" +
|
|
4204
|
+
"<div class=\"return-card__head\">" +
|
|
4205
|
+
"<a class=\"return-card__rma\" href=\"/orders/" + esc(String(p.order_id)) + "\"><code>" + esc(String(p.order_id).slice(0, 8)) + "</code></a>" +
|
|
4206
|
+
"<span class=\"return-status\">" + esc(_pickupStatusLabel(p.status)) + "</span>" +
|
|
4207
|
+
"</div>" +
|
|
4208
|
+
"<p class=\"return-card__meta\">" +
|
|
4209
|
+
"Location <code>" + esc(String(p.location_code)) + "</code>" +
|
|
4210
|
+
(when ? " · <time>" + esc(when) + " UTC</time>" : "") +
|
|
4211
|
+
"</p>" +
|
|
4212
|
+
"</li>";
|
|
4213
|
+
}
|
|
4214
|
+
var inner = rowsHtml
|
|
4215
|
+
? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
|
|
4216
|
+
: "<div class=\"account-empty\">" +
|
|
4217
|
+
"<p class=\"account-empty__lede\">No pickups yet. Choose “pick up in store” at checkout to schedule one.</p>" +
|
|
4218
|
+
"<a class=\"btn-secondary\" href=\"/account/orders\">View your orders →</a>" +
|
|
4219
|
+
"</div>";
|
|
4220
|
+
var body =
|
|
4221
|
+
"<section class=\"account-returns\">" +
|
|
4222
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
4223
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
4224
|
+
"<li aria-current=\"page\">Pickups</li>" +
|
|
4225
|
+
"</ol></nav>" +
|
|
4226
|
+
"<h1 class=\"account-returns__title\">Pickups</h1>" +
|
|
4227
|
+
inner +
|
|
4228
|
+
"</section>";
|
|
4229
|
+
return _wrap({
|
|
4230
|
+
title: "Pickups",
|
|
4231
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
4232
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
4233
|
+
theme_css: opts.theme_css,
|
|
4234
|
+
robots: "noindex",
|
|
4235
|
+
body: body,
|
|
4236
|
+
});
|
|
4237
|
+
}
|
|
4238
|
+
|
|
4104
4239
|
// Customer-facing phrasing for a return label's carrier-scan status. The
|
|
4105
4240
|
// return-labels module's FSM enum (issued / shipped / in_transit /
|
|
4106
4241
|
// delivered / exception) is operator/carrier vocabulary; these read for
|
|
@@ -4564,6 +4699,13 @@ function renderSupportNew(opts) {
|
|
|
4564
4699
|
var notice = opts.notice
|
|
4565
4700
|
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
4566
4701
|
: "";
|
|
4702
|
+
// On a validation re-render, mark the one rejected field with aria-invalid +
|
|
4703
|
+
// an adjacent error span (WCAG 3.3.1/3.3.3) via the shared field helpers.
|
|
4704
|
+
// `opts.invalid_field` is the { field, message } the route picked off the
|
|
4705
|
+
// backend-thrown error; ids are static "support-err-<name>".
|
|
4706
|
+
var inv = opts.invalid_field || null;
|
|
4707
|
+
function _supAria(name) { return _fieldAriaAttr("support-err-", name, inv); }
|
|
4708
|
+
function _supErr(name) { return _fieldErrorSpan("support-err-", name, inv); }
|
|
4567
4709
|
var body =
|
|
4568
4710
|
"<section class=\"return-form-page\">" +
|
|
4569
4711
|
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
@@ -4575,14 +4717,14 @@ function renderSupportNew(opts) {
|
|
|
4575
4717
|
notice +
|
|
4576
4718
|
"<form class=\"return-form form-stack\" method=\"post\" action=\"/account/support\">" +
|
|
4577
4719
|
"<label class=\"form-field\"><span class=\"form-field__label\">Email for our reply</span>" +
|
|
4578
|
-
"<input type=\"email\" name=\"customer_email\" value=\"" + esc(v.customer_email == null ? "" : String(v.customer_email)) + "\" required autocomplete=\"email\" maxlength=\"254\"
|
|
4720
|
+
"<input type=\"email\" name=\"customer_email\" value=\"" + esc(v.customer_email == null ? "" : String(v.customer_email)) + "\" required autocomplete=\"email\" maxlength=\"254\"" + _supAria("customer_email") + ">" + _supErr("customer_email") + "</label>" +
|
|
4579
4721
|
"<label class=\"form-field\"><span class=\"form-field__label\">Category</span>" +
|
|
4580
|
-
"<select name=\"category\" required>" + categoryOpts + "</select
|
|
4722
|
+
"<select name=\"category\" required" + _supAria("category") + ">" + categoryOpts + "</select>" + _supErr("category") + "</label>" +
|
|
4581
4723
|
orderField +
|
|
4582
4724
|
"<label class=\"form-field\"><span class=\"form-field__label\">Subject</span>" +
|
|
4583
|
-
"<input type=\"text\" name=\"subject\" value=\"" + esc(v.subject == null ? "" : String(v.subject)) + "\" required maxlength=\"200\"
|
|
4725
|
+
"<input type=\"text\" name=\"subject\" value=\"" + esc(v.subject == null ? "" : String(v.subject)) + "\" required maxlength=\"200\"" + _supAria("subject") + ">" + _supErr("subject") + "</label>" +
|
|
4584
4726
|
"<label class=\"form-field\"><span class=\"form-field__label\">How can we help?</span>" +
|
|
4585
|
-
"<textarea name=\"body\" required maxlength=\"8000\" rows=\"6\">" + esc(v.body == null ? "" : String(v.body)) + "</textarea
|
|
4727
|
+
"<textarea name=\"body\" required maxlength=\"8000\" rows=\"6\"" + _supAria("body") + ">" + esc(v.body == null ? "" : String(v.body)) + "</textarea>" + _supErr("body") + "</label>" +
|
|
4586
4728
|
"<button type=\"submit\" class=\"btn-primary\">Send</button>" +
|
|
4587
4729
|
"</form>" +
|
|
4588
4730
|
"</section>";
|
|
@@ -6294,6 +6436,53 @@ function _loyaltyCheckoutField(bal, perUsd) {
|
|
|
6294
6436
|
"</label></div>";
|
|
6295
6437
|
}
|
|
6296
6438
|
|
|
6439
|
+
// The gift message / recipient / hide-prices fields appended into the
|
|
6440
|
+
// checkout form when the gift-options primitive is wired. These persist
|
|
6441
|
+
// post-commit via setForOrder (they need the order id). The wrap itself is
|
|
6442
|
+
// already a cart line (selected on /cart). All values are echoed nowhere
|
|
6443
|
+
// pre-submit (empty inputs), so no escaping is needed here, but the labels
|
|
6444
|
+
// are static framework copy. Returns "" when gift options aren't wired.
|
|
6445
|
+
function _checkoutGiftFields(opts) {
|
|
6446
|
+
if (!opts.gift_enabled) return "";
|
|
6447
|
+
var hasWrap = !!opts.gift_wrap_sku_in_cart;
|
|
6448
|
+
return "<fieldset class=\"checkout-gift\">" +
|
|
6449
|
+
"<legend>Gift options</legend>" +
|
|
6450
|
+
(hasWrap
|
|
6451
|
+
? "<p class=\"checkout-gift__note\">A gift wrap is in your cart. Add a message and recipient below.</p>"
|
|
6452
|
+
: "<p class=\"checkout-gift__note\">Sending this as a gift? Add a message and recipient. (Choose a gift wrap on the cart page.)</p>") +
|
|
6453
|
+
"<label class=\"form-field\"><span>Recipient name <small>(optional)</small></span>" +
|
|
6454
|
+
"<input type=\"text\" name=\"gift_recipient_name\" maxlength=\"120\" autocomplete=\"off\"></label>" +
|
|
6455
|
+
"<label class=\"form-field\"><span>Gift message <small>(optional)</small></span>" +
|
|
6456
|
+
"<textarea name=\"gift_message\" rows=\"3\" maxlength=\"500\" placeholder=\"Add a note for the recipient\"></textarea></label>" +
|
|
6457
|
+
"<label class=\"form-field form-field--check\"><input type=\"checkbox\" name=\"gift_hide_prices\" value=\"1\">" +
|
|
6458
|
+
"<span>Hide prices on the packing slip (gift receipt)</span></label>" +
|
|
6459
|
+
"</fieldset>";
|
|
6460
|
+
}
|
|
6461
|
+
|
|
6462
|
+
// The "pick up in store" location picker appended into the checkout form
|
|
6463
|
+
// when the click-and-collect primitive is wired AND at least one active
|
|
6464
|
+
// pickup location exists. The default is ship-to-me (empty value); choosing
|
|
6465
|
+
// a location schedules a pickup post-commit. location name/code are operator
|
|
6466
|
+
// free text — escaped at the sink. Returns "" when no locations.
|
|
6467
|
+
function _checkoutPickupPicker(opts) {
|
|
6468
|
+
var locs = opts.pickup_locations || [];
|
|
6469
|
+
if (!locs.length) return "";
|
|
6470
|
+
var esc = b.template.escapeHtml;
|
|
6471
|
+
var options = "<option value=\"\">Ship to my address</option>" +
|
|
6472
|
+
locs.map(function (l) {
|
|
6473
|
+
var addr = l.address || {};
|
|
6474
|
+
var addrLine = [addr.city, addr.country].filter(Boolean).map(String).join(", ");
|
|
6475
|
+
return "<option value=\"" + esc(String(l.code)) + "\">" +
|
|
6476
|
+
esc(String(l.name)) + (addrLine ? " — " + esc(addrLine) : "") + "</option>";
|
|
6477
|
+
}).join("");
|
|
6478
|
+
return "<fieldset class=\"checkout-pickup\">" +
|
|
6479
|
+
"<legend>Delivery method</legend>" +
|
|
6480
|
+
"<label class=\"form-field\"><span>How would you like to get your order?</span>" +
|
|
6481
|
+
"<select name=\"pickup_location_code\">" + options + "</select></label>" +
|
|
6482
|
+
"<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>" +
|
|
6483
|
+
"</fieldset>";
|
|
6484
|
+
}
|
|
6485
|
+
|
|
6297
6486
|
function renderCheckoutForm(opts) {
|
|
6298
6487
|
if (!opts) throw new TypeError("storefront.renderCheckoutForm: opts required");
|
|
6299
6488
|
var lines = opts.lines || [];
|
|
@@ -6365,6 +6554,17 @@ function renderCheckoutForm(opts) {
|
|
|
6365
6554
|
if (checkoutCaptcha) {
|
|
6366
6555
|
body = body.replace("</form>", checkoutCaptcha + "</form>");
|
|
6367
6556
|
}
|
|
6557
|
+
// Pick-up-in-store location picker + gift personalization fields, appended
|
|
6558
|
+
// into the form (container-only). Spliced via _spliceRaw so an operator
|
|
6559
|
+
// free-text location name carrying a `$` can't trip dollar substitution.
|
|
6560
|
+
var checkoutPickup = _checkoutPickupPicker(opts);
|
|
6561
|
+
if (checkoutPickup) {
|
|
6562
|
+
body = _spliceRaw(body, "</form>", checkoutPickup + "</form>");
|
|
6563
|
+
}
|
|
6564
|
+
var checkoutGift = _checkoutGiftFields(opts);
|
|
6565
|
+
if (checkoutGift) {
|
|
6566
|
+
body = body.replace("</form>", checkoutGift + "</form>");
|
|
6567
|
+
}
|
|
6368
6568
|
// Signed-in customer with a spendable points balance — surface a
|
|
6369
6569
|
// redeem-at-checkout field. The block is appended as raw HTML (the
|
|
6370
6570
|
// balance + value are numbers we control, the conversion ratio is the
|
|
@@ -6608,6 +6808,8 @@ var ORDER_PAGE =
|
|
|
6608
6808
|
" <tbody>{{line_rows}}</tbody>\n" +
|
|
6609
6809
|
" </table>\n" +
|
|
6610
6810
|
" </div>\n" +
|
|
6811
|
+
" RAW_ORDER_PICKUP" +
|
|
6812
|
+
" RAW_ORDER_GIFT" +
|
|
6611
6813
|
" RAW_ORDER_ACTIONS" +
|
|
6612
6814
|
" RAW_ORDER_RATING" +
|
|
6613
6815
|
" </div>\n" +
|
|
@@ -6945,6 +7147,60 @@ function _ratingNoticeFor(code) {
|
|
|
6945
7147
|
return null;
|
|
6946
7148
|
}
|
|
6947
7149
|
|
|
7150
|
+
// Human label for a pickup FSM status, for the customer order page.
|
|
7151
|
+
function _pickupStatusLabel(status) {
|
|
7152
|
+
if (status === "scheduled") return "Scheduled for pickup";
|
|
7153
|
+
if (status === "ready") return "Ready for pickup";
|
|
7154
|
+
if (status === "picked_up") return "Picked up";
|
|
7155
|
+
if (status === "no_show") return "Missed pickup";
|
|
7156
|
+
if (status === "cancelled") return "Pickup cancelled";
|
|
7157
|
+
return status;
|
|
7158
|
+
}
|
|
7159
|
+
|
|
7160
|
+
// Customer-facing pickup (BOPIS) status panel on /orders/:id. The window
|
|
7161
|
+
// times come straight from the schedule row (operator-set epoch ms); the
|
|
7162
|
+
// location_code is operator-defined free text — escaped at the sink.
|
|
7163
|
+
// Returns "" when there's no schedule (the common no-pickup order).
|
|
7164
|
+
function _orderPickupBlock(pickup) {
|
|
7165
|
+
if (!pickup) return "";
|
|
7166
|
+
var esc = b.template.escapeHtml;
|
|
7167
|
+
var when = "";
|
|
7168
|
+
if (pickup.scheduled_window_start) {
|
|
7169
|
+
var start = new Date(Number(pickup.scheduled_window_start));
|
|
7170
|
+
var end = pickup.scheduled_window_end ? new Date(Number(pickup.scheduled_window_end)) : null;
|
|
7171
|
+
when = "<p class=\"order-pickup__window\">Window: " + esc(start.toISOString().slice(0, 16).replace("T", " ")) +
|
|
7172
|
+
(end ? " – " + esc(end.toISOString().slice(11, 16)) : "") + " UTC</p>";
|
|
7173
|
+
}
|
|
7174
|
+
return "<section class=\"order-pickup\">" +
|
|
7175
|
+
"<h2 class=\"pdp__variants-title\">Pickup</h2>" +
|
|
7176
|
+
"<p class=\"order-pickup__status\"><span class=\"pdp__badge\">" + esc(_pickupStatusLabel(pickup.status)) + "</span></p>" +
|
|
7177
|
+
"<p class=\"order-pickup__loc\">Location: <code class=\"inline-code\">" + esc(String(pickup.location_code)) + "</code></p>" +
|
|
7178
|
+
when +
|
|
7179
|
+
"</section>";
|
|
7180
|
+
}
|
|
7181
|
+
|
|
7182
|
+
// Customer-facing gift-options display on /orders/:id. The wrap_sku /
|
|
7183
|
+
// gift_message / recipient_name are customer (and operator) free text —
|
|
7184
|
+
// escaped at the sink. hide_prices is shown as a plain note. Returns ""
|
|
7185
|
+
// when no gift options are set.
|
|
7186
|
+
function _orderGiftBlock(gift) {
|
|
7187
|
+
if (!gift || (!gift.wrap_sku && !gift.gift_message && !gift.recipient_name && !gift.hide_prices)) return "";
|
|
7188
|
+
var esc = b.template.escapeHtml;
|
|
7189
|
+
var parts = [];
|
|
7190
|
+
if (gift.recipient_name) parts.push("<p class=\"order-gift__recipient\">To: " + esc(String(gift.recipient_name)) + "</p>");
|
|
7191
|
+
if (gift.wrap_sku) parts.push("<p class=\"order-gift__wrap\">Gift wrap: <code class=\"inline-code\">" + esc(String(gift.wrap_sku)) + "</code></p>");
|
|
7192
|
+
if (gift.gift_message) {
|
|
7193
|
+
// The message may be multi-line — split on LF and escape each line.
|
|
7194
|
+
var lines = String(gift.gift_message).replace(/\r\n/g, "\n").split("\n")
|
|
7195
|
+
.map(function (ln) { return esc(ln); }).join("<br>");
|
|
7196
|
+
parts.push("<blockquote class=\"order-gift__message\">" + lines + "</blockquote>");
|
|
7197
|
+
}
|
|
7198
|
+
if (gift.hide_prices) parts.push("<p class=\"order-gift__hide\">Prices hidden on the packing slip (gift receipt).</p>");
|
|
7199
|
+
return "<section class=\"order-gift\">" +
|
|
7200
|
+
"<h2 class=\"pdp__variants-title\">Gift options</h2>" + parts.join("") +
|
|
7201
|
+
"</section>";
|
|
7202
|
+
}
|
|
7203
|
+
|
|
6948
7204
|
function renderOrder(opts) {
|
|
6949
7205
|
if (!opts || !opts.order) throw new TypeError("storefront.renderOrder: opts.order required");
|
|
6950
7206
|
var o = opts.order;
|
|
@@ -7006,6 +7262,8 @@ function renderOrder(opts) {
|
|
|
7006
7262
|
ship_to: o.ship_to || null,
|
|
7007
7263
|
timeline_html: timelineHtml,
|
|
7008
7264
|
tracking_html: trackingHtml,
|
|
7265
|
+
pickup_html: _orderPickupBlock(opts.pickup),
|
|
7266
|
+
gift_html: _orderGiftBlock(opts.gift_options),
|
|
7009
7267
|
actions_html: actionsHtml,
|
|
7010
7268
|
rating_html: ratingHtml,
|
|
7011
7269
|
can_return: _orderEligibleForReturn(o.status),
|
|
@@ -7057,8 +7315,13 @@ function renderOrder(opts) {
|
|
|
7057
7315
|
.replace("RAW_REORDER_NOTICE", reorderNotice + cancelNotice)
|
|
7058
7316
|
.replace("RAW_ORDER_TIMELINE", timelineHtml)
|
|
7059
7317
|
.replace("RAW_ORDER_TRACKING", trackingHtml)
|
|
7318
|
+
.replace("RAW_ORDER_PICKUP", _orderPickupBlock(opts.pickup))
|
|
7060
7319
|
.replace("RAW_ORDER_ACTIONS", actionsHtml)
|
|
7061
7320
|
.replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
|
|
7321
|
+
// The gift block carries customer free text (escaped, but a `$` in it would
|
|
7322
|
+
// still trip String.replace's dollar substitution) — splice it via the
|
|
7323
|
+
// replacer-function helper, never a replacement-string .replace.
|
|
7324
|
+
body = _spliceRaw(body, "RAW_ORDER_GIFT", _orderGiftBlock(opts.gift_options));
|
|
7062
7325
|
// The rating panel carries customer/operator free text (already escaped
|
|
7063
7326
|
// into comment_html / response_html, but a `$` in that text would still
|
|
7064
7327
|
// trip String.replace's dollar substitution) — splice it via the
|
|
@@ -7241,6 +7504,41 @@ function _buildCartTotalsRows(t, fmt, subtotalStr, totalStr) {
|
|
|
7241
7504
|
return rows;
|
|
7242
7505
|
}
|
|
7243
7506
|
|
|
7507
|
+
// The cart-page gift disclosure (CONTAINER-ONLY — the edge cart renders
|
|
7508
|
+
// only the cookie-less empty shell, never a cart with lines, so this block
|
|
7509
|
+
// is never reached at the edge; see [[storefront-dual-render]]). It offers a
|
|
7510
|
+
// wrap <select> (POST /cart/gift adds the wrap as a real cart LINE so the
|
|
7511
|
+
// fee flows through the quote + is charged) plus a remove control when a
|
|
7512
|
+
// wrap is already in the cart. The message / recipient / hide-prices fields
|
|
7513
|
+
// are collected at the checkout step (they need the order id to persist), so
|
|
7514
|
+
// the cart block is the wrap selector only. Wrap titles are operator free
|
|
7515
|
+
// text — escaped at the sink. Returns "" when no active wraps are defined.
|
|
7516
|
+
function _cartGiftBlock(opts) {
|
|
7517
|
+
var wraps = opts.gift_wraps || [];
|
|
7518
|
+
if (!wraps.length) return "";
|
|
7519
|
+
var esc = b.template.escapeHtml;
|
|
7520
|
+
var current = opts.gift_wrap_in_cart || "";
|
|
7521
|
+
var options = "<option value=\"\">No gift wrap</option>" +
|
|
7522
|
+
wraps.map(function (w) {
|
|
7523
|
+
var feeStr = pricing.format(w.fee_minor, (opts.totals && opts.totals.currency) || "USD");
|
|
7524
|
+
return "<option value=\"" + esc(String(w.wrap_sku)) + "\"" +
|
|
7525
|
+
(w.wrap_sku === current ? " selected" : "") + ">" +
|
|
7526
|
+
esc(String(w.title)) + " (" + esc(feeStr) + ")</option>";
|
|
7527
|
+
}).join("");
|
|
7528
|
+
return "<section class=\"cart-gift\">" +
|
|
7529
|
+
"<details class=\"cart-gift__details\"" + (current ? " open" : "") + ">" +
|
|
7530
|
+
"<summary class=\"cart-gift__summary\">Add a gift wrap</summary>" +
|
|
7531
|
+
"<form method=\"post\" action=\"/cart/gift\" class=\"cart-gift__form\">" +
|
|
7532
|
+
"<label class=\"form-field\"><span>Gift wrap</span>" +
|
|
7533
|
+
"<select name=\"wrap_sku\">" + options + "</select>" +
|
|
7534
|
+
"</label>" +
|
|
7535
|
+
"<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>" +
|
|
7536
|
+
"<button type=\"submit\" class=\"btn-secondary\">Update gift wrap</button>" +
|
|
7537
|
+
"</form>" +
|
|
7538
|
+
"</details>" +
|
|
7539
|
+
"</section>";
|
|
7540
|
+
}
|
|
7541
|
+
|
|
7244
7542
|
function renderCart(opts) {
|
|
7245
7543
|
if (!opts) throw new TypeError("storefront.renderCart: opts required");
|
|
7246
7544
|
var lines = opts.lines || [];
|
|
@@ -7373,6 +7671,13 @@ function renderCart(opts) {
|
|
|
7373
7671
|
.replace("RAW_TOTALS_ROWS", totalsRows)
|
|
7374
7672
|
.replace("RAW_CHECKOUT_CTA", checkoutCta)
|
|
7375
7673
|
.replace("RAW_CART_NOTICE", notice);
|
|
7674
|
+
// CONTAINER-ONLY gift wrap disclosure, appended after the cart grid (a
|
|
7675
|
+
// separate <section>, never spliced into the shared CART_PAGE markup the
|
|
7676
|
+
// edge twin mirrors). Only reached for a cart with lines, which the edge
|
|
7677
|
+
// never renders. Spliced via the body concat (the block carries operator
|
|
7678
|
+
// free text already escaped; appending — not String.replace — so a `$`
|
|
7679
|
+
// in a wrap title can't trip dollar substitution).
|
|
7680
|
+
body = body + _cartGiftBlock(opts);
|
|
7376
7681
|
}
|
|
7377
7682
|
return _wrap(Object.assign({
|
|
7378
7683
|
title: "Cart",
|
|
@@ -8394,6 +8699,8 @@ var ACCOUNT_DASH_PAGE =
|
|
|
8394
8699
|
" <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
|
|
8395
8700
|
" <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
|
|
8396
8701
|
" <a class=\"btn-secondary\" href=\"/account/exchanges\">Exchanges</a>\n" +
|
|
8702
|
+
" RAW_PICKUPS_LINK\n" +
|
|
8703
|
+
" RAW_PAYMENT_METHODS_LINK\n" +
|
|
8397
8704
|
" <a class=\"btn-secondary\" href=\"/account/support\">Support</a>\n" +
|
|
8398
8705
|
" <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
|
|
8399
8706
|
" <a class=\"btn-secondary\" href=\"/account/credit\">Store credit</a>\n" +
|
|
@@ -8511,6 +8818,15 @@ function renderAccount(opts) {
|
|
|
8511
8818
|
// links to a 404.
|
|
8512
8819
|
.replace("RAW_PREORDER_LINK", opts.preorders_enabled
|
|
8513
8820
|
? "<a class=\"btn-secondary\" href=\"/account/preorders\">Pre-orders</a>"
|
|
8821
|
+
: "")
|
|
8822
|
+
// The Pickups + Payment-methods links render only when those routes are
|
|
8823
|
+
// mounted (the primitive is wired), so a deploy without them never links
|
|
8824
|
+
// to a 404.
|
|
8825
|
+
.replace("RAW_PICKUPS_LINK", opts.pickups_enabled
|
|
8826
|
+
? "<a class=\"btn-secondary\" href=\"/account/pickups\">Pickups</a>"
|
|
8827
|
+
: "")
|
|
8828
|
+
.replace("RAW_PAYMENT_METHODS_LINK", opts.payment_methods_enabled
|
|
8829
|
+
? "<a class=\"btn-secondary\" href=\"/account/payment-methods\">Payment methods</a>"
|
|
8514
8830
|
: "");
|
|
8515
8831
|
return _wrap({
|
|
8516
8832
|
title: "Account",
|
|
@@ -8671,6 +8987,120 @@ function renderPasskeyRemoveConfirm(opts) {
|
|
|
8671
8987
|
});
|
|
8672
8988
|
}
|
|
8673
8989
|
|
|
8990
|
+
// ---- saved payment methods --------------------------------------------
|
|
8991
|
+
//
|
|
8992
|
+
// The signed-in customer's vaulted cards (Stripe pm_… tokens — the shop
|
|
8993
|
+
// never holds the PAN/CVV). Each card shows brand + last4 + expiry, a
|
|
8994
|
+
// Default badge, a Set-default form, and a Remove form; an "Add a card"
|
|
8995
|
+
// CTA links to the SetupIntent page. Brand/last4 are processor-supplied
|
|
8996
|
+
// short strings but escaped at the sink for consistency.
|
|
8997
|
+
function renderPaymentMethods(opts) {
|
|
8998
|
+
opts = opts || {};
|
|
8999
|
+
var esc = b.template.escapeHtml;
|
|
9000
|
+
var list = opts.payment_methods || [];
|
|
9001
|
+
var notice = opts.notice
|
|
9002
|
+
? "<p class=\"form-notice form-notice--err\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
9003
|
+
: "";
|
|
9004
|
+
var success = opts.success
|
|
9005
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(String(opts.success)) + "</p>"
|
|
9006
|
+
: "";
|
|
9007
|
+
var rowsHtml = "";
|
|
9008
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
9009
|
+
var pm = list[i];
|
|
9010
|
+
var isDefault = Number(pm.is_default) === 1 || pm.is_default === true;
|
|
9011
|
+
var expiry = (pm.exp_month != null && pm.exp_year != null)
|
|
9012
|
+
? esc(String(pm.exp_month).padStart ? String(pm.exp_month).padStart(2, "0") : String(pm.exp_month)) + "/" + esc(String(pm.exp_year))
|
|
9013
|
+
: "";
|
|
9014
|
+
rowsHtml +=
|
|
9015
|
+
"<li class=\"pm-card\">" +
|
|
9016
|
+
"<div class=\"pm-card__head\">" +
|
|
9017
|
+
"<span class=\"pm-card__brand\">" + esc(String(pm.brand)) + " · ···· " + esc(String(pm.last4)) + "</span>" +
|
|
9018
|
+
(isDefault ? "<span class=\"pdp__badge pdp__badge--ok\">Default</span>" : "") +
|
|
9019
|
+
"</div>" +
|
|
9020
|
+
(expiry ? "<p class=\"pm-card__exp\">Expires " + expiry + "</p>" : "") +
|
|
9021
|
+
"<div class=\"pm-card__actions\">" +
|
|
9022
|
+
(isDefault ? "" :
|
|
9023
|
+
"<form method=\"post\" action=\"/account/payment-methods/" + esc(String(pm.id)) + "/default\">" +
|
|
9024
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Set as default</button></form>") +
|
|
9025
|
+
"<form method=\"post\" action=\"/account/payment-methods/" + esc(String(pm.id)) + "/archive\">" +
|
|
9026
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button></form>" +
|
|
9027
|
+
"</div>" +
|
|
9028
|
+
"</li>";
|
|
9029
|
+
}
|
|
9030
|
+
var inner = rowsHtml
|
|
9031
|
+
? "<ul class=\"pm-list\">" + rowsHtml + "</ul>"
|
|
9032
|
+
: "<div class=\"account-empty\"><p class=\"account-empty__lede\">No saved cards yet. Add one for faster checkout.</p></div>";
|
|
9033
|
+
var body =
|
|
9034
|
+
"<section class=\"account-payment-methods\">" +
|
|
9035
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
9036
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
9037
|
+
"<li aria-current=\"page\">Payment methods</li>" +
|
|
9038
|
+
"</ol></nav>" +
|
|
9039
|
+
"<h1 class=\"account-returns__title\">Payment methods</h1>" +
|
|
9040
|
+
success + notice +
|
|
9041
|
+
inner +
|
|
9042
|
+
"<div class=\"account-payment-methods__cta\">" +
|
|
9043
|
+
"<a class=\"btn-primary\" href=\"/account/payment-methods/add\">Add a card</a>" +
|
|
9044
|
+
"</div>" +
|
|
9045
|
+
"</section>";
|
|
9046
|
+
return _wrap({
|
|
9047
|
+
title: "Payment methods",
|
|
9048
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
9049
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
9050
|
+
theme_css: opts.theme_css,
|
|
9051
|
+
robots: "noindex",
|
|
9052
|
+
body: body,
|
|
9053
|
+
});
|
|
9054
|
+
}
|
|
9055
|
+
|
|
9056
|
+
// The add-card page — a Stripe SetupIntent collected through the Payment
|
|
9057
|
+
// Element. Mirrors the pay page: external Stripe.js (admitted by the
|
|
9058
|
+
// route-scoped CSP set on GET) + the same-origin saved-card.js island. No
|
|
9059
|
+
// inline script. The publishable key rides an HTML-attribute-escaped data-*
|
|
9060
|
+
// attribute on the mount div, read by the island. The SetupIntent
|
|
9061
|
+
// client_secret is fetched by the island from POST
|
|
9062
|
+
// /account/payment-methods/setup-intent (so it's per-session, never cached
|
|
9063
|
+
// in the page HTML).
|
|
9064
|
+
var ADD_PAYMENT_METHOD_PAGE =
|
|
9065
|
+
"<section class=\"pay-page account-add-card\">\n" +
|
|
9066
|
+
" <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>\n" +
|
|
9067
|
+
" <li><a href=\"/account\">Account</a></li>\n" +
|
|
9068
|
+
" <li><a href=\"/account/payment-methods\">Payment methods</a></li>\n" +
|
|
9069
|
+
" <li aria-current=\"page\">Add a card</li>\n" +
|
|
9070
|
+
" </ol></nav>\n" +
|
|
9071
|
+
" <header class=\"section-head\">\n" +
|
|
9072
|
+
" <p class=\"eyebrow\">Secure · Stripe</p>\n" +
|
|
9073
|
+
" <h1 class=\"section-head__title\">Add a card</h1>\n" +
|
|
9074
|
+
" <p class=\"section-head__lede\">Your card is stored securely with Stripe — this store never sees the full number.</p>\n" +
|
|
9075
|
+
" </header>\n" +
|
|
9076
|
+
" <div class=\"pay-card\" id=\"add-card-island\" data-pk=\"{{pk}}\">\n" +
|
|
9077
|
+
" <form id=\"add-card-form\" method=\"post\" action=\"/account/payment-methods\">\n" +
|
|
9078
|
+
" <input type=\"hidden\" name=\"setup_intent_id\" id=\"add-card-si\">\n" +
|
|
9079
|
+
" <div id=\"payment-element\" class=\"pay-card__element\"></div>\n" +
|
|
9080
|
+
" <button id=\"add-card-submit\" type=\"button\" class=\"btn-primary pay-card__submit\">Save card</button>\n" +
|
|
9081
|
+
" <p id=\"add-card-message\" class=\"pay-card__message\" role=\"status\"></p>\n" +
|
|
9082
|
+
" </form>\n" +
|
|
9083
|
+
" </div>\n" +
|
|
9084
|
+
" <script src=\"https://js.stripe.com/v3/\"></script>\n" +
|
|
9085
|
+
" RAW_ADD_CARD_SCRIPT\n" +
|
|
9086
|
+
"</section>\n";
|
|
9087
|
+
|
|
9088
|
+
function renderAddPaymentMethod(opts) {
|
|
9089
|
+
opts = opts || {};
|
|
9090
|
+
if (!opts.publishable_key) throw new TypeError("storefront.renderAddPaymentMethod: opts.publishable_key required");
|
|
9091
|
+
var body = _render(ADD_PAYMENT_METHOD_PAGE, {
|
|
9092
|
+
pk: opts.publishable_key,
|
|
9093
|
+
}).replace("RAW_ADD_CARD_SCRIPT", _islandScript("saved-card.js"));
|
|
9094
|
+
return _wrap({
|
|
9095
|
+
title: "Add a card",
|
|
9096
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
9097
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
9098
|
+
theme_css: opts.theme_css,
|
|
9099
|
+
robots: "noindex",
|
|
9100
|
+
body: body,
|
|
9101
|
+
});
|
|
9102
|
+
}
|
|
9103
|
+
|
|
8674
9104
|
// ---- profile edit ------------------------------------------------------
|
|
8675
9105
|
//
|
|
8676
9106
|
// Display-name edit for the signed-in customer. Email is stored hash-only
|
|
@@ -11345,6 +11775,26 @@ function mount(router, deps) {
|
|
|
11345
11775
|
hero_media: media.length ? media[0] : null,
|
|
11346
11776
|
};
|
|
11347
11777
|
}
|
|
11778
|
+
// Gift options — the active wrap catalog for the cart-page gift UI, plus
|
|
11779
|
+
// which wrap (if any) is already in the cart as a line so the selector
|
|
11780
|
+
// can pre-select it. Drop-silent: an unmigrated gift_wraps table → no UI.
|
|
11781
|
+
var giftWraps = [];
|
|
11782
|
+
var giftWrapInCart = null;
|
|
11783
|
+
if (deps.giftOptions) {
|
|
11784
|
+
try {
|
|
11785
|
+
giftWraps = await deps.giftOptions.listWraps({ active_only: true });
|
|
11786
|
+
if (giftWraps.length) {
|
|
11787
|
+
// A wrap is "in the cart" when one of its variant SKUs matches a
|
|
11788
|
+
// cart line's sku — the wrap rides as a real line (see POST
|
|
11789
|
+
// /cart/gift), so removing it removes the line.
|
|
11790
|
+
var wrapSkuSet = {};
|
|
11791
|
+
for (var gi = 0; gi < giftWraps.length; gi += 1) wrapSkuSet[giftWraps[gi].wrap_sku] = true;
|
|
11792
|
+
for (var ci = 0; ci < lines.length; ci += 1) {
|
|
11793
|
+
if (wrapSkuSet[lines[ci].sku]) { giftWrapInCart = lines[ci].sku; break; }
|
|
11794
|
+
}
|
|
11795
|
+
}
|
|
11796
|
+
} catch (_e) { giftWraps = []; giftWrapInCart = null; }
|
|
11797
|
+
}
|
|
11348
11798
|
_send(res, 200, renderCart(Object.assign({
|
|
11349
11799
|
lines: lines,
|
|
11350
11800
|
totals: totals,
|
|
@@ -11353,6 +11803,8 @@ function mount(router, deps) {
|
|
|
11353
11803
|
product_lookup: productLookup,
|
|
11354
11804
|
can_save: !!(deps.saveForLater && deps.customers),
|
|
11355
11805
|
checkout_available: !!(deps.checkout && deps.order),
|
|
11806
|
+
gift_wraps: giftWraps,
|
|
11807
|
+
gift_wrap_in_cart: giftWrapInCart,
|
|
11356
11808
|
added: added,
|
|
11357
11809
|
shop_name: shopName,
|
|
11358
11810
|
theme: theme,
|
|
@@ -11462,6 +11914,28 @@ function mount(router, deps) {
|
|
|
11462
11914
|
}
|
|
11463
11915
|
} catch (_e) { prefill = null; }
|
|
11464
11916
|
}
|
|
11917
|
+
// Gift options — does the cart carry a wrap line? When it does (or the
|
|
11918
|
+
// gift primitive is wired at all), surface the message/recipient/hide-
|
|
11919
|
+
// prices fields so the customer can personalize the gift; they persist
|
|
11920
|
+
// post-commit via setForOrder. Drop-silent on a read failure.
|
|
11921
|
+
var giftWrapSkuInCart = null;
|
|
11922
|
+
if (deps.giftOptions) {
|
|
11923
|
+
try {
|
|
11924
|
+
var coWraps = await deps.giftOptions.listWraps({ active_only: true });
|
|
11925
|
+
var coWrapSet = {};
|
|
11926
|
+
for (var wi = 0; wi < coWraps.length; wi += 1) coWrapSet[coWraps[wi].wrap_sku] = true;
|
|
11927
|
+
for (var cli = 0; cli < lines.length; cli += 1) {
|
|
11928
|
+
if (coWrapSet[lines[cli].sku]) { giftWrapSkuInCart = lines[cli].sku; break; }
|
|
11929
|
+
}
|
|
11930
|
+
} catch (_e) { giftWrapSkuInCart = null; }
|
|
11931
|
+
}
|
|
11932
|
+
// Click-and-collect — the active pickup locations for the "pick up in
|
|
11933
|
+
// store" option. Drop-silent: an unmigrated table → no picker.
|
|
11934
|
+
var pickupLocations = [];
|
|
11935
|
+
if (deps.clickAndCollect) {
|
|
11936
|
+
try { pickupLocations = await deps.clickAndCollect.availableLocations({ limit: 50 }); }
|
|
11937
|
+
catch (_e) { pickupLocations = []; }
|
|
11938
|
+
}
|
|
11465
11939
|
return {
|
|
11466
11940
|
lines: lines, totals: totals, totals_detail: totalsDetail,
|
|
11467
11941
|
shop_name: shopName, theme: theme,
|
|
@@ -11469,6 +11943,9 @@ function mount(router, deps) {
|
|
|
11469
11943
|
paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
|
|
11470
11944
|
loyalty_balance: loyaltyBalance,
|
|
11471
11945
|
loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
|
|
11946
|
+
gift_enabled: !!deps.giftOptions,
|
|
11947
|
+
gift_wrap_sku_in_cart: giftWrapSkuInCart,
|
|
11948
|
+
pickup_locations: pickupLocations,
|
|
11472
11949
|
prefill: prefill,
|
|
11473
11950
|
inline_error: inlineError || null,
|
|
11474
11951
|
// CAPTCHA widget props — set only when a provider is active, so the
|
|
@@ -11492,6 +11969,72 @@ function mount(router, deps) {
|
|
|
11492
11969
|
res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(keys));
|
|
11493
11970
|
}
|
|
11494
11971
|
|
|
11972
|
+
// Persist the gift options + schedule a store pickup for a just-placed
|
|
11973
|
+
// order. POST-COMMIT + drop-silent (each in its own try/catch): both run
|
|
11974
|
+
// AFTER the charge and never change the amount — the wrap fee was already
|
|
11975
|
+
// a real cart line in the quote. The wrap_sku written here is read off the
|
|
11976
|
+
// cart's wrap line (so getForOrder shows it on the order page); the
|
|
11977
|
+
// message/recipient/hide-prices come from the checkout form. A failure
|
|
11978
|
+
// must NOT roll back the paid order.
|
|
11979
|
+
async function _persistGiftAndPickup(cart, placedOrder, body) {
|
|
11980
|
+
if (!placedOrder || !placedOrder.id) return;
|
|
11981
|
+
// Gift options.
|
|
11982
|
+
if (deps.giftOptions) {
|
|
11983
|
+
try {
|
|
11984
|
+
var orderLines = placedOrder.lines || [];
|
|
11985
|
+
var wrapSku = null;
|
|
11986
|
+
// Find the wrap line on the placed order (its sku matches an active
|
|
11987
|
+
// wrap) so the order page can show "Gift wrap: <sku>".
|
|
11988
|
+
var activeWraps = await deps.giftOptions.listWraps({ active_only: true });
|
|
11989
|
+
var wrapSet = {};
|
|
11990
|
+
for (var i = 0; i < activeWraps.length; i += 1) wrapSet[activeWraps[i].wrap_sku] = true;
|
|
11991
|
+
for (var li = 0; li < orderLines.length; li += 1) {
|
|
11992
|
+
if (wrapSet[orderLines[li].sku]) { wrapSku = orderLines[li].sku; break; }
|
|
11993
|
+
}
|
|
11994
|
+
var giftMessage = (typeof body.gift_message === "string" && body.gift_message.trim()) ? body.gift_message : null;
|
|
11995
|
+
var recipientName = (typeof body.gift_recipient_name === "string" && body.gift_recipient_name.trim()) ? body.gift_recipient_name : null;
|
|
11996
|
+
var hidePrices = body.gift_hide_prices === "1" || body.gift_hide_prices === true || body.gift_hide_prices === "on";
|
|
11997
|
+
// Only write a row when there's something to record (a wrap line, a
|
|
11998
|
+
// message, a recipient, or a hide-prices toggle).
|
|
11999
|
+
if (wrapSku || giftMessage || recipientName || hidePrices) {
|
|
12000
|
+
await deps.giftOptions.setForOrder({
|
|
12001
|
+
order_id: placedOrder.id,
|
|
12002
|
+
wrap_sku: wrapSku || undefined,
|
|
12003
|
+
gift_message: giftMessage || undefined,
|
|
12004
|
+
recipient_name: recipientName || undefined,
|
|
12005
|
+
hide_prices: hidePrices,
|
|
12006
|
+
});
|
|
12007
|
+
}
|
|
12008
|
+
} catch (_e) { /* drop-silent — a gift-options failure must not roll back the paid order */ }
|
|
12009
|
+
}
|
|
12010
|
+
// Pickup scheduling — when the customer chose "pick up in store". A
|
|
12011
|
+
// default 1-hour window starting at the location's lead-time floor (the
|
|
12012
|
+
// primitive enforces lead time + capacity; a refusal is swallowed). v1
|
|
12013
|
+
// keeps shipping as quoted — the pickup choice doesn't suppress the
|
|
12014
|
+
// shipping charge (re-open if operators need that).
|
|
12015
|
+
if (deps.clickAndCollect) {
|
|
12016
|
+
var locationCode = typeof body.pickup_location_code === "string" ? body.pickup_location_code.trim() : "";
|
|
12017
|
+
if (locationCode) {
|
|
12018
|
+
try {
|
|
12019
|
+
var loc = await deps.clickAndCollect.getLocation(locationCode);
|
|
12020
|
+
if (loc) {
|
|
12021
|
+
var leadMs = Number(loc.lead_time_hours || 0) * b.constants.TIME.hours(1);
|
|
12022
|
+
// Start the window one minute past the lead-time floor so the
|
|
12023
|
+
// primitive's strict `< leadFloor` gate accepts it.
|
|
12024
|
+
var start = Date.now() + leadMs + b.constants.TIME.minutes(1);
|
|
12025
|
+
var end = start + b.constants.TIME.hours(1);
|
|
12026
|
+
await deps.clickAndCollect.scheduleAtLocation({
|
|
12027
|
+
order_id: placedOrder.id,
|
|
12028
|
+
location_code: locationCode,
|
|
12029
|
+
scheduled_window_start: start,
|
|
12030
|
+
scheduled_window_end: end,
|
|
12031
|
+
});
|
|
12032
|
+
}
|
|
12033
|
+
} catch (_e) { /* drop-silent — a scheduling failure must not roll back the paid order */ }
|
|
12034
|
+
}
|
|
12035
|
+
}
|
|
12036
|
+
}
|
|
12037
|
+
|
|
11495
12038
|
router.get("/checkout", async function (req, res) {
|
|
11496
12039
|
var sid = _readSidCookie(req);
|
|
11497
12040
|
if (!sid) return _send(res, 303, "<a href=\"/cart\">Cart is empty</a>"), res.setHeader && res.setHeader("location", "/cart");
|
|
@@ -11580,6 +12123,13 @@ function mount(router, deps) {
|
|
|
11580
12123
|
loyalty_redeem_points: _parseRedeemPoints(body.loyalty_redeem_points),
|
|
11581
12124
|
idempotency_key: "checkout:" + c.id + ":" + b.uuid.v7(),
|
|
11582
12125
|
});
|
|
12126
|
+
// POST-COMMIT, drop-silent: gift options + pickup scheduling. Both
|
|
12127
|
+
// run AFTER the charge and DO NOT change the amount — the wrap FEE was
|
|
12128
|
+
// already a real cart line in the quote, so it's charged; only the
|
|
12129
|
+
// gift metadata (message/recipient/hide-prices) and the pickup
|
|
12130
|
+
// schedule land here. A failure must never roll back a paid order, so
|
|
12131
|
+
// each is its own try/catch (mirrors _recordAutoDiscounts).
|
|
12132
|
+
await _persistGiftAndPickup(c, result.order, body);
|
|
11583
12133
|
// When a gift card fully covered the order there's no Stripe
|
|
11584
12134
|
// intent — the order is already paid. Skip the pay-cookie +
|
|
11585
12135
|
// pay page and land the customer straight on the confirmation.
|
|
@@ -11870,11 +12420,28 @@ function mount(router, deps) {
|
|
|
11870
12420
|
// Operator trust badges at the order_confirmation placement (container-
|
|
11871
12421
|
// only; drop-silent → "" on any failure).
|
|
11872
12422
|
var ordTrustBadges = await _trustBadgesHtml("order_confirmation", req);
|
|
12423
|
+
// Pickup (BOPIS) status — the IDOR gate above already proved ownership
|
|
12424
|
+
// (or this is a guest order on its capability URL). Drop-silent
|
|
12425
|
+
// defensive read: an unmigrated pickup table / bad shape → no panel.
|
|
12426
|
+
var pickup = null;
|
|
12427
|
+
if (deps.clickAndCollect) {
|
|
12428
|
+
try { pickup = await deps.clickAndCollect.getScheduleByOrder(o.id); }
|
|
12429
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; pickup = null; }
|
|
12430
|
+
}
|
|
12431
|
+
// Gift options — the order's wrap / message / recipient / hide-prices.
|
|
12432
|
+
// Drop-silent: an unmigrated gift_options table / bad shape → no panel.
|
|
12433
|
+
var giftOptionsRow = null;
|
|
12434
|
+
if (deps.giftOptions) {
|
|
12435
|
+
try { giftOptionsRow = await deps.giftOptions.getForOrder(o.id); }
|
|
12436
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; giftOptionsRow = null; }
|
|
12437
|
+
}
|
|
11873
12438
|
_send(res, 200, renderOrder({
|
|
11874
12439
|
order: o,
|
|
11875
12440
|
product_lookup: productLookup,
|
|
11876
12441
|
recommendations: recommendations,
|
|
11877
12442
|
shipments: shipments,
|
|
12443
|
+
pickup: pickup,
|
|
12444
|
+
gift_options: giftOptionsRow,
|
|
11878
12445
|
trust_badges_html: ordTrustBadges,
|
|
11879
12446
|
rating: ratingRow,
|
|
11880
12447
|
rating_eligible: ratingEligible,
|
|
@@ -12388,6 +12955,8 @@ function mount(router, deps) {
|
|
|
12388
12955
|
order_product_lookup: orderProductLookup,
|
|
12389
12956
|
passkey_count: passkeyCount,
|
|
12390
12957
|
preorders_enabled: !!preorder,
|
|
12958
|
+
pickups_enabled: !!deps.clickAndCollect,
|
|
12959
|
+
payment_methods_enabled: !!(deps.paymentMethods && deps.payment),
|
|
12391
12960
|
shop_name: shopName,
|
|
12392
12961
|
cart_count: cartCount,
|
|
12393
12962
|
}));
|
|
@@ -12658,6 +13227,181 @@ function mount(router, deps) {
|
|
|
12658
13227
|
}
|
|
12659
13228
|
});
|
|
12660
13229
|
|
|
13230
|
+
// ---- saved payment methods (Stripe SetupIntent vault) -------------
|
|
13231
|
+
//
|
|
13232
|
+
// List / set-default / archive a customer's saved cards, plus an add-card
|
|
13233
|
+
// flow over a Stripe SetupIntent (the shop never sees the PAN/CVV — only
|
|
13234
|
+
// the opaque pm_… token). Mounts only when BOTH the paymentMethods
|
|
13235
|
+
// primitive AND a Stripe payment handle are wired (the SetupIntent +
|
|
13236
|
+
// payment-method reads need Stripe). The list/set-default/archive
|
|
13237
|
+
// surfaces need no external JS; the add page reuses the route-scoped CSP +
|
|
13238
|
+
// an external same-origin island (saved-card.js), mirroring the pay page.
|
|
13239
|
+
if (deps.paymentMethods && deps.payment) {
|
|
13240
|
+
// IDOR GATE — paymentMethods.get/setDefault/archive are keyed by id
|
|
13241
|
+
// ALONE (no customer scope). Resolve the row by path id and confirm it
|
|
13242
|
+
// belongs to the authed customer; a foreign / unknown / malformed id is
|
|
13243
|
+
// a 404, never a cross-customer reveal or mutation. Mandatory before any
|
|
13244
|
+
// read/mutate. Mirrors _ownedPasskey.
|
|
13245
|
+
async function _ownedPaymentMethod(req, res, auth) {
|
|
13246
|
+
var id = req.params && req.params.id;
|
|
13247
|
+
var row;
|
|
13248
|
+
try { row = await deps.paymentMethods.get(id); }
|
|
13249
|
+
catch (e) {
|
|
13250
|
+
// A malformed id throws TypeError from the uuid guard — a 404, not
|
|
13251
|
+
// a 500 (a bad id is a missing record).
|
|
13252
|
+
if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
13253
|
+
throw e;
|
|
13254
|
+
}
|
|
13255
|
+
if (!row || row.customer_id !== auth.customer_id) {
|
|
13256
|
+
_send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
13257
|
+
return null;
|
|
13258
|
+
}
|
|
13259
|
+
return row;
|
|
13260
|
+
}
|
|
13261
|
+
|
|
13262
|
+
async function _renderPaymentMethodsPage(req, res, auth, notice, code) {
|
|
13263
|
+
var rows = await deps.paymentMethods.listForCustomer(auth.customer_id);
|
|
13264
|
+
var cartCount = await _cartCountForReq(req);
|
|
13265
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
13266
|
+
var okKind = url ? url.searchParams.get("ok") : null;
|
|
13267
|
+
var successCopy = null;
|
|
13268
|
+
if (okKind === "added") successCopy = "Card saved.";
|
|
13269
|
+
if (okKind === "default") successCopy = "Default card updated.";
|
|
13270
|
+
if (okKind === "archived") successCopy = "Card removed.";
|
|
13271
|
+
if (okKind === "exists") successCopy = "That card is already on file.";
|
|
13272
|
+
_send(res, code || 200, renderPaymentMethods({
|
|
13273
|
+
payment_methods: rows,
|
|
13274
|
+
notice: notice || null,
|
|
13275
|
+
success: successCopy,
|
|
13276
|
+
shop_name: shopName,
|
|
13277
|
+
cart_count: cartCount,
|
|
13278
|
+
}));
|
|
13279
|
+
}
|
|
13280
|
+
|
|
13281
|
+
router.get("/account/payment-methods", async function (req, res) {
|
|
13282
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
13283
|
+
await _renderPaymentMethodsPage(req, res, auth, null);
|
|
13284
|
+
});
|
|
13285
|
+
|
|
13286
|
+
// The add-card page — loads Stripe.js (admitted by the route-scoped
|
|
13287
|
+
// CSP) + the saved-card.js island that mounts a SetupIntent Payment
|
|
13288
|
+
// Element. The publishable key rides a data-* attribute (no inline
|
|
13289
|
+
// script). Requires the publishable key; absent it, a 503 like the pay
|
|
13290
|
+
// page.
|
|
13291
|
+
router.get("/account/payment-methods/add", async function (req, res) {
|
|
13292
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
13293
|
+
var pk = deps.stripe_publishable_key || "";
|
|
13294
|
+
if (!pk) {
|
|
13295
|
+
return _send(res, 503, _wrap({
|
|
13296
|
+
title: "Add a card", shop_name: shopName, theme_css: undefined, cart_count: await _cartCountForReq(req),
|
|
13297
|
+
body: "<section class=\"account-returns\"><h1>Add a card</h1><p>Card storage isn't available right now.</p>" +
|
|
13298
|
+
"<a class=\"btn-secondary\" href=\"/account/payment-methods\">Back</a></section>",
|
|
13299
|
+
}));
|
|
13300
|
+
}
|
|
13301
|
+
// Route-scoped CSP admits js.stripe.com (script/connect/frame) so the
|
|
13302
|
+
// SDK + the same-origin saved-card.js island load — without relaxing
|
|
13303
|
+
// the app-level strict CSP on any other route.
|
|
13304
|
+
res.setHeader && res.setHeader("content-security-policy", securityMiddleware.scopedCsp(["stripe"]));
|
|
13305
|
+
var cartCount = await _cartCountForReq(req);
|
|
13306
|
+
_send(res, 200, renderAddPaymentMethod({
|
|
13307
|
+
publishable_key: pk,
|
|
13308
|
+
shop_name: shopName,
|
|
13309
|
+
cart_count: cartCount,
|
|
13310
|
+
}));
|
|
13311
|
+
});
|
|
13312
|
+
|
|
13313
|
+
// Create (server-side) a SetupIntent for the shopper + return its
|
|
13314
|
+
// client_secret as JSON (CSRF-tokened; NOT an edge path). A fresh
|
|
13315
|
+
// Stripe Customer is minted per add (no stripe_customer_id column on
|
|
13316
|
+
// the customers table; Stripe dedupes by metadata/email) — acceptable
|
|
13317
|
+
// v1, documented in the build spec.
|
|
13318
|
+
router.post("/account/payment-methods/setup-intent", async function (req, res) {
|
|
13319
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
13320
|
+
function _json(status, obj) {
|
|
13321
|
+
res.status(status);
|
|
13322
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
13323
|
+
var s = JSON.stringify(obj);
|
|
13324
|
+
return res.end ? res.end(s) : res.send(s);
|
|
13325
|
+
}
|
|
13326
|
+
try {
|
|
13327
|
+
var customer = await deps.payment.createCustomer({ metadata: { shop_customer_id: auth.customer_id } });
|
|
13328
|
+
if (!customer || !customer.id) return _json(502, { error: "customer-create-failed" });
|
|
13329
|
+
var si = await deps.payment.createSetupIntent({ customer: customer.id });
|
|
13330
|
+
if (!si || !si.client_secret) return _json(502, { error: "setup-intent-failed" });
|
|
13331
|
+
return _json(200, { client_secret: si.client_secret });
|
|
13332
|
+
} catch (e) {
|
|
13333
|
+
return _json(e instanceof TypeError ? 400 : 502, { error: (e && e.message) || "setup-intent-failed" });
|
|
13334
|
+
}
|
|
13335
|
+
});
|
|
13336
|
+
|
|
13337
|
+
// After Elements confirms the SetupIntent client-side, the browser
|
|
13338
|
+
// POSTs the setup_intent_id back; the server reads it → the resulting
|
|
13339
|
+
// pm_… → its display fields → stores via paymentMethods.add. A
|
|
13340
|
+
// duplicate token (already on file) is an idempotent notice, not a 500.
|
|
13341
|
+
router.post("/account/payment-methods", async function (req, res) {
|
|
13342
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
13343
|
+
var body = req.body || {};
|
|
13344
|
+
var setupIntentId = typeof body.setup_intent_id === "string" ? body.setup_intent_id : "";
|
|
13345
|
+
if (!setupIntentId) return _renderPaymentMethodsPage(req, res, auth, "Couldn't add that card — please try again.", 400);
|
|
13346
|
+
try {
|
|
13347
|
+
var si = await deps.payment.retrieveSetupIntent(setupIntentId);
|
|
13348
|
+
var pmId = si && si.payment_method;
|
|
13349
|
+
if (!pmId) return _renderPaymentMethodsPage(req, res, auth, "That card couldn't be confirmed — please try again.", 400);
|
|
13350
|
+
var pm = await deps.payment.retrievePaymentMethod(pmId);
|
|
13351
|
+
var card = (pm && pm.card) || {};
|
|
13352
|
+
if (!card.brand || !card.last4) {
|
|
13353
|
+
return _renderPaymentMethodsPage(req, res, auth, "We couldn't read that card's details — please try again.", 400);
|
|
13354
|
+
}
|
|
13355
|
+
await deps.paymentMethods.add({
|
|
13356
|
+
customer_id: auth.customer_id,
|
|
13357
|
+
processor: "stripe",
|
|
13358
|
+
processor_token: pmId,
|
|
13359
|
+
brand: card.brand,
|
|
13360
|
+
last4: card.last4,
|
|
13361
|
+
exp_month: Number(card.exp_month),
|
|
13362
|
+
exp_year: Number(card.exp_year),
|
|
13363
|
+
});
|
|
13364
|
+
} catch (e) {
|
|
13365
|
+
if (e && e.code === "PAYMENT_METHOD_DUPLICATE_TOKEN") {
|
|
13366
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/payment-methods?ok=exists");
|
|
13367
|
+
return res.end ? res.end() : res.send("");
|
|
13368
|
+
}
|
|
13369
|
+
if (e instanceof TypeError) return _renderPaymentMethodsPage(req, res, auth, "That card couldn't be saved — please try again.", 400);
|
|
13370
|
+
throw e;
|
|
13371
|
+
}
|
|
13372
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/payment-methods?ok=added");
|
|
13373
|
+
return res.end ? res.end() : res.send("");
|
|
13374
|
+
});
|
|
13375
|
+
|
|
13376
|
+
router.post("/account/payment-methods/:id/default", async function (req, res) {
|
|
13377
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
13378
|
+
var pm = await _ownedPaymentMethod(req, res, auth); if (!pm) return;
|
|
13379
|
+
try { await deps.paymentMethods.setDefault(pm.id); }
|
|
13380
|
+
catch (e) {
|
|
13381
|
+
if (e instanceof TypeError || (e && typeof e.code === "string" && e.code.indexOf("PAYMENT_METHOD_") === 0)) {
|
|
13382
|
+
return _renderPaymentMethodsPage(req, res, auth, "Couldn't set that as your default card.", 400);
|
|
13383
|
+
}
|
|
13384
|
+
throw e;
|
|
13385
|
+
}
|
|
13386
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/payment-methods?ok=default");
|
|
13387
|
+
return res.end ? res.end() : res.send("");
|
|
13388
|
+
});
|
|
13389
|
+
|
|
13390
|
+
router.post("/account/payment-methods/:id/archive", async function (req, res) {
|
|
13391
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
13392
|
+
var pm = await _ownedPaymentMethod(req, res, auth); if (!pm) return;
|
|
13393
|
+
try { await deps.paymentMethods.archive({ payment_method_id: pm.id, reason: "customer_request" }); }
|
|
13394
|
+
catch (e) {
|
|
13395
|
+
if (e instanceof TypeError || (e && typeof e.code === "string" && e.code.indexOf("PAYMENT_METHOD_") === 0)) {
|
|
13396
|
+
return _renderPaymentMethodsPage(req, res, auth, "Couldn't remove that card.", 400);
|
|
13397
|
+
}
|
|
13398
|
+
throw e;
|
|
13399
|
+
}
|
|
13400
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/payment-methods?ok=archived");
|
|
13401
|
+
return res.end ? res.end() : res.send("");
|
|
13402
|
+
});
|
|
13403
|
+
}
|
|
13404
|
+
|
|
12661
13405
|
// Profile edit — display-name only. Email is hash-only + the OAuth
|
|
12662
13406
|
// linking key, so the primitive refuses an email change without a
|
|
12663
13407
|
// verification ceremony; the form shows it read-only. PRG with a
|
|
@@ -13914,7 +14658,7 @@ function mount(router, deps) {
|
|
|
13914
14658
|
if (kind === "default-billing") return "Default billing address updated.";
|
|
13915
14659
|
return null;
|
|
13916
14660
|
}
|
|
13917
|
-
async function _renderAddrPage(req, res, auth, editAddr, notice, code) {
|
|
14661
|
+
async function _renderAddrPage(req, res, auth, editAddr, notice, code, invalidField) {
|
|
13918
14662
|
var rows = await deps.addresses.listForCustomer(auth.customer_id, { limit: 50 });
|
|
13919
14663
|
var cartCount = await _cartCountForReq(req);
|
|
13920
14664
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
@@ -13924,15 +14668,24 @@ function mount(router, deps) {
|
|
|
13924
14668
|
// when the ?undo=<id> marker round-trips a real owned address id.
|
|
13925
14669
|
var undoId = (okKind === "removed" && url) ? url.searchParams.get("undo") : null;
|
|
13926
14670
|
_send(res, code || 200, renderAddresses({
|
|
13927
|
-
addresses:
|
|
13928
|
-
edit:
|
|
13929
|
-
notice:
|
|
13930
|
-
success:
|
|
13931
|
-
undo_id:
|
|
13932
|
-
|
|
13933
|
-
|
|
14671
|
+
addresses: rows,
|
|
14672
|
+
edit: editAddr || null,
|
|
14673
|
+
notice: notice || null,
|
|
14674
|
+
success: success,
|
|
14675
|
+
undo_id: undoId || null,
|
|
14676
|
+
invalid_field: invalidField || null,
|
|
14677
|
+
shop_name: shopName,
|
|
14678
|
+
cart_count: cartCount,
|
|
13934
14679
|
}));
|
|
13935
14680
|
}
|
|
14681
|
+
// The address form's renderable fields, by `name`. An error on a
|
|
14682
|
+
// non-form internal (customer_id, address_id) returns null from the
|
|
14683
|
+
// shared extractor, so the page-top banner still shows but no input is
|
|
14684
|
+
// falsely marked.
|
|
14685
|
+
var _ADDR_FORM_FIELDS = {
|
|
14686
|
+
recipient_name: 1, label: 1, company: 1, street_line1: 1, street_line2: 1,
|
|
14687
|
+
city: 1, region: 1, postal_code: 1, country: 1, phone: 1,
|
|
14688
|
+
};
|
|
13936
14689
|
function _addrInput(body, customerId) {
|
|
13937
14690
|
return {
|
|
13938
14691
|
customer_id: customerId,
|
|
@@ -13969,7 +14722,10 @@ function mount(router, deps) {
|
|
|
13969
14722
|
try {
|
|
13970
14723
|
await deps.addresses.add(_addrInput(req.body || {}, auth.customer_id));
|
|
13971
14724
|
} catch (e) {
|
|
13972
|
-
if (e instanceof TypeError)
|
|
14725
|
+
if (e instanceof TypeError) {
|
|
14726
|
+
var inv = _fieldFromValidatorError(e, "addresses", _ADDR_FORM_FIELDS);
|
|
14727
|
+
return _renderAddrPage(req, res, auth, null, (e && e.message) || "Please check the address.", 400, inv);
|
|
14728
|
+
}
|
|
13973
14729
|
throw e;
|
|
13974
14730
|
}
|
|
13975
14731
|
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses?ok=added");
|
|
@@ -13984,7 +14740,8 @@ function mount(router, deps) {
|
|
|
13984
14740
|
} catch (e) {
|
|
13985
14741
|
if (e instanceof TypeError) {
|
|
13986
14742
|
var merged = Object.assign({}, addr, _addrInput(req.body || {}, auth.customer_id));
|
|
13987
|
-
|
|
14743
|
+
var inv = _fieldFromValidatorError(e, "addresses", _ADDR_FORM_FIELDS);
|
|
14744
|
+
return _renderAddrPage(req, res, auth, merged, (e && e.message) || "Please check the address.", 400, inv);
|
|
13988
14745
|
}
|
|
13989
14746
|
throw e;
|
|
13990
14747
|
}
|
|
@@ -14935,6 +15692,26 @@ function mount(router, deps) {
|
|
|
14935
15692
|
});
|
|
14936
15693
|
}
|
|
14937
15694
|
|
|
15695
|
+
// Click-and-collect — the signed-in customer's pickup list. Scoped via
|
|
15696
|
+
// the primitive's customerSchedules, which resolves the customer→order
|
|
15697
|
+
// linkage through the shared order handle, so a foreign order's pickup
|
|
15698
|
+
// never appears here (the IDOR defense is the order-scoping). Mounts only
|
|
15699
|
+
// when the primitive is wired.
|
|
15700
|
+
if (deps.clickAndCollect) {
|
|
15701
|
+
router.get("/account/pickups", async function (req, res) {
|
|
15702
|
+
var auth = _accountAuth(req, res); if (!auth) return;
|
|
15703
|
+
var schedules = [];
|
|
15704
|
+
try { schedules = await deps.clickAndCollect.customerSchedules(auth.customer_id); }
|
|
15705
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; schedules = []; }
|
|
15706
|
+
var cartCount = await _cartCountForReq(req);
|
|
15707
|
+
_send(res, 200, renderAccountPickups({
|
|
15708
|
+
pickups: schedules,
|
|
15709
|
+
shop_name: shopName,
|
|
15710
|
+
cart_count: cartCount,
|
|
15711
|
+
}));
|
|
15712
|
+
});
|
|
15713
|
+
}
|
|
15714
|
+
|
|
14938
15715
|
// Support tickets — the signed-in customer raises a ticket, lists
|
|
14939
15716
|
// their own, reads a thread, and replies. EVERY route is login-gated
|
|
14940
15717
|
// AND scoped to the session customer's id: the support primitive
|
|
@@ -15057,11 +15834,12 @@ function mount(router, deps) {
|
|
|
15057
15834
|
} catch (e) {
|
|
15058
15835
|
if (e instanceof TypeError) {
|
|
15059
15836
|
return _send(res, 400, renderSupportNew({
|
|
15060
|
-
orders:
|
|
15061
|
-
values:
|
|
15062
|
-
notice:
|
|
15063
|
-
|
|
15064
|
-
|
|
15837
|
+
orders: await _orders(),
|
|
15838
|
+
values: body,
|
|
15839
|
+
notice: (e && e.message || "").replace(/^supportTickets[.:]\s*/, "") || "Please check your ticket and try again.",
|
|
15840
|
+
invalid_field: _fieldFromValidatorError(e, "supportTickets", { customer_email: 1, category: 1, subject: 1, body: 1 }),
|
|
15841
|
+
shop_name: shopName,
|
|
15842
|
+
cart_count: cartCount,
|
|
15065
15843
|
}));
|
|
15066
15844
|
}
|
|
15067
15845
|
throw e;
|
|
@@ -15745,10 +16523,11 @@ function mount(router, deps) {
|
|
|
15745
16523
|
// anything else is a real 500.
|
|
15746
16524
|
if (e instanceof TypeError) {
|
|
15747
16525
|
return _send(res, 400, renderReviewForm({
|
|
15748
|
-
product:
|
|
15749
|
-
notice:
|
|
15750
|
-
|
|
15751
|
-
|
|
16526
|
+
product: { title: ctx.product.title, slug: ctx.product.slug },
|
|
16527
|
+
notice: (e && e.message) || "Please check your review and try again.",
|
|
16528
|
+
invalid_field: _fieldFromValidatorError(e, "reviews", { rating: 1, title: 1, body: 1 }),
|
|
16529
|
+
shop_name: shopName,
|
|
16530
|
+
cart_count: ctx.cartCount,
|
|
15752
16531
|
}));
|
|
15753
16532
|
}
|
|
15754
16533
|
throw e;
|
|
@@ -15816,10 +16595,11 @@ function mount(router, deps) {
|
|
|
15816
16595
|
// anything else is a real 500.
|
|
15817
16596
|
if (e instanceof TypeError) {
|
|
15818
16597
|
return _send(res, 400, renderQuestionForm({
|
|
15819
|
-
product:
|
|
15820
|
-
notice:
|
|
15821
|
-
|
|
15822
|
-
|
|
16598
|
+
product: { title: ctx.product.title, slug: ctx.product.slug },
|
|
16599
|
+
notice: (e && e.message) || "Please check your question and try again.",
|
|
16600
|
+
invalid_field: _fieldFromValidatorError(e, "productQA", { body: 1 }),
|
|
16601
|
+
shop_name: shopName,
|
|
16602
|
+
cart_count: ctx.cartCount,
|
|
15823
16603
|
}));
|
|
15824
16604
|
}
|
|
15825
16605
|
throw e;
|
|
@@ -16083,6 +16863,53 @@ function mount(router, deps) {
|
|
|
16083
16863
|
res.end ? res.end() : res.send("");
|
|
16084
16864
|
});
|
|
16085
16865
|
|
|
16866
|
+
// POST /cart/gift — set (or clear) the cart's gift wrap. The wrap rides as
|
|
16867
|
+
// a REAL cart line so its fee flows through pricing.totals and is charged
|
|
16868
|
+
// by checkout.confirm (NEVER a post-commit hook — that would mis-charge).
|
|
16869
|
+
// Selecting "No gift wrap" removes any wrap line. Selecting a wrap removes
|
|
16870
|
+
// any prior wrap line then adds the chosen one (qty 1, capped by
|
|
16871
|
+
// max_per_order). The message / recipient are collected at checkout (they
|
|
16872
|
+
// need the order id). Mounts only when the gift-options primitive is wired.
|
|
16873
|
+
if (deps.giftOptions) {
|
|
16874
|
+
router.post("/cart/gift", async function (req, res) {
|
|
16875
|
+
var body = req.body || {};
|
|
16876
|
+
var wrapSku = typeof body.wrap_sku === "string" ? body.wrap_sku.trim() : "";
|
|
16877
|
+
var resolved = await _getOrCreateCart(req, res, "USD");
|
|
16878
|
+
var cartId = resolved.cart.id;
|
|
16879
|
+
try {
|
|
16880
|
+
// Resolve the active wrap catalog so we know every wrap sku (to
|
|
16881
|
+
// remove a stale wrap line) and the selected wrap's cap.
|
|
16882
|
+
var wraps = await deps.giftOptions.listWraps({ active_only: true });
|
|
16883
|
+
var wrapBySku = {};
|
|
16884
|
+
for (var i = 0; i < wraps.length; i += 1) wrapBySku[wraps[i].wrap_sku] = wraps[i];
|
|
16885
|
+
// Remove any existing wrap line first (every active wrap's sku).
|
|
16886
|
+
var existingLines = await deps.cart.listLines(cartId);
|
|
16887
|
+
for (var li = 0; li < existingLines.length; li += 1) {
|
|
16888
|
+
if (wrapBySku[existingLines[li].sku]) {
|
|
16889
|
+
await deps.cart.removeLine(existingLines[li].id, cartId);
|
|
16890
|
+
}
|
|
16891
|
+
}
|
|
16892
|
+
// Add the selected wrap (when one was chosen + it's a real active
|
|
16893
|
+
// wrap). The wrap_sku is a real catalog variant; resolve its variant
|
|
16894
|
+
// id and add it as a line — the price snapshot comes from the catalog
|
|
16895
|
+
// price, so the fee is charged through the normal quote path.
|
|
16896
|
+
if (wrapSku && wrapBySku[wrapSku]) {
|
|
16897
|
+
var variant = await deps.catalog.variants.bySku(wrapSku);
|
|
16898
|
+
if (variant) {
|
|
16899
|
+
await deps.cart.addLine(cartId, { variant_id: variant.id, qty: 1 });
|
|
16900
|
+
}
|
|
16901
|
+
}
|
|
16902
|
+
} catch (e) {
|
|
16903
|
+
if (!(e instanceof TypeError)) throw e;
|
|
16904
|
+
// A bad shape degrades to a silent no-op redirect rather than 500 —
|
|
16905
|
+
// the cart is still valid, the wrap just wasn't changed.
|
|
16906
|
+
}
|
|
16907
|
+
res.status(303);
|
|
16908
|
+
res.setHeader && res.setHeader("location", "/cart");
|
|
16909
|
+
return res.end ? res.end() : res.send("");
|
|
16910
|
+
});
|
|
16911
|
+
}
|
|
16912
|
+
|
|
16086
16913
|
// POST /cart/bundle — add every member of a bundle to the cart at the
|
|
16087
16914
|
// bundle price, atomically. Reads `bundle_sku` from the form body.
|
|
16088
16915
|
// The price is recomputed server-side from the catalog + the bundle
|
|
@@ -16912,6 +17739,9 @@ module.exports = {
|
|
|
16912
17739
|
renderAccount: renderAccount,
|
|
16913
17740
|
renderPasskeys: renderPasskeys,
|
|
16914
17741
|
renderPasskeyRemoveConfirm: renderPasskeyRemoveConfirm,
|
|
17742
|
+
renderPaymentMethods: renderPaymentMethods,
|
|
17743
|
+
renderAddPaymentMethod: renderAddPaymentMethod,
|
|
17744
|
+
renderAccountPickups: renderAccountPickups,
|
|
16915
17745
|
renderProfile: renderProfile,
|
|
16916
17746
|
renderAccountSubscriptions: renderAccountSubscriptions,
|
|
16917
17747
|
renderCookiePreferences: renderCookiePreferences,
|