@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/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
- var body = _render(REVIEW_FORM_PAGE, {
2523
- title: opts.product.title,
2524
- slug: slug,
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\" required></textarea>\n" +
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 &amp; 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
- function _addressForm(action, addr, submitLabel) {
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\"></label>" +
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></label>" +
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\"></label>" +
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></label>" +
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: rows,
14581
- edit: editAddr || null,
14582
- notice: notice || null,
14583
- success: success,
14584
- undo_id: undoId || null,
14585
- shop_name: shopName,
14586
- cart_count: cartCount,
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) return _renderAddrPage(req, res, auth, null, (e && e.message) || "Please check the address.", 400);
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
- return _renderAddrPage(req, res, auth, merged, (e && e.message) || "Please check the address.", 400);
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: await _orders(),
15734
- values: body,
15735
- notice: (e && e.message || "").replace(/^supportTickets[.:]\s*/, "") || "Please check your ticket and try again.",
15736
- shop_name: shopName,
15737
- cart_count: cartCount,
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: { title: ctx.product.title, slug: ctx.product.slug },
16422
- notice: (e && e.message) || "Please check your review and try again.",
16423
- shop_name: shopName,
16424
- cart_count: ctx.cartCount,
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: { title: ctx.product.title, slug: ctx.product.slug },
16493
- notice: (e && e.message) || "Please check your question and try again.",
16494
- shop_name: shopName,
16495
- cart_count: ctx.cartCount,
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;