@blamejs/blamejs-shop 0.3.53 → 0.3.55

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