@blamejs/blamejs-shop 0.3.54 → 0.3.56
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/README.md +39 -2
- package/SECURITY.md +14 -0
- package/lib/admin.js +87 -77
- package/lib/asset-manifest.json +5 -5
- package/lib/email.js +169 -0
- package/lib/storefront.js +522 -47
- package/lib/wishlist-alerts.js +22 -0
- package/lib/wishlist-digest.js +18 -0
- 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",
|
|
@@ -2748,6 +2805,11 @@ function renderWishlist(opts) {
|
|
|
2748
2805
|
// sharing primitive isn't wired, or a unit test calling the renderer
|
|
2749
2806
|
// directly) the panel is empty so the page renders unchanged.
|
|
2750
2807
|
var sharePanel = opts.share_panel || "";
|
|
2808
|
+
// Alert + digest opt-in panel (server-rendered, no client JS). The
|
|
2809
|
+
// route builds it (it owns the per-trigger / per-schedule prefs reads);
|
|
2810
|
+
// absent it (the alerts/digest primitives aren't wired, or a unit test
|
|
2811
|
+
// calling the renderer directly) the panel is empty.
|
|
2812
|
+
var prefsPanel = opts.prefs_panel || "";
|
|
2751
2813
|
var body =
|
|
2752
2814
|
"<section class=\"account-wishlist\">" +
|
|
2753
2815
|
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
@@ -2756,6 +2818,7 @@ function renderWishlist(opts) {
|
|
|
2756
2818
|
"</ol></nav>" +
|
|
2757
2819
|
"<h1 class=\"account-wishlist__title\">Saved items</h1>" +
|
|
2758
2820
|
notice +
|
|
2821
|
+
prefsPanel +
|
|
2759
2822
|
sharePanel +
|
|
2760
2823
|
inner +
|
|
2761
2824
|
"</section>";
|
|
@@ -2841,6 +2904,81 @@ function _wishlistSharePanel(opts) {
|
|
|
2841
2904
|
"</section>";
|
|
2842
2905
|
}
|
|
2843
2906
|
|
|
2907
|
+
// The wishlist alert + digest opt-in panel (mounts on /account/wishlist
|
|
2908
|
+
// when the alerts / digest primitives are wired). Server-rendered, no
|
|
2909
|
+
// client JS: each toggle is a `<form method="post">` whose hidden `on`
|
|
2910
|
+
// field flips on submit (the submit button carries the new state). The
|
|
2911
|
+
// `_csrf` token is injected automatically by the `_wrap` form chokepoint
|
|
2912
|
+
// (_injectCsrfFields) like every other container POST form — the panel
|
|
2913
|
+
// emits no token itself.
|
|
2914
|
+
//
|
|
2915
|
+
// opts.alerts — [{ trigger, label, subscribed }] (subscribed drives
|
|
2916
|
+
// the button's on/off action)
|
|
2917
|
+
// opts.digests — [{ slug, label, enrolled, enrollment_id }]
|
|
2918
|
+
// opts.notice — "alerts" | "digest" → a saved confirmation
|
|
2919
|
+
//
|
|
2920
|
+
// Every operator-controlled string (a schedule slug used as a label) is
|
|
2921
|
+
// escaped via the storefront `esc()` path.
|
|
2922
|
+
var WISHLIST_PREFS_NOTICES = {
|
|
2923
|
+
alerts: "Alert preferences saved.",
|
|
2924
|
+
digest: "Digest subscription updated.",
|
|
2925
|
+
};
|
|
2926
|
+
function _wishlistPrefsPanel(opts) {
|
|
2927
|
+
opts = opts || {};
|
|
2928
|
+
var esc = b.template.escapeHtml;
|
|
2929
|
+
var alerts = opts.alerts || [];
|
|
2930
|
+
var digests = opts.digests || [];
|
|
2931
|
+
if (alerts.length === 0 && digests.length === 0) return "";
|
|
2932
|
+
var noticeMsg = WISHLIST_PREFS_NOTICES[opts.notice];
|
|
2933
|
+
var notice = noticeMsg
|
|
2934
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
|
|
2935
|
+
: "";
|
|
2936
|
+
|
|
2937
|
+
var alertRows = "";
|
|
2938
|
+
for (var i = 0; i < alerts.length; i += 1) {
|
|
2939
|
+
var a = alerts[i];
|
|
2940
|
+
var on = a.subscribed === true;
|
|
2941
|
+
// Submitting flips the state: a subscribed trigger posts on="" (off);
|
|
2942
|
+
// an unsubscribed one posts on="1".
|
|
2943
|
+
alertRows +=
|
|
2944
|
+
"<form class=\"wishlist-prefs__row\" method=\"post\" action=\"/account/wishlist/alerts\">" +
|
|
2945
|
+
"<input type=\"hidden\" name=\"trigger\" value=\"" + esc(a.trigger) + "\">" +
|
|
2946
|
+
"<input type=\"hidden\" name=\"on\" value=\"" + (on ? "" : "1") + "\">" +
|
|
2947
|
+
"<span class=\"wishlist-prefs__label\">" + esc(a.label || a.trigger) + "</span>" +
|
|
2948
|
+
"<span class=\"wishlist-prefs__state\">" + (on ? "On" : "Off") + "</span>" +
|
|
2949
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">" + (on ? "Turn off" : "Turn on") + "</button>" +
|
|
2950
|
+
"</form>";
|
|
2951
|
+
}
|
|
2952
|
+
var alertsBlock = alertRows
|
|
2953
|
+
? "<h3 class=\"wishlist-prefs__subhead\">Price-drop & back-in-stock alerts</h3>" + alertRows
|
|
2954
|
+
: "";
|
|
2955
|
+
|
|
2956
|
+
var digestRows = "";
|
|
2957
|
+
for (var j = 0; j < digests.length; j += 1) {
|
|
2958
|
+
var d = digests[j];
|
|
2959
|
+
var enrolled = d.enrolled === true;
|
|
2960
|
+
digestRows +=
|
|
2961
|
+
"<form class=\"wishlist-prefs__row\" method=\"post\" action=\"/account/wishlist/digest\">" +
|
|
2962
|
+
"<input type=\"hidden\" name=\"schedule_slug\" value=\"" + esc(d.slug) + "\">" +
|
|
2963
|
+
"<input type=\"hidden\" name=\"on\" value=\"" + (enrolled ? "" : "1") + "\">" +
|
|
2964
|
+
"<span class=\"wishlist-prefs__label\">" + esc(d.label || d.slug) + "</span>" +
|
|
2965
|
+
"<span class=\"wishlist-prefs__state\">" + (enrolled ? "Subscribed" : "Not subscribed") + "</span>" +
|
|
2966
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">" + (enrolled ? "Unsubscribe" : "Subscribe") + "</button>" +
|
|
2967
|
+
"</form>";
|
|
2968
|
+
}
|
|
2969
|
+
var digestBlock = digestRows
|
|
2970
|
+
? "<h3 class=\"wishlist-prefs__subhead\">Periodic wishlist digest</h3>" + digestRows
|
|
2971
|
+
: "";
|
|
2972
|
+
|
|
2973
|
+
return "<section class=\"wishlist-prefs-panel\" aria-labelledby=\"wishlist-prefs-heading\">" +
|
|
2974
|
+
"<h2 id=\"wishlist-prefs-heading\" class=\"wishlist-prefs-panel__title\">Wishlist notifications</h2>" +
|
|
2975
|
+
"<p class=\"wishlist-prefs-panel__lede\">Choose how you'd like to hear about your saved items. We'll only email you if your store has email delivery configured.</p>" +
|
|
2976
|
+
notice +
|
|
2977
|
+
alertsBlock +
|
|
2978
|
+
digestBlock +
|
|
2979
|
+
"</section>";
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2844
2982
|
// Public, no-auth shared-wishlist page (`GET /wishlist/shared/:token`). It
|
|
2845
2983
|
// renders ONLY the shared product cards (title, image, link to each PDP) —
|
|
2846
2984
|
// the owner's identity, the per-entry private notes, and the owner customer
|
|
@@ -3545,32 +3683,59 @@ function _addrField(name, labelText, value, opts) {
|
|
|
3545
3683
|
// The `*` is a color-only visual cue; pair it with a visually-hidden
|
|
3546
3684
|
// "(required)" so a screen reader announces the field's requiredness.
|
|
3547
3685
|
var req = opts.required ? " <span class=\"form-field__req\" aria-hidden=\"true\">*</span><span class=\"sr-only\">(required)</span>" : "";
|
|
3686
|
+
// On a validation re-render, the rejected field carries aria-invalid +
|
|
3687
|
+
// aria-describedby pointing at an adjacent error span (WCAG 3.3.1/3.3.3).
|
|
3688
|
+
// The id is a static prefix + field name (attacker-uncontrolled) but is
|
|
3689
|
+
// still escaped for defense-in-depth; the reason is operator-validator
|
|
3690
|
+
// prose, escaped via esc().
|
|
3691
|
+
var errSpan = "";
|
|
3692
|
+
if (opts.invalid) {
|
|
3693
|
+
attrs += " aria-invalid=\"true\" aria-describedby=\"" + esc(opts.error_id) + "\"";
|
|
3694
|
+
errSpan = "<span class=\"form-field__error\" id=\"" + esc(opts.error_id) +
|
|
3695
|
+
"\" role=\"alert\">" + esc(opts.error_msg == null ? "" : String(opts.error_msg)) + "</span>";
|
|
3696
|
+
}
|
|
3548
3697
|
return "<label class=\"form-field\">" +
|
|
3549
3698
|
"<span class=\"form-field__label\">" + esc(labelText) + req + "</span>" +
|
|
3550
3699
|
"<input type=\"text\" name=\"" + esc(name) + "\" value=\"" + esc(value == null ? "" : String(value)) + "\"" + attrs + ">" +
|
|
3700
|
+
errSpan +
|
|
3551
3701
|
"</label>";
|
|
3552
3702
|
}
|
|
3553
3703
|
|
|
3554
3704
|
// Shared add/edit address form. `addr` pre-fills for edit (null = add).
|
|
3555
|
-
|
|
3705
|
+
// `invalidField` (optional) is a { field, message } picked off the
|
|
3706
|
+
// backend-thrown validator error so the one rejected input renders with
|
|
3707
|
+
// aria-invalid + a per-field error span (WCAG 3.3.1/3.3.3). When null, every
|
|
3708
|
+
// field renders byte-identically to the no-error path.
|
|
3709
|
+
function _addressForm(action, addr, submitLabel, invalidField) {
|
|
3556
3710
|
var esc = b.template.escapeHtml;
|
|
3557
3711
|
addr = addr || {};
|
|
3558
3712
|
function _checked(v) { return Number(v) === 1 ? " checked" : ""; }
|
|
3713
|
+
// Merge the per-field invalid marker into a field's opts when it is the one
|
|
3714
|
+
// the validator rejected. The id is a static "addr-err-<name>" so it is
|
|
3715
|
+
// attacker-uncontrolled.
|
|
3716
|
+
function _mark(name, opts) {
|
|
3717
|
+
if (invalidField && invalidField.field === name) {
|
|
3718
|
+
opts.invalid = true;
|
|
3719
|
+
opts.error_id = "addr-err-" + name;
|
|
3720
|
+
opts.error_msg = invalidField.message;
|
|
3721
|
+
}
|
|
3722
|
+
return opts;
|
|
3723
|
+
}
|
|
3559
3724
|
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" }) +
|
|
3725
|
+
_addrField("recipient_name", "Recipient name", addr.recipient_name, _mark("recipient_name", { required: true, maxlength: 120, autocomplete: "name" })) +
|
|
3726
|
+
_addrField("label", "Label (e.g. Home, Work)", addr.label, _mark("label", { maxlength: 60 })) +
|
|
3727
|
+
_addrField("company", "Company", addr.company, _mark("company", { maxlength: 120, autocomplete: "organization" })) +
|
|
3728
|
+
_addrField("street_line1", "Street address", addr.street_line1, _mark("street_line1", { required: true, maxlength: 200, autocomplete: "address-line1" })) +
|
|
3729
|
+
_addrField("street_line2", "Apt / suite / unit", addr.street_line2, _mark("street_line2", { maxlength: 200, autocomplete: "address-line2" })) +
|
|
3565
3730
|
"<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" }) +
|
|
3731
|
+
_addrField("city", "City", addr.city, _mark("city", { required: true, maxlength: 120, autocomplete: "address-level2" })) +
|
|
3732
|
+
_addrField("region", "State / region", addr.region, _mark("region", { maxlength: 120, autocomplete: "address-level1" })) +
|
|
3568
3733
|
"</div>" +
|
|
3569
3734
|
"<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" }) +
|
|
3735
|
+
_addrField("postal_code", "Postal code", addr.postal_code, _mark("postal_code", { required: true, maxlength: 32, autocomplete: "postal-code" })) +
|
|
3736
|
+
_addrField("country", "Country (ISO 3166-1)", addr.country || "US", _mark("country", { required: true, maxlength: 2, pattern: "[A-Za-z]{2}", autocomplete: "country" })) +
|
|
3572
3737
|
"</div>" +
|
|
3573
|
-
_addrField("phone", "Phone", addr.phone, { maxlength: 40, autocomplete: "tel" }) +
|
|
3738
|
+
_addrField("phone", "Phone", addr.phone, _mark("phone", { maxlength: 40, autocomplete: "tel" })) +
|
|
3574
3739
|
"<label class=\"address-form__check\"><input type=\"checkbox\" name=\"is_default_shipping\" value=\"1\"" + _checked(addr.is_default_shipping) + "> Default shipping address</label>" +
|
|
3575
3740
|
"<label class=\"address-form__check\"><input type=\"checkbox\" name=\"is_default_billing\" value=\"1\"" + _checked(addr.is_default_billing) + "> Default billing address</label>" +
|
|
3576
3741
|
"<button type=\"submit\" class=\"btn-primary\">" + esc(submitLabel) + "</button>" +
|
|
@@ -3642,7 +3807,7 @@ function renderAddresses(opts) {
|
|
|
3642
3807
|
listHtml +
|
|
3643
3808
|
"<h2 class=\"account-addresses__form-title\">" + esc(formHeading) + "</h2>" +
|
|
3644
3809
|
notice +
|
|
3645
|
-
_addressForm(formAction, editing, editing ? "Save changes" : "Add address") +
|
|
3810
|
+
_addressForm(formAction, editing, editing ? "Save changes" : "Add address", opts.invalid_field || null) +
|
|
3646
3811
|
"</section>";
|
|
3647
3812
|
return _wrap({
|
|
3648
3813
|
title: "Addresses",
|
|
@@ -4615,6 +4780,13 @@ function renderSupportNew(opts) {
|
|
|
4615
4780
|
var notice = opts.notice
|
|
4616
4781
|
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
4617
4782
|
: "";
|
|
4783
|
+
// On a validation re-render, mark the one rejected field with aria-invalid +
|
|
4784
|
+
// an adjacent error span (WCAG 3.3.1/3.3.3) via the shared field helpers.
|
|
4785
|
+
// `opts.invalid_field` is the { field, message } the route picked off the
|
|
4786
|
+
// backend-thrown error; ids are static "support-err-<name>".
|
|
4787
|
+
var inv = opts.invalid_field || null;
|
|
4788
|
+
function _supAria(name) { return _fieldAriaAttr("support-err-", name, inv); }
|
|
4789
|
+
function _supErr(name) { return _fieldErrorSpan("support-err-", name, inv); }
|
|
4618
4790
|
var body =
|
|
4619
4791
|
"<section class=\"return-form-page\">" +
|
|
4620
4792
|
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
@@ -4626,14 +4798,14 @@ function renderSupportNew(opts) {
|
|
|
4626
4798
|
notice +
|
|
4627
4799
|
"<form class=\"return-form form-stack\" method=\"post\" action=\"/account/support\">" +
|
|
4628
4800
|
"<label class=\"form-field\"><span class=\"form-field__label\">Email for our reply</span>" +
|
|
4629
|
-
"<input type=\"email\" name=\"customer_email\" value=\"" + esc(v.customer_email == null ? "" : String(v.customer_email)) + "\" required autocomplete=\"email\" maxlength=\"254\"
|
|
4801
|
+
"<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>" +
|
|
4630
4802
|
"<label class=\"form-field\"><span class=\"form-field__label\">Category</span>" +
|
|
4631
|
-
"<select name=\"category\" required>" + categoryOpts + "</select
|
|
4803
|
+
"<select name=\"category\" required" + _supAria("category") + ">" + categoryOpts + "</select>" + _supErr("category") + "</label>" +
|
|
4632
4804
|
orderField +
|
|
4633
4805
|
"<label class=\"form-field\"><span class=\"form-field__label\">Subject</span>" +
|
|
4634
|
-
"<input type=\"text\" name=\"subject\" value=\"" + esc(v.subject == null ? "" : String(v.subject)) + "\" required maxlength=\"200\"
|
|
4806
|
+
"<input type=\"text\" name=\"subject\" value=\"" + esc(v.subject == null ? "" : String(v.subject)) + "\" required maxlength=\"200\"" + _supAria("subject") + ">" + _supErr("subject") + "</label>" +
|
|
4635
4807
|
"<label class=\"form-field\"><span class=\"form-field__label\">How can we help?</span>" +
|
|
4636
|
-
"<textarea name=\"body\" required maxlength=\"8000\" rows=\"6\">" + esc(v.body == null ? "" : String(v.body)) + "</textarea
|
|
4808
|
+
"<textarea name=\"body\" required maxlength=\"8000\" rows=\"6\"" + _supAria("body") + ">" + esc(v.body == null ? "" : String(v.body)) + "</textarea>" + _supErr("body") + "</label>" +
|
|
4637
4809
|
"<button type=\"submit\" class=\"btn-primary\">Send</button>" +
|
|
4638
4810
|
"</form>" +
|
|
4639
4811
|
"</section>";
|
|
@@ -8511,6 +8683,7 @@ var ACCOUNT_LOGIN_PAGE =
|
|
|
8511
8683
|
" <p id=\"login-message\" class=\"auth-form__message\"></p>\n" +
|
|
8512
8684
|
" </form>\n" +
|
|
8513
8685
|
" RAW_LOGIN_OAUTH\n" +
|
|
8686
|
+
" RAW_LOGIN_MAGIC\n" +
|
|
8514
8687
|
" <p class=\"auth-card__alt\">New here? <a href=\"/account/register\">Create an account →</a></p>\n" +
|
|
8515
8688
|
" </div>\n" +
|
|
8516
8689
|
" RAW_LOGIN_SCRIPT\n" +
|
|
@@ -8519,6 +8692,7 @@ var ACCOUNT_LOGIN_PAGE =
|
|
|
8519
8692
|
var LOGIN_ERROR_MESSAGES = {
|
|
8520
8693
|
oauth: "We couldn't complete that sign-in. Please try again.",
|
|
8521
8694
|
"email-conflict": "That email already has an account — sign in with your passkey instead.",
|
|
8695
|
+
link: "That sign-in link is invalid or has expired. Request a fresh one.",
|
|
8522
8696
|
};
|
|
8523
8697
|
|
|
8524
8698
|
function renderAccountLogin(opts) {
|
|
@@ -8539,8 +8713,12 @@ function renderAccountLogin(opts) {
|
|
|
8539
8713
|
var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
|
|
8540
8714
|
? "<p class=\"auth-form__message auth-form__message--err\">" + b.template.escapeHtml(LOGIN_ERROR_MESSAGES[opts.error]) + "</p>"
|
|
8541
8715
|
: "";
|
|
8716
|
+
var magicHtml = opts.magic_link_enabled
|
|
8717
|
+
? "<p class=\"auth-card__alt\"><a href=\"/account/login/link\">Email me a sign-in link instead →</a></p>"
|
|
8718
|
+
: "";
|
|
8542
8719
|
var body = ACCOUNT_LOGIN_PAGE
|
|
8543
8720
|
.replace("RAW_LOGIN_OAUTH", oauthHtml)
|
|
8721
|
+
.replace("RAW_LOGIN_MAGIC", magicHtml)
|
|
8544
8722
|
.replace("RAW_LOGIN_ERROR", errHtml)
|
|
8545
8723
|
// Login captcha is gated separately (CAPTCHA_GATE_LOGIN): the widget
|
|
8546
8724
|
// renders only when the operator has a provider active AND opted login
|
|
@@ -8557,6 +8735,50 @@ function renderAccountLogin(opts) {
|
|
|
8557
8735
|
});
|
|
8558
8736
|
}
|
|
8559
8737
|
|
|
8738
|
+
// Magic-link sign-in — a passwordless entry for shoppers without a
|
|
8739
|
+
// passkey or a social login. The page is a single email field that POSTs
|
|
8740
|
+
// to /account/login/link. The response is always the same enumeration-
|
|
8741
|
+
// safe confirmation ("if an account exists, we've emailed a link")
|
|
8742
|
+
// regardless of whether the address resolves — no account-existence
|
|
8743
|
+
// oracle. Server-rendered, no client JS.
|
|
8744
|
+
var ACCOUNT_MAGIC_LINK_PAGE =
|
|
8745
|
+
"<section class=\"auth-page\">\n" +
|
|
8746
|
+
" <div class=\"auth-card\">\n" +
|
|
8747
|
+
" <p class=\"eyebrow\">Sign in by email</p>\n" +
|
|
8748
|
+
" <h1 class=\"auth-card__title\">Email me a sign-in link</h1>\n" +
|
|
8749
|
+
" <p class=\"auth-card__lede\">No passkey or social login? Enter your email and we'll send a single-use link that signs you in.</p>\n" +
|
|
8750
|
+
" RAW_MAGIC_LINK_NOTICE\n" +
|
|
8751
|
+
" <form method=\"post\" action=\"/account/login/link\" class=\"form-stack auth-form\">\n" +
|
|
8752
|
+
" <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span><input type=\"email\" name=\"email\" required autocomplete=\"email\" autofocus></label></div>\n" +
|
|
8753
|
+
" <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary auth-form__submit\">Email me a link</button></div>\n" +
|
|
8754
|
+
" </form>\n" +
|
|
8755
|
+
" <p class=\"auth-card__alt\"><a href=\"/account/login\">← Back to sign in</a></p>\n" +
|
|
8756
|
+
" </div>\n" +
|
|
8757
|
+
"</section>\n";
|
|
8758
|
+
|
|
8759
|
+
function renderMagicLinkPage(opts) {
|
|
8760
|
+
opts = opts || {};
|
|
8761
|
+
var esc = b.template.escapeHtml;
|
|
8762
|
+
// The post-submit confirmation (sent=1) is the enumeration-safe message
|
|
8763
|
+
// — identical whether or not the address matched an account. The
|
|
8764
|
+
// unconfigured-mailer case carries its own honest notice.
|
|
8765
|
+
var noticeHtml = "";
|
|
8766
|
+
if (opts.sent) {
|
|
8767
|
+
noticeHtml = "<p class=\"form-notice form-notice--ok\" role=\"status\">If an account exists for that email, we've sent a sign-in link. Check your inbox.</p>";
|
|
8768
|
+
} else if (opts.unavailable) {
|
|
8769
|
+
noticeHtml = "<p class=\"auth-form__message auth-form__message--err\">Email sign-in isn't available on this store. Use a passkey or social login instead.</p>";
|
|
8770
|
+
}
|
|
8771
|
+
var body = ACCOUNT_MAGIC_LINK_PAGE.replace("RAW_MAGIC_LINK_NOTICE", noticeHtml);
|
|
8772
|
+
void esc;
|
|
8773
|
+
return _wrap({
|
|
8774
|
+
title: "Email sign-in",
|
|
8775
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
8776
|
+
cart_count: opts.cart_count,
|
|
8777
|
+
theme_css: opts.theme_css,
|
|
8778
|
+
body: body,
|
|
8779
|
+
});
|
|
8780
|
+
}
|
|
8781
|
+
|
|
8560
8782
|
var ACCOUNT_REGISTER_PAGE =
|
|
8561
8783
|
"<section class=\"auth-page\">\n" +
|
|
8562
8784
|
" <div class=\"auth-card\">\n" +
|
|
@@ -12535,6 +12757,7 @@ function mount(router, deps) {
|
|
|
12535
12757
|
cart_count: cartCount,
|
|
12536
12758
|
google_enabled: !!deps.oauthGoogle,
|
|
12537
12759
|
apple_enabled: !!deps.oauthApple,
|
|
12760
|
+
magic_link_enabled: !!(deps.customerPortal && deps.customerPortalEmail),
|
|
12538
12761
|
error: url && url.searchParams.get("error"),
|
|
12539
12762
|
captcha_kind: captchaLoginOn ? captchaKind : null,
|
|
12540
12763
|
captcha_public_key: captchaLoginOn ? captchaPubKey : null,
|
|
@@ -12554,6 +12777,105 @@ function mount(router, deps) {
|
|
|
12554
12777
|
}));
|
|
12555
12778
|
});
|
|
12556
12779
|
|
|
12780
|
+
// ---- magic-link sign-in (passwordless email entry) ------------------
|
|
12781
|
+
//
|
|
12782
|
+
// A minimal passwordless login for shoppers without a passkey or a
|
|
12783
|
+
// social login. Composes the customer-portal primitive: createSession
|
|
12784
|
+
// mints a single-use, hashed-at-rest token; the link is emailed; the
|
|
12785
|
+
// GET redemption verifies (single-use) and sets the sealed shop_auth
|
|
12786
|
+
// cookie. The whole surface mounts only when BOTH the portal primitive
|
|
12787
|
+
// AND a transactional mailer are wired — absent either, /account/login/
|
|
12788
|
+
// link renders an "unavailable" state and passkey / OAuth are unchanged.
|
|
12789
|
+
if (deps.customerPortal && deps.customerPortalEmail) {
|
|
12790
|
+
// GET — the email-entry form.
|
|
12791
|
+
router.get("/account/login/link", async function (req, res) {
|
|
12792
|
+
var cartCount = await _cartCountForReq(req);
|
|
12793
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
12794
|
+
_send(res, 200, renderMagicLinkPage({
|
|
12795
|
+
shop_name: shopName,
|
|
12796
|
+
cart_count: cartCount,
|
|
12797
|
+
sent: url ? url.searchParams.get("sent") === "1" : false,
|
|
12798
|
+
}));
|
|
12799
|
+
});
|
|
12800
|
+
|
|
12801
|
+
// POST — resolve the email to a customer, mint a portal session,
|
|
12802
|
+
// email the link. The response is ALWAYS the same enumeration-safe
|
|
12803
|
+
// confirmation (sent=1) regardless of whether the address matched —
|
|
12804
|
+
// no account-existence oracle. A bad-shaped email re-renders the
|
|
12805
|
+
// form. Every send is best-effort: a mailer failure still shows the
|
|
12806
|
+
// generic confirmation (the link simply doesn't arrive).
|
|
12807
|
+
router.post("/account/login/link", async function (req, res) {
|
|
12808
|
+
var body = req.body || {};
|
|
12809
|
+
var emailRaw = typeof body.email === "string" ? body.email : "";
|
|
12810
|
+
var customerId = null;
|
|
12811
|
+
// Resolve the customer by email hash. A malformed address (hashEmail
|
|
12812
|
+
// throws on bad shape) or a no-match both fall through to the
|
|
12813
|
+
// generic confirmation — no oracle.
|
|
12814
|
+
try {
|
|
12815
|
+
var hash = deps.customers.hashEmail(emailRaw);
|
|
12816
|
+
var cust = await deps.customers.byEmailHash(hash);
|
|
12817
|
+
if (cust && cust.id) customerId = cust.id;
|
|
12818
|
+
} catch (_e) { customerId = null; }
|
|
12819
|
+
|
|
12820
|
+
if (customerId) {
|
|
12821
|
+
try {
|
|
12822
|
+
var minted = await deps.customerPortal.createSession({
|
|
12823
|
+
customer_id: customerId,
|
|
12824
|
+
scope: "full",
|
|
12825
|
+
});
|
|
12826
|
+
// Build the absolute redemption link from this request's origin.
|
|
12827
|
+
var origin = "";
|
|
12828
|
+
try { origin = new URL(_requestUrls(req).canonical_url).origin; }
|
|
12829
|
+
catch (_e2) { origin = ""; }
|
|
12830
|
+
var linkUrl = origin + "/account/portal/" + encodeURIComponent(minted.plaintext_token);
|
|
12831
|
+
// The customer's plaintext address: the portal flow needs a
|
|
12832
|
+
// deliverable address. The customers store keeps only the hash,
|
|
12833
|
+
// so reuse the submitted address (the customer just typed it);
|
|
12834
|
+
// the email handle validates the address shape.
|
|
12835
|
+
await deps.customerPortalEmail.sendMagicLink({
|
|
12836
|
+
customer_email: emailRaw,
|
|
12837
|
+
link_url: linkUrl,
|
|
12838
|
+
});
|
|
12839
|
+
} catch (_e3) { /* drop-silent — generic confirmation regardless */ }
|
|
12840
|
+
}
|
|
12841
|
+
// 303 to the GET with the generic confirmation flag.
|
|
12842
|
+
res.status(303);
|
|
12843
|
+
res.setHeader && res.setHeader("location", "/account/login/link?sent=1");
|
|
12844
|
+
return res.end ? res.end() : res.send("");
|
|
12845
|
+
});
|
|
12846
|
+
|
|
12847
|
+
// GET — redeem the magic-link token. verifyToken is single-use (flips
|
|
12848
|
+
// the row to consumed) and re-checks expiry; on success set the sealed
|
|
12849
|
+
// shop_auth cookie + 303 to /account. An unknown / expired / already-
|
|
12850
|
+
// used token bounces to login with a soft error (no oracle on why).
|
|
12851
|
+
router.get("/account/portal/:token", async function (req, res) {
|
|
12852
|
+
var token = (req.params && req.params.token) || "";
|
|
12853
|
+
var rv = null;
|
|
12854
|
+
try { rv = await deps.customerPortal.verifyToken(token); }
|
|
12855
|
+
catch (e) {
|
|
12856
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
12857
|
+
rv = null;
|
|
12858
|
+
}
|
|
12859
|
+
if (!rv || !rv.customer_id) {
|
|
12860
|
+
res.status(303);
|
|
12861
|
+
res.setHeader && res.setHeader("location", "/account/login?error=link");
|
|
12862
|
+
return res.end ? res.end() : res.send("");
|
|
12863
|
+
}
|
|
12864
|
+
// Adopt the guest cart into the now-authenticated account, mirroring
|
|
12865
|
+
// the OAuth / passkey login paths.
|
|
12866
|
+
var sid = _readSidCookie(req);
|
|
12867
|
+
if (sid) {
|
|
12868
|
+
try {
|
|
12869
|
+
var anonCart = await deps.cart.bySession(sid);
|
|
12870
|
+
if (anonCart) await deps.cart.setCustomer(anonCart.id, rv.customer_id);
|
|
12871
|
+
} catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
|
|
12872
|
+
}
|
|
12873
|
+
_setAuthCookie(req, res, { customer_id: rv.customer_id, exp: Date.now() + b.constants.TIME.days(14) });
|
|
12874
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account");
|
|
12875
|
+
return res.end ? res.end() : res.send("");
|
|
12876
|
+
});
|
|
12877
|
+
}
|
|
12878
|
+
|
|
12557
12879
|
router.post("/account/passkey/register-begin", async function (req, res) {
|
|
12558
12880
|
try {
|
|
12559
12881
|
var body = _readJsonBody(req);
|
|
@@ -13663,15 +13985,152 @@ function mount(router, deps) {
|
|
|
13663
13985
|
notice: wlUrl ? wlUrl.searchParams.get("share") : null,
|
|
13664
13986
|
});
|
|
13665
13987
|
}
|
|
13988
|
+
// Alert + digest opt-in panel — only when those primitives are
|
|
13989
|
+
// wired (a mailer-configured deploy). The current per-trigger
|
|
13990
|
+
// opt-out + per-schedule enrollment state drives each toggle's
|
|
13991
|
+
// checked/unchecked render. A reads failure degrades to no panel
|
|
13992
|
+
// rather than 500-ing the page.
|
|
13993
|
+
var prefsPanel = await _buildWishlistPrefsPanel(
|
|
13994
|
+
auth.customer_id,
|
|
13995
|
+
wlUrl ? wlUrl.searchParams.get("prefs") : null,
|
|
13996
|
+
);
|
|
13666
13997
|
_send(res, 200, renderWishlist({
|
|
13667
13998
|
items: items,
|
|
13668
13999
|
notice: wlUrl ? wlUrl.searchParams.get("ok") : null,
|
|
14000
|
+
prefs_panel: prefsPanel,
|
|
13669
14001
|
share_panel: sharePanel,
|
|
13670
14002
|
shop_name: shopName,
|
|
13671
14003
|
cart_count: cartCount,
|
|
13672
14004
|
asset_prefix: deps.asset_prefix || "/assets/",
|
|
13673
14005
|
}));
|
|
13674
14006
|
});
|
|
14007
|
+
|
|
14008
|
+
// Build the alert + digest opt-in panel for a signed-in customer.
|
|
14009
|
+
// Reads the current per-trigger opt-out state (isUnsubscribedFrom
|
|
14010
|
+
// Trigger, now exported) for each scannable alert trigger, and the
|
|
14011
|
+
// active-enrollment state per live digest schedule. Each read is
|
|
14012
|
+
// best-effort: a missing table / read error drops that section
|
|
14013
|
+
// rather than throwing. Returns "" when neither primitive is wired.
|
|
14014
|
+
async function _buildWishlistPrefsPanel(customerId, notice) {
|
|
14015
|
+
var alerts = [];
|
|
14016
|
+
var digests = [];
|
|
14017
|
+
if (deps.wishlistAlerts) {
|
|
14018
|
+
var ALERT_TRIGGERS = [
|
|
14019
|
+
{ trigger: "price_drop", label: "Email me when a saved item drops in price" },
|
|
14020
|
+
{ trigger: "back_in_stock", label: "Email me when a saved item is back in stock" },
|
|
14021
|
+
];
|
|
14022
|
+
for (var i = 0; i < ALERT_TRIGGERS.length; i += 1) {
|
|
14023
|
+
var t = ALERT_TRIGGERS[i];
|
|
14024
|
+
var unsub = true;
|
|
14025
|
+
try { unsub = await deps.wishlistAlerts.isUnsubscribedFromTrigger(customerId, t.trigger); }
|
|
14026
|
+
catch (_e) { unsub = false; }
|
|
14027
|
+
alerts.push({ trigger: t.trigger, label: t.label, subscribed: !unsub });
|
|
14028
|
+
}
|
|
14029
|
+
}
|
|
14030
|
+
if (deps.wishlistDigest) {
|
|
14031
|
+
var schedules = [];
|
|
14032
|
+
try { schedules = await deps.wishlistDigest.listSchedules({ active_only: true }); }
|
|
14033
|
+
catch (_e) { schedules = []; }
|
|
14034
|
+
var enrollments = [];
|
|
14035
|
+
try { enrollments = await deps.wishlistDigest.enrollmentsForCustomer(customerId); }
|
|
14036
|
+
catch (_e) { enrollments = []; }
|
|
14037
|
+
var activeBySlug = {};
|
|
14038
|
+
for (var k = 0; k < enrollments.length; k += 1) {
|
|
14039
|
+
var en = enrollments[k];
|
|
14040
|
+
if (en.status === "active") activeBySlug[en.schedule_slug] = en.id;
|
|
14041
|
+
}
|
|
14042
|
+
for (var s = 0; s < schedules.length; s += 1) {
|
|
14043
|
+
var sch = schedules[s];
|
|
14044
|
+
var label = (sch.frequency === "weekly" ? "Weekly" : "Monthly") +
|
|
14045
|
+
" digest (" + sch.slug + ")";
|
|
14046
|
+
digests.push({
|
|
14047
|
+
slug: sch.slug,
|
|
14048
|
+
label: label,
|
|
14049
|
+
enrolled: Object.prototype.hasOwnProperty.call(activeBySlug, sch.slug),
|
|
14050
|
+
enrollment_id: activeBySlug[sch.slug] || null,
|
|
14051
|
+
});
|
|
14052
|
+
}
|
|
14053
|
+
}
|
|
14054
|
+
return _wishlistPrefsPanel({ alerts: alerts, digests: digests, notice: notice });
|
|
14055
|
+
}
|
|
14056
|
+
|
|
14057
|
+
// POST /account/wishlist/alerts — flip a per-trigger alert opt-out.
|
|
14058
|
+
// Body { trigger, on }: a falsey `on` unsubscribes (insert opt-out
|
|
14059
|
+
// row); a truthy `on` re-subscribes (delete the opt-out row). Gated
|
|
14060
|
+
// on the session customer; CSRF rides the container form chokepoint.
|
|
14061
|
+
if (deps.wishlistAlerts) {
|
|
14062
|
+
router.post("/account/wishlist/alerts", async function (req, res) {
|
|
14063
|
+
var auth;
|
|
14064
|
+
try { auth = _currentCustomer(req); }
|
|
14065
|
+
catch (e) {
|
|
14066
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
14067
|
+
throw e;
|
|
14068
|
+
}
|
|
14069
|
+
if (!auth) {
|
|
14070
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
14071
|
+
return res.end ? res.end() : res.send("");
|
|
14072
|
+
}
|
|
14073
|
+
var body = req.body || {};
|
|
14074
|
+
var trigger = body.trigger;
|
|
14075
|
+
var on = body.on === "1" || body.on === "on" || body.on === true;
|
|
14076
|
+
try {
|
|
14077
|
+
if (on) {
|
|
14078
|
+
await deps.wishlistAlerts.resubscribeToAlertKind({ customer_id: auth.customer_id, trigger: trigger });
|
|
14079
|
+
} else {
|
|
14080
|
+
await deps.wishlistAlerts.unsubscribeFromAlertKind({ customer_id: auth.customer_id, trigger: trigger });
|
|
14081
|
+
}
|
|
14082
|
+
} catch (e) {
|
|
14083
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
14084
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
14085
|
+
}
|
|
14086
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/wishlist?prefs=alerts");
|
|
14087
|
+
return res.end ? res.end() : res.send("");
|
|
14088
|
+
});
|
|
14089
|
+
}
|
|
14090
|
+
|
|
14091
|
+
// POST /account/wishlist/digest — enroll / pause a digest schedule.
|
|
14092
|
+
// Body { schedule_slug, on }: a truthy `on` enrolls; a falsey `on`
|
|
14093
|
+
// pauses the active enrollment for that slug (looked up via
|
|
14094
|
+
// enrollmentsForCustomer). Gated on the session customer.
|
|
14095
|
+
if (deps.wishlistDigest) {
|
|
14096
|
+
router.post("/account/wishlist/digest", async function (req, res) {
|
|
14097
|
+
var auth;
|
|
14098
|
+
try { auth = _currentCustomer(req); }
|
|
14099
|
+
catch (e) {
|
|
14100
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
14101
|
+
throw e;
|
|
14102
|
+
}
|
|
14103
|
+
if (!auth) {
|
|
14104
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
14105
|
+
return res.end ? res.end() : res.send("");
|
|
14106
|
+
}
|
|
14107
|
+
var body = req.body || {};
|
|
14108
|
+
var scheduleSlug = body.schedule_slug;
|
|
14109
|
+
var on = body.on === "1" || body.on === "on" || body.on === true;
|
|
14110
|
+
try {
|
|
14111
|
+
if (on) {
|
|
14112
|
+
await deps.wishlistDigest.enrollCustomer({ customer_id: auth.customer_id, schedule_slug: scheduleSlug });
|
|
14113
|
+
} else {
|
|
14114
|
+
// Find the active enrollment for this slug and pause it.
|
|
14115
|
+
var enrollments = await deps.wishlistDigest.enrollmentsForCustomer(auth.customer_id);
|
|
14116
|
+
var target = null;
|
|
14117
|
+
for (var i = 0; i < enrollments.length; i += 1) {
|
|
14118
|
+
if (enrollments[i].schedule_slug === scheduleSlug && enrollments[i].status === "active") {
|
|
14119
|
+
target = enrollments[i]; break;
|
|
14120
|
+
}
|
|
14121
|
+
}
|
|
14122
|
+
if (target) {
|
|
14123
|
+
await deps.wishlistDigest.pauseEnrollment({ enrollment_id: target.id, reason: "customer opted out" });
|
|
14124
|
+
}
|
|
14125
|
+
}
|
|
14126
|
+
} catch (e) {
|
|
14127
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
14128
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
14129
|
+
}
|
|
14130
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/wishlist?prefs=digest");
|
|
14131
|
+
return res.end ? res.end() : res.send("");
|
|
14132
|
+
});
|
|
14133
|
+
}
|
|
13675
14134
|
}
|
|
13676
14135
|
|
|
13677
14136
|
// ---- wishlist sharing -----------------------------------------------
|
|
@@ -14567,7 +15026,7 @@ function mount(router, deps) {
|
|
|
14567
15026
|
if (kind === "default-billing") return "Default billing address updated.";
|
|
14568
15027
|
return null;
|
|
14569
15028
|
}
|
|
14570
|
-
async function _renderAddrPage(req, res, auth, editAddr, notice, code) {
|
|
15029
|
+
async function _renderAddrPage(req, res, auth, editAddr, notice, code, invalidField) {
|
|
14571
15030
|
var rows = await deps.addresses.listForCustomer(auth.customer_id, { limit: 50 });
|
|
14572
15031
|
var cartCount = await _cartCountForReq(req);
|
|
14573
15032
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
@@ -14577,15 +15036,24 @@ function mount(router, deps) {
|
|
|
14577
15036
|
// when the ?undo=<id> marker round-trips a real owned address id.
|
|
14578
15037
|
var undoId = (okKind === "removed" && url) ? url.searchParams.get("undo") : null;
|
|
14579
15038
|
_send(res, code || 200, renderAddresses({
|
|
14580
|
-
addresses:
|
|
14581
|
-
edit:
|
|
14582
|
-
notice:
|
|
14583
|
-
success:
|
|
14584
|
-
undo_id:
|
|
14585
|
-
|
|
14586
|
-
|
|
15039
|
+
addresses: rows,
|
|
15040
|
+
edit: editAddr || null,
|
|
15041
|
+
notice: notice || null,
|
|
15042
|
+
success: success,
|
|
15043
|
+
undo_id: undoId || null,
|
|
15044
|
+
invalid_field: invalidField || null,
|
|
15045
|
+
shop_name: shopName,
|
|
15046
|
+
cart_count: cartCount,
|
|
14587
15047
|
}));
|
|
14588
15048
|
}
|
|
15049
|
+
// The address form's renderable fields, by `name`. An error on a
|
|
15050
|
+
// non-form internal (customer_id, address_id) returns null from the
|
|
15051
|
+
// shared extractor, so the page-top banner still shows but no input is
|
|
15052
|
+
// falsely marked.
|
|
15053
|
+
var _ADDR_FORM_FIELDS = {
|
|
15054
|
+
recipient_name: 1, label: 1, company: 1, street_line1: 1, street_line2: 1,
|
|
15055
|
+
city: 1, region: 1, postal_code: 1, country: 1, phone: 1,
|
|
15056
|
+
};
|
|
14589
15057
|
function _addrInput(body, customerId) {
|
|
14590
15058
|
return {
|
|
14591
15059
|
customer_id: customerId,
|
|
@@ -14622,7 +15090,10 @@ function mount(router, deps) {
|
|
|
14622
15090
|
try {
|
|
14623
15091
|
await deps.addresses.add(_addrInput(req.body || {}, auth.customer_id));
|
|
14624
15092
|
} catch (e) {
|
|
14625
|
-
if (e instanceof TypeError)
|
|
15093
|
+
if (e instanceof TypeError) {
|
|
15094
|
+
var inv = _fieldFromValidatorError(e, "addresses", _ADDR_FORM_FIELDS);
|
|
15095
|
+
return _renderAddrPage(req, res, auth, null, (e && e.message) || "Please check the address.", 400, inv);
|
|
15096
|
+
}
|
|
14626
15097
|
throw e;
|
|
14627
15098
|
}
|
|
14628
15099
|
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses?ok=added");
|
|
@@ -14637,7 +15108,8 @@ function mount(router, deps) {
|
|
|
14637
15108
|
} catch (e) {
|
|
14638
15109
|
if (e instanceof TypeError) {
|
|
14639
15110
|
var merged = Object.assign({}, addr, _addrInput(req.body || {}, auth.customer_id));
|
|
14640
|
-
|
|
15111
|
+
var inv = _fieldFromValidatorError(e, "addresses", _ADDR_FORM_FIELDS);
|
|
15112
|
+
return _renderAddrPage(req, res, auth, merged, (e && e.message) || "Please check the address.", 400, inv);
|
|
14641
15113
|
}
|
|
14642
15114
|
throw e;
|
|
14643
15115
|
}
|
|
@@ -15730,11 +16202,12 @@ function mount(router, deps) {
|
|
|
15730
16202
|
} catch (e) {
|
|
15731
16203
|
if (e instanceof TypeError) {
|
|
15732
16204
|
return _send(res, 400, renderSupportNew({
|
|
15733
|
-
orders:
|
|
15734
|
-
values:
|
|
15735
|
-
notice:
|
|
15736
|
-
|
|
15737
|
-
|
|
16205
|
+
orders: await _orders(),
|
|
16206
|
+
values: body,
|
|
16207
|
+
notice: (e && e.message || "").replace(/^supportTickets[.:]\s*/, "") || "Please check your ticket and try again.",
|
|
16208
|
+
invalid_field: _fieldFromValidatorError(e, "supportTickets", { customer_email: 1, category: 1, subject: 1, body: 1 }),
|
|
16209
|
+
shop_name: shopName,
|
|
16210
|
+
cart_count: cartCount,
|
|
15738
16211
|
}));
|
|
15739
16212
|
}
|
|
15740
16213
|
throw e;
|
|
@@ -16418,10 +16891,11 @@ function mount(router, deps) {
|
|
|
16418
16891
|
// anything else is a real 500.
|
|
16419
16892
|
if (e instanceof TypeError) {
|
|
16420
16893
|
return _send(res, 400, renderReviewForm({
|
|
16421
|
-
product:
|
|
16422
|
-
notice:
|
|
16423
|
-
|
|
16424
|
-
|
|
16894
|
+
product: { title: ctx.product.title, slug: ctx.product.slug },
|
|
16895
|
+
notice: (e && e.message) || "Please check your review and try again.",
|
|
16896
|
+
invalid_field: _fieldFromValidatorError(e, "reviews", { rating: 1, title: 1, body: 1 }),
|
|
16897
|
+
shop_name: shopName,
|
|
16898
|
+
cart_count: ctx.cartCount,
|
|
16425
16899
|
}));
|
|
16426
16900
|
}
|
|
16427
16901
|
throw e;
|
|
@@ -16489,10 +16963,11 @@ function mount(router, deps) {
|
|
|
16489
16963
|
// anything else is a real 500.
|
|
16490
16964
|
if (e instanceof TypeError) {
|
|
16491
16965
|
return _send(res, 400, renderQuestionForm({
|
|
16492
|
-
product:
|
|
16493
|
-
notice:
|
|
16494
|
-
|
|
16495
|
-
|
|
16966
|
+
product: { title: ctx.product.title, slug: ctx.product.slug },
|
|
16967
|
+
notice: (e && e.message) || "Please check your question and try again.",
|
|
16968
|
+
invalid_field: _fieldFromValidatorError(e, "productQA", { body: 1 }),
|
|
16969
|
+
shop_name: shopName,
|
|
16970
|
+
cart_count: ctx.cartCount,
|
|
16496
16971
|
}));
|
|
16497
16972
|
}
|
|
16498
16973
|
throw e;
|