@blamejs/blamejs-shop 0.3.67 → 0.3.69

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
@@ -2083,6 +2083,55 @@ function _pdpShippingNote(availability) {
2083
2083
  "See our <a href=\"/terms\">shipping &amp; returns policy</a>.</p>";
2084
2084
  }
2085
2085
 
2086
+ // "Get it by <date>" delivery-estimate line for the PDP + cart. Renders the
2087
+ // SHARED markup the dual-render parity gate pins — kept byte-for-byte in sync
2088
+ // with worker/render/product.js#_buildDeliveryEstimate (and the cart twin) so
2089
+ // the PDP/cart land identical across substrates.
2090
+ //
2091
+ // THE DESIGN RULE: the edge ALWAYS passes estimate=null. The arrival date is
2092
+ // destination-specific (it's derived from the signed-in customer's shipping
2093
+ // address) and edge pages are shared-cacheable across anonymous visitors, so
2094
+ // baking a date would serve one visitor's ZIP date to everyone — and a
2095
+ // computed date also goes stale in the cache. So estimate=null renders EMPTY
2096
+ // (the builder returns ""), keeping the edge output byte-stable; the real date
2097
+ // renders container-only when the route resolves a per-customer estimate.
2098
+ //
2099
+ // `estimate` (when present) is the resolved display shape the route builds:
2100
+ // { deliver_by, latest_by?, service_label }
2101
+ // where deliver_by / latest_by are YYYY-MM-DD strings (origin-timezone) and
2102
+ // service_label is the carrier service the date is for. The date is formatted
2103
+ // here with a self-contained deterministic formatter (no toLocaleDateString —
2104
+ // its output is locale/runtime-dependent and would break the byte-parity gate)
2105
+ // so the same YYYY-MM-DD always yields the same "Thu, Jun 12" wherever it runs.
2106
+ function _formatDeliveryDate(ymd) {
2107
+ // ymd is YYYY-MM-DD; format as "Thu, Jun 12" deterministically. Date.UTC
2108
+ // gives a runtime-stable weekday (Sun=0..Sat=6) with no timezone drift.
2109
+ var DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
2110
+ var MON = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
2111
+ var y = Number(ymd.slice(0, 4));
2112
+ var m = Number(ymd.slice(5, 7));
2113
+ var d = Number(ymd.slice(8, 10));
2114
+ var wd = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
2115
+ return DOW[wd] + ", " + MON[m - 1] + " " + d;
2116
+ }
2117
+ function _buildDeliveryEstimate(estimate, esc) {
2118
+ if (!estimate || typeof estimate !== "object" || !estimate.deliver_by) return "";
2119
+ var by = _formatDeliveryDate(estimate.deliver_by);
2120
+ // A range when the slowest service lands later than the fastest — "by Thu,
2121
+ // Jun 12" becomes "Tue, Jun 10 – Thu, Jun 12" so the shopper sees the window,
2122
+ // not just the optimistic edge.
2123
+ var dateText = (estimate.latest_by && estimate.latest_by !== estimate.deliver_by)
2124
+ ? _formatDeliveryDate(estimate.deliver_by) + " – " + _formatDeliveryDate(estimate.latest_by)
2125
+ : "by " + by;
2126
+ var svc = estimate.service_label
2127
+ ? " <span class=\"delivery-est__svc\">via " + esc(estimate.service_label) + "</span>"
2128
+ : "";
2129
+ return "<p class=\"delivery-est\" role=\"status\">" +
2130
+ "<span class=\"delivery-est__icon\" aria-hidden=\"true\">🚚</span> " +
2131
+ "<span class=\"delivery-est__label\">Get it " + esc(dateText) + "</span>" + svc +
2132
+ "</p>";
2133
+ }
2134
+
2086
2135
  // Pre-order CTA — replaces the add-to-cart buy box on a PDP whose lead SKU
2087
2136
  // has an OPEN pre-order campaign (a SKU that isn't released yet, so it's not
2088
2137
  // normally purchasable). `preorder` is the resolved shape the route loads:
@@ -2271,6 +2320,7 @@ var PRODUCT_PAGE =
2271
2320
  " RAW_AVAILABILITY_PLACEHOLDER\n" +
2272
2321
  " RAW_BUYBOX_PLACEHOLDER\n" +
2273
2322
  " RAW_SHIPPING_NOTE_PLACEHOLDER\n" +
2323
+ " RAW_DELIVERYESTIMATE_PLACEHOLDER\n" +
2274
2324
  " RAW_QTYBREAK_PLACEHOLDER\n" +
2275
2325
  " RAW_WISHLIST_PLACEHOLDER\n" +
2276
2326
  " RAW_COMPARE_PLACEHOLDER\n" +
@@ -6229,6 +6279,13 @@ function renderProduct(opts) {
6229
6279
  buyboxHtml = _buildPreorderNotice(opts.preorder_notice) + buyboxHtml;
6230
6280
  var availabilityHtml = _buildAvailability(availability);
6231
6281
  var shippingNoteHtml = _pdpShippingNote(availability);
6282
+ // "Get it by <date>" — container-only (the route resolves a per-customer
6283
+ // estimate; the edge passes no `delivery_estimate`, so this renders empty
6284
+ // and the edge PDP stays byte-stable). Suppressed on a digital-only product
6285
+ // (nothing ships) and on a pre-order CTA (the release date is the headline).
6286
+ var deliveryEstimateHtml = (availability.requires_shipping && !preorderShape)
6287
+ ? _buildDeliveryEstimate(opts.delivery_estimate, b.template.escapeHtml)
6288
+ : "";
6232
6289
  var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
6233
6290
  var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
6234
6291
  var qaHtml = _buildProductQa(opts.qa_questions, opts.qa_cta);
@@ -6244,6 +6301,7 @@ function renderProduct(opts) {
6244
6301
  .replace("RAW_AVAILABILITY_PLACEHOLDER", availabilityHtml)
6245
6302
  .replace("RAW_BUYBOX_PLACEHOLDER", buyboxHtml)
6246
6303
  .replace("RAW_SHIPPING_NOTE_PLACEHOLDER", shippingNoteHtml)
6304
+ .replace("RAW_DELIVERYESTIMATE_PLACEHOLDER", deliveryEstimateHtml)
6247
6305
  .replace("RAW_QTYBREAK_PLACEHOLDER", qtyBreaksHtml)
6248
6306
  .replace("RAW_WISHLIST_PLACEHOLDER", wishlistHtml)
6249
6307
  .replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
@@ -7128,11 +7186,51 @@ function _orderTimelineBlock(status) {
7128
7186
  "<ol class=\"order-timeline__steps\">" + steps + "</ol></div>";
7129
7187
  }
7130
7188
 
7189
+ // Cap on how many carrier events the per-shipment timeline renders. A
7190
+ // long-haul international parcel can accumulate dozens of scans; the
7191
+ // customer-facing panel shows the most recent MAX_SHIPMENT_TIMELINE
7192
+ // (newest-first) so the page stays bounded regardless of stream length.
7193
+ // getShipment hydrates the FULL event list (it has no LIMIT of its own),
7194
+ // so the bound is applied here at the render sink.
7195
+ var MAX_SHIPMENT_TIMELINE = 20;
7196
+
7197
+ // Render one shipment's carrier-event timeline, newest-first. Events
7198
+ // arrive oldest-first from getShipment (occurred_at ASC, recorded_at ASC),
7199
+ // so the list is reversed, then capped at MAX_SHIPMENT_TIMELINE. Each row
7200
+ // shows the status, an optional carrier location, an optional operator-
7201
+ // recorded detail note, and the event time. status / location / detail are
7202
+ // carrier- or operator-supplied free text → escaped at the sink. Returns ""
7203
+ // for a shipment with no events so the panel falls back to the carrier line
7204
+ // alone (exactly what the pre-timeline block rendered).
7205
+ function _shipmentTimeline(events) {
7206
+ if (!Array.isArray(events) || !events.length) return "";
7207
+ var esc = b.template.escapeHtml;
7208
+ // Newest-first: copy + reverse (never mutate the hydrated row's array),
7209
+ // then bound the render.
7210
+ var ordered = events.slice().reverse().slice(0, MAX_SHIPMENT_TIMELINE);
7211
+ var rows = ordered.map(function (ev) {
7212
+ var when = ev && ev.occurred_at != null
7213
+ ? new Date(Number(ev.occurred_at)).toISOString().slice(0, 16).replace("T", " ")
7214
+ : "";
7215
+ var loc = ev && ev.location ? String(ev.location) : "";
7216
+ var detail = ev && ev.detail ? String(ev.detail) : "";
7217
+ return "<li class=\"order-shipment__event\">" +
7218
+ "<span class=\"order-shipment__event-status\">" + esc(String(ev && ev.status)) + "</span>" +
7219
+ (loc ? " <span class=\"order-shipment__event-loc\">" + esc(loc) + "</span>" : "") +
7220
+ (detail ? " <span class=\"order-shipment__event-detail\">" + esc(detail) + "</span>" : "") +
7221
+ (when ? " <time class=\"order-shipment__event-when\" datetime=\"" + esc(when) + "\">" + esc(when) + "</time>" : "") +
7222
+ "</li>";
7223
+ }).join("");
7224
+ return "<ol class=\"order-shipment__timeline\">" + rows + "</ol>";
7225
+ }
7226
+
7131
7227
  // Render the shipment + carrier-tracking panel from order-tracking's
7132
7228
  // listForOrder() rows. Each shipment shows its carrier, status, the
7133
7229
  // tracking number (linked to the carrier's public tracking URL when one
7134
- // is known), and the most-recent carrier event. Empty/absent shipments
7135
- // render nothing so a digital or not-yet-shipped order shows no panel.
7230
+ // is known), and the FULL carrier-event timeline newest-first (bounded by
7231
+ // MAX_SHIPMENT_TIMELINE). Empty/absent shipments render nothing so a digital
7232
+ // or not-yet-shipped order shows no panel; a shipment with no events yet
7233
+ // shows just the carrier + status line (the pre-timeline shape).
7136
7234
  function _orderTrackingBlock(shipments) {
7137
7235
  if (!Array.isArray(shipments) || !shipments.length) return "";
7138
7236
  var esc = b.template.escapeHtml;
@@ -7147,24 +7245,20 @@ function _orderTrackingBlock(shipments) {
7147
7245
  "\" rel=\"noopener nofollow\" target=\"_blank\">" + esc(String(s.tracking_number)) + " ↗</a>"
7148
7246
  : "<span class=\"order-shipment__track\">" + esc(String(s.tracking_number)) + "</span>";
7149
7247
  }
7150
- // Latest carrier event (events arrive oldest-first from listForOrder's
7151
- // getShipment ordering; the panel's per-shipment events array, when
7152
- // hydrated, is the same order take the last).
7248
+ // Full carrier-event timeline (newest-first, bounded). getShipment
7249
+ // hydrates the per-shipment events array oldest-first; the timeline
7250
+ // builder reverses + caps it. An eventless shipment renders no timeline,
7251
+ // so the card is the carrier + status line alone — identical to the
7252
+ // block before the timeline shipped.
7153
7253
  var events = Array.isArray(s.events) ? s.events : [];
7154
- var latest = events.length ? events[events.length - 1] : null;
7155
- var latestHtml = latest
7156
- ? "<p class=\"order-shipment__event\">" +
7157
- esc(String(latest.status)) +
7158
- (latest.location ? " &middot; " + esc(String(latest.location)) : "") +
7159
- "</p>"
7160
- : "";
7254
+ var timelineHtml = _shipmentTimeline(events);
7161
7255
  return "<li class=\"order-shipment\">" +
7162
7256
  "<div class=\"order-shipment__head\">" +
7163
7257
  "<span class=\"order-shipment__carrier\">" + esc(String(carrier)) + "</span>" +
7164
7258
  "<span class=\"pdp__badge\">" + esc(String(s.status)) + "</span>" +
7165
7259
  "</div>" +
7166
7260
  (trackingHtml ? "<p class=\"order-shipment__tracking\">Tracking: " + trackingHtml + "</p>" : "") +
7167
- latestHtml +
7261
+ timelineHtml +
7168
7262
  "</li>";
7169
7263
  }).join("");
7170
7264
  return "<div class=\"order-tracking-panel\">" +
@@ -7567,6 +7661,7 @@ var CART_PAGE =
7567
7661
  " <dl class=\"totals-list\">\n" +
7568
7662
  "RAW_TOTALS_ROWS" +
7569
7663
  " </dl>\n" +
7664
+ "RAW_CART_DELIVERYESTIMATE_PLACEHOLDER" +
7570
7665
  "RAW_CHECKOUT_CTA" +
7571
7666
  " </aside>\n" +
7572
7667
  " </div>\n" +
@@ -7724,6 +7819,62 @@ function _cartGiftBlock(opts) {
7724
7819
  "</section>";
7725
7820
  }
7726
7821
 
7822
+ // The cart-page coupon-code entry (CONTAINER-ONLY — like the gift block, it
7823
+ // is only reached for a cart WITH lines, which the edge never renders; the
7824
+ // edge cart is the cookie-less empty shell, see [[storefront-dual-render]]).
7825
+ // The form posts to /cart/coupon, which is NOT an EDGE_POST_PATHS prefix, so
7826
+ // _injectCsrfFields tokens it automatically — same posture as /cart/gift
7827
+ // (the closest sibling). It offers a single code input + Apply, lists each
7828
+ // already-applied code with a Remove control, and shows an inline PRG notice
7829
+ // (applied / removed / uniform error). The code echo is operator/shopper
7830
+ // free text → escaped at the sink. Returns "" when the coupon surface isn't
7831
+ // wired (no discount engine), so a store without discounts shows nothing.
7832
+ function _cartCouponBlock(opts) {
7833
+ if (!opts.coupon_enabled) return "";
7834
+ var esc = b.template.escapeHtml;
7835
+ var applied = Array.isArray(opts.applied_codes) ? opts.applied_codes : [];
7836
+ // Inline PRG notice — applied / removed succeed with a confirmation; an
7837
+ // error is the uniform "couldn't apply" (no oracle on why).
7838
+ var notice = "";
7839
+ if (opts.code_applied) {
7840
+ notice = "<p class=\"cart-coupon__notice cart-coupon__notice--ok\" role=\"status\">Discount code applied.</p>";
7841
+ } else if (opts.code_removed) {
7842
+ notice = "<p class=\"cart-coupon__notice cart-coupon__notice--ok\" role=\"status\">Discount code removed.</p>";
7843
+ } else if (opts.code_err) {
7844
+ notice = "<p class=\"cart-coupon__notice cart-coupon__notice--err\" role=\"status\">That code can't be applied to this cart.</p>";
7845
+ }
7846
+ // Already-applied codes, each with a Remove form (the code rides in a
7847
+ // hidden field; the value is escaped). Shopper-typed free text → esc().
7848
+ var appliedHtml = "";
7849
+ if (applied.length) {
7850
+ var items = applied.map(function (r) {
7851
+ var codeStr = String(r.code);
7852
+ return "<li class=\"cart-coupon__applied-item\">" +
7853
+ "<code class=\"cart-coupon__code\">" + esc(codeStr) + "</code>" +
7854
+ "<form method=\"post\" action=\"/cart/coupon/remove\" class=\"cart-coupon__remove\">" +
7855
+ "<input type=\"hidden\" name=\"code\" value=\"" + esc(codeStr) + "\">" +
7856
+ "<button type=\"submit\" class=\"cart-line__btn\">Remove</button>" +
7857
+ "</form>" +
7858
+ "</li>";
7859
+ }).join("");
7860
+ appliedHtml = "<ul class=\"cart-coupon__applied\">" + items + "</ul>";
7861
+ }
7862
+ return "<section class=\"cart-coupon\">" +
7863
+ "<details class=\"cart-coupon__details\"" + (applied.length || opts.code_err ? " open" : "") + ">" +
7864
+ "<summary class=\"cart-coupon__summary\">Have a discount code?</summary>" +
7865
+ notice +
7866
+ appliedHtml +
7867
+ "<form method=\"post\" action=\"/cart/coupon\" class=\"cart-coupon__form\">" +
7868
+ "<label class=\"form-field\"><span>Discount code</span>" +
7869
+ "<input type=\"text\" name=\"code\" autocomplete=\"off\" autocapitalize=\"characters\" " +
7870
+ "spellcheck=\"false\" maxlength=\"64\" placeholder=\"Enter code\">" +
7871
+ "</label>" +
7872
+ "<button type=\"submit\" class=\"btn-secondary\">Apply code</button>" +
7873
+ "</form>" +
7874
+ "</details>" +
7875
+ "</section>";
7876
+ }
7877
+
7727
7878
  function renderCart(opts) {
7728
7879
  if (!opts) throw new TypeError("storefront.renderCart: opts required");
7729
7880
  var lines = opts.lines || [];
@@ -7850,10 +8001,20 @@ function renderCart(opts) {
7850
8001
  " <div><dt>Subtotal</dt><dd>" + subtotal + "</dd></div>\n" +
7851
8002
  " <div class=\"totals-list__grand\"><dt>Total</dt><dd>" + total + "</dd></div>\n";
7852
8003
  }
8004
+ // "Get it by <date>" for the whole cart — the slowest line's window, so
8005
+ // the shopper sees when EVERYTHING arrives, not the optimistic item. The
8006
+ // SAME shared builder the PDP uses (byte-identical across the dual render),
8007
+ // spliced into the summary aside. Container-only: the route resolves a
8008
+ // per-customer estimate (signed-in + saved address); absent that, or on
8009
+ // any primitive failure, opts.delivery_estimate is null and this renders
8010
+ // empty. Indented to match the aside's two-space block.
8011
+ var cartEstimateHtml = _buildDeliveryEstimate(opts.delivery_estimate, b.template.escapeHtml);
8012
+ cartEstimateHtml = cartEstimateHtml ? " " + cartEstimateHtml + "\n" : "";
7853
8013
  body = _render(CART_PAGE, {
7854
8014
  line_rows: "RAW_LINES",
7855
8015
  }).replace("RAW_LINES", rows)
7856
8016
  .replace("RAW_TOTALS_ROWS", totalsRows)
8017
+ .replace("RAW_CART_DELIVERYESTIMATE_PLACEHOLDER", cartEstimateHtml)
7857
8018
  .replace("RAW_CHECKOUT_CTA", checkoutCta)
7858
8019
  .replace("RAW_CART_NOTICE", notice);
7859
8020
  // CONTAINER-ONLY gift wrap disclosure, appended after the cart grid (a
@@ -7863,6 +8024,11 @@ function renderCart(opts) {
7863
8024
  // free text already escaped; appending — not String.replace — so a `$`
7864
8025
  // in a wrap title can't trip dollar substitution).
7865
8026
  body = body + _cartGiftBlock(opts);
8027
+ // CONTAINER-ONLY coupon-code entry, same placement + edge-safety
8028
+ // reasoning as the gift block (reached only for a cart with lines; the
8029
+ // code echo is escaped at the sink; appended, not String.replace'd, so a
8030
+ // `$` in a typed code can't trip dollar substitution).
8031
+ body = body + _cartCouponBlock(opts);
7866
8032
  }
7867
8033
  return _wrap(Object.assign({
7868
8034
  title: "Cart",
@@ -11107,6 +11273,80 @@ function mount(router, deps) {
11107
11273
  return { ship_to: { country: "US" }, from_saved: false };
11108
11274
  }
11109
11275
 
11276
+ // Resolve the "Get it by <date>" delivery estimate for the PDP / cart. This
11277
+ // is a DROP-SILENT defensive reader (the storefront read tier): it returns
11278
+ // null on ANYTHING short of a clean, dated estimate — no deliveryEstimate
11279
+ // dep, no signed-in customer, no saved shipping address with a postal code,
11280
+ // an unconfigured origin/cutoff (the primitive THROWS a config-state
11281
+ // TypeError there), or a no-match `{ ok:false }`. The route renders no
11282
+ // estimate in every one of those cases, never a 500.
11283
+ //
11284
+ // WHY signed-in + saved-address only: the arrival date is destination-
11285
+ // specific. The edge serves a shared cache across anonymous visitors and
11286
+ // must never bake a per-visitor date, so the date is a container-only
11287
+ // enhancement for a customer whose destination we already know (their
11288
+ // default shipping address). v1 ships no anonymous ZIP-entry widget — that's
11289
+ // a separate slice; an anonymous shopper simply sees no date.
11290
+ //
11291
+ // `originLocation` comes from operator config (`shop.estimate_origin`); the
11292
+ // primitive REFUSES to guess an origin (no defaultLocation resolver is
11293
+ // wired), so without that config row no estimate renders — by design.
11294
+ // `weightGrams` (optional) lets the primitive's weight-aware rows apply.
11295
+ //
11296
+ // Returns the display shape the dual-render builder consumes:
11297
+ // { deliver_by, latest_by, service_label }
11298
+ // or null.
11299
+ async function _resolveDeliveryEstimate(req, opts) {
11300
+ opts = opts || {};
11301
+ if (!deps.deliveryEstimate) return null;
11302
+ try {
11303
+ // Per-customer destination: only a SIGNED-IN customer with a saved
11304
+ // shipping address that carries a postal code yields a date. _normalize
11305
+ // (inside _estimateDestination) drops a garbage postal, so a saved
11306
+ // address with no usable postal falls through to null here.
11307
+ var coAuth = _currentCustomerEnv(req);
11308
+ if (!coAuth) return null;
11309
+ var dest = await _estimateDestination(req);
11310
+ if (!dest || !dest.from_saved || !dest.ship_to || !dest.ship_to.postal) return null;
11311
+ // Operator-configured origin — the primitive won't guess one. Resolved
11312
+ // at boot into `deps.delivery_estimate_origin` (a plain slug string) from
11313
+ // SHOP_ESTIMATE_ORIGIN / the config primitive, since the storefront's
11314
+ // sfDeps.config is a bare { shop_name } stub with no live .get(). Falls
11315
+ // back to a per-request config read if a future caller wires a real
11316
+ // config handle. Absent any origin, no date renders — by design.
11317
+ var origin = (typeof deps.delivery_estimate_origin === "string" && deps.delivery_estimate_origin)
11318
+ ? deps.delivery_estimate_origin
11319
+ : null;
11320
+ if (!origin && deps.config && typeof deps.config.get === "function") {
11321
+ try { origin = await deps.config.get("shop.estimate_origin", null); }
11322
+ catch (_e) { origin = null; }
11323
+ }
11324
+ if (typeof origin !== "string" || !origin) return null;
11325
+ var est = await deps.deliveryEstimate.estimate({
11326
+ origin_location: origin,
11327
+ destination_postal: dest.ship_to.postal,
11328
+ destination_country: dest.ship_to.country,
11329
+ destination_region: dest.ship_to.state ? dest.ship_to.state.toLowerCase() : undefined,
11330
+ weight_grams: Number.isInteger(opts.weight_grams) ? opts.weight_grams : undefined,
11331
+ });
11332
+ if (!est || !est.ok || !est.est_min_delivery_date) return null;
11333
+ // The fastest service is the headline date (service_levels are sorted
11334
+ // ascending by transit_days inside the primitive); the slowest gives the
11335
+ // range upper bound so the line reads as a window, not a promise.
11336
+ var fastest = est.service_levels && est.service_levels.length ? est.service_levels[0] : null;
11337
+ return {
11338
+ deliver_by: est.est_min_delivery_date,
11339
+ latest_by: est.est_max_delivery_date || est.est_min_delivery_date,
11340
+ service_label: fastest ? fastest.label : null,
11341
+ };
11342
+ } catch (_e) {
11343
+ // Drop-silent — every config-state / validation throw the primitive
11344
+ // raises (no cutoff row, origin_location required, a malformed saved
11345
+ // postal) collapses to "no estimate", never a broken page.
11346
+ return null;
11347
+ }
11348
+ }
11349
+
11110
11350
  // Compute the cart/checkout totals the shopper sees BEFORE paying.
11111
11351
  // Composes the SAME tax + shipping primitives the charge runs through
11112
11352
  // (via checkout.quote, which prices tax against the pre-discount
@@ -11131,6 +11371,18 @@ function mount(router, deps) {
11131
11371
  // subtotal-only breakdown with tax/shipping flagged unresolved — the
11132
11372
  // subtotal is always honest, and the renderer labels the rest
11133
11373
  // "calculated at checkout" rather than fabricating a number.
11374
+ // The discount codes a cart carries, as plain strings, for threading into
11375
+ // checkout.confirm / checkout.quote. Drop-silent: an unmigrated
11376
+ // cart_discount_codes table / un-wired method → [] (no codes, no
11377
+ // code-gated discount), never a throw on the buy path.
11378
+ async function _cartAppliedCodeStrings(cartId) {
11379
+ if (typeof deps.cart.listDiscountCodes !== "function") return [];
11380
+ try {
11381
+ var rows = await deps.cart.listDiscountCodes(cartId);
11382
+ return rows.map(function (r) { return r.code; });
11383
+ } catch (_e) { return []; }
11384
+ }
11385
+
11134
11386
  async function _estimateCartTotals(req, c, lines, opts) {
11135
11387
  opts = opts || {};
11136
11388
  var base = pricing.totals(c, lines, {}); // subtotal-only, always valid
@@ -11156,6 +11408,10 @@ function mount(router, deps) {
11156
11408
  var quote = await deps.checkout.quote({
11157
11409
  cart_id: c.id,
11158
11410
  ship_to: dest.ship_to,
11411
+ // Shopper-applied coupon codes so the estimate reflects a code-
11412
+ // gated discount (the same codes checkout.confirm honours). Absent
11413
+ // / empty → only pure-automatic rules, byte-identical to before.
11414
+ codes: Array.isArray(opts.codes) && opts.codes.length ? opts.codes : undefined,
11159
11415
  });
11160
11416
  var taxMinor = quote.totals.tax_minor;
11161
11417
  result.tax_resolved = quote.tax_rate_bps > 0 ||
@@ -11593,10 +11849,17 @@ function mount(router, deps) {
11593
11849
  // to a banner; only meaningful when a campaign is present.
11594
11850
  var pdpUrl = req.url ? new URL(req.url, "http://localhost") : null;
11595
11851
  var preorderNotice = pdpUrl ? pdpUrl.searchParams.get("preorder") : null;
11852
+ // "Get it by <date>" — container-only, per-customer (see
11853
+ // _resolveDeliveryEstimate). Weighted by the lead variant so the
11854
+ // primitive's weight-aware transit rows apply. Drop-silent → null.
11855
+ var leadWeight = firstVariant && Number.isInteger(firstVariant.weight_grams)
11856
+ ? firstVariant.weight_grams : undefined;
11857
+ var deliveryEstimate = await _resolveDeliveryEstimate(req, { weight_grams: leadWeight });
11596
11858
  var html = renderProduct(Object.assign({
11597
11859
  product: product,
11598
11860
  variants: variants,
11599
11861
  prices: prices,
11862
+ delivery_estimate: deliveryEstimate,
11600
11863
  preorder_campaign: preorderCampaign,
11601
11864
  preorder_notice: preorderNotice,
11602
11865
  media: media,
@@ -11983,6 +12246,17 @@ function mount(router, deps) {
11983
12246
  var cartUrl = req.url ? new URL(req.url, "http://localhost") : null;
11984
12247
  var added = (req.query && req.query.added === "1") ||
11985
12248
  (cartUrl && cartUrl.searchParams.get("added") === "1") || false;
12249
+ // Coupon-entry PRG outcomes (set by POST /cart/coupon[/remove]). One of
12250
+ // applied / removed / err so the cart shows an inline notice. `?code_err`
12251
+ // carries no detail beyond "couldn't apply" — a uniform message, no
12252
+ // code-existence oracle.
12253
+ function _cartQp(name) {
12254
+ return (req.query && req.query[name] === "1") ||
12255
+ (cartUrl && cartUrl.searchParams.get(name) === "1") || false;
12256
+ }
12257
+ var codeApplied = _cartQp("code_applied");
12258
+ var codeRemoved = _cartQp("code_removed");
12259
+ var codeErr = _cartQp("code_err");
11986
12260
  if (!sid) {
11987
12261
  return _send(res, 200, renderCart(Object.assign({
11988
12262
  lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
@@ -12003,12 +12277,23 @@ function mount(router, deps) {
12003
12277
  // Recomputed every render (idempotent); the stored snapshot is never
12004
12278
  // mutated, so changing a line's quantity re-prices it automatically.
12005
12279
  var lines = await _repriceCartLines(rawLines);
12280
+ // Applied coupon codes — the strings the shopper entered on the cart
12281
+ // page, persisted on the cart. Threaded into the totals estimate so a
12282
+ // code-gated discount shows in the breakdown, and echoed in the coupon
12283
+ // block (with a remove control). Drop-silent: an unmigrated
12284
+ // cart_discount_codes table → no applied codes, no coupon discount.
12285
+ var appliedCodes = [];
12286
+ if (typeof deps.cart.listDiscountCodes === "function") {
12287
+ try { appliedCodes = await deps.cart.listDiscountCodes(c.id); }
12288
+ catch (_e) { appliedCodes = []; }
12289
+ }
12290
+ var appliedCodeStrings = appliedCodes.map(function (r) { return r.code; });
12006
12291
  // Real total before pay: compose the same tax + shipping primitives the
12007
12292
  // charge runs through (estimated against the shopper's saved/default
12008
12293
  // destination until they confirm an address at checkout). Falls back to
12009
12294
  // a subtotal-only breakdown — with tax/shipping labelled "calculated at
12010
12295
  // checkout" — when checkout isn't wired or no zone matches.
12011
- var totalsDetail = await _estimateCartTotals(req, c, lines, {});
12296
+ var totalsDetail = await _estimateCartTotals(req, c, lines, { codes: appliedCodeStrings });
12012
12297
  var totals = totalsDetail.totals;
12013
12298
  // Truthful per-line stock state (out / low / ok) so the cart never
12014
12299
  // implies a sold-out line is buyable.
@@ -12050,10 +12335,17 @@ function mount(router, deps) {
12050
12335
  }
12051
12336
  } catch (_e) { giftWraps = []; giftWrapInCart = null; }
12052
12337
  }
12338
+ // "Get it by <date>" for the whole cart — container-only, per-customer
12339
+ // (see _resolveDeliveryEstimate). No weight is summed across lines (the
12340
+ // cart estimate is the destination window, not a per-parcel weight quote);
12341
+ // the primitive falls back to its weight-agnostic transit rows. Drop-
12342
+ // silent → null, and the summary renders no estimate.
12343
+ var cartEstimate = await _resolveDeliveryEstimate(req, {});
12053
12344
  _send(res, 200, renderCart(Object.assign({
12054
12345
  lines: lines,
12055
12346
  totals: totals,
12056
12347
  totals_detail: totalsDetail,
12348
+ delivery_estimate: cartEstimate,
12057
12349
  line_stock: lineStock,
12058
12350
  product_lookup: productLookup,
12059
12351
  can_save: !!(deps.saveForLater && deps.customers),
@@ -12061,6 +12353,15 @@ function mount(router, deps) {
12061
12353
  gift_wraps: giftWraps,
12062
12354
  gift_wrap_in_cart: giftWrapInCart,
12063
12355
  added: added,
12356
+ // Coupon entry: surfaced only when the discount engine is wired (the
12357
+ // POST routes mount on the same condition). `applied_codes` echoes the
12358
+ // typed codes so each gets a remove control; the *_notice flags drive
12359
+ // the inline PRG banner.
12360
+ coupon_enabled: !!(deps.autoDiscount && typeof deps.cart.listDiscountCodes === "function"),
12361
+ applied_codes: appliedCodes,
12362
+ code_applied: codeApplied,
12363
+ code_removed: codeRemoved,
12364
+ code_err: codeErr,
12064
12365
  shop_name: shopName,
12065
12366
  theme: theme,
12066
12367
  }, ccy)));
@@ -12433,12 +12734,16 @@ function mount(router, deps) {
12433
12734
  } else {
12434
12735
  defaultShipId = deps.default_shipping_id;
12435
12736
  }
12737
+ var coCodes = await _cartAppliedCodeStrings(c.id);
12436
12738
  var result = await deps.checkout.confirm({
12437
12739
  cart_id: c.id,
12438
12740
  ship_to: shipTo,
12439
12741
  selected_shipping_id: defaultShipId || "std",
12440
12742
  customer: { email: body.email, name: body.name },
12441
12743
  gift_card_code: body.gift_card_code || undefined,
12744
+ // Shopper-applied coupon codes — honoured at charge time so the
12745
+ // order total matches the cart-page estimate.
12746
+ codes: coCodes.length ? coCodes : undefined,
12442
12747
  loyalty_redeem_points: _parseRedeemPoints(body.loyalty_redeem_points),
12443
12748
  idempotency_key: "checkout:" + c.id + ":" + b.uuid.v7(),
12444
12749
  });
@@ -12583,12 +12888,14 @@ function mount(router, deps) {
12583
12888
  try {
12584
12889
  var defaultShipId = typeof deps.default_shipping_id === "function"
12585
12890
  ? await deps.default_shipping_id() : deps.default_shipping_id;
12891
+ var ppCodes = await _cartAppliedCodeStrings(c.id);
12586
12892
  var created = await deps.checkout.createPaypalOrder({
12587
12893
  cart_id: c.id,
12588
12894
  ship_to: shipTo,
12589
12895
  selected_shipping_id: body.selected_shipping_id || defaultShipId || "std",
12590
12896
  customer: { email: body.email, name: body.name },
12591
12897
  gift_card_code: body.gift_card_code || undefined,
12898
+ codes: ppCodes.length ? ppCodes : undefined,
12592
12899
  idempotency_key: "paypal:" + c.id + ":" + b.uuid.v7(),
12593
12900
  return_url: body.return_url || undefined,
12594
12901
  cancel_url: body.cancel_url || undefined,
@@ -17557,6 +17864,67 @@ function mount(router, deps) {
17557
17864
  });
17558
17865
  }
17559
17866
 
17867
+ // POST /cart/coupon — apply a shopper-typed discount code to the cart.
17868
+ // The code is validated server-side against the discount engine
17869
+ // (autoDiscount.ruleForCode): it must resolve to an active, code-gated
17870
+ // rule. An accepted code is persisted on the cart (cart.addDiscountCode)
17871
+ // so the cart totals re-render with the rule's savings and
17872
+ // checkout.confirm honours it. An unknown / malformed / un-applicable
17873
+ // code bounces back with ?code_err=1 and a UNIFORM message — no oracle on
17874
+ // whether the code exists. PRG: always a 303 to /cart. CSRF: /cart/coupon
17875
+ // is NOT an EDGE_POST_PATHS prefix, so _injectCsrfFields tokens the form +
17876
+ // the csrf gate checks it (same posture as /cart/gift). Mounts only when
17877
+ // the discount engine is wired.
17878
+ if (deps.autoDiscount && typeof deps.cart.addDiscountCode === "function") {
17879
+ router.post("/cart/coupon", async function (req, res) {
17880
+ var body = req.body || {};
17881
+ var typed = typeof body.code === "string" ? body.code.trim() : "";
17882
+ var dest = "/cart?code_err=1";
17883
+ try {
17884
+ var resolved = await _getOrCreateCart(req, res, "USD");
17885
+ var cartId = resolved.cart.id;
17886
+ // ruleForCode returns null for anything that isn't an active, code-
17887
+ // gated rule (including a malformed code) — one uniform "no" answer.
17888
+ var rule = typed ? await deps.autoDiscount.ruleForCode(typed) : null;
17889
+ if (rule && rule.unlock_code) {
17890
+ // Persist the code as the operator authored it (canonical casing),
17891
+ // not as the shopper typed it, so the applied chip + the engine
17892
+ // lookup agree.
17893
+ await deps.cart.addDiscountCode(cartId, rule.unlock_code, rule.slug);
17894
+ dest = "/cart?code_applied=1";
17895
+ }
17896
+ } catch (e) {
17897
+ // A malformed code (TypeError from addDiscountCode's validator) or a
17898
+ // bad cart shape is the same uniform error — never a 500 on the cart.
17899
+ if (!(e instanceof TypeError)) throw e;
17900
+ dest = "/cart?code_err=1";
17901
+ }
17902
+ res.status(303);
17903
+ res.setHeader && res.setHeader("location", dest);
17904
+ return res.end ? res.end() : res.send("");
17905
+ });
17906
+
17907
+ // POST /cart/coupon/remove — drop one applied code. Idempotent: removing
17908
+ // a code that isn't on the cart still lands on the removed notice (no
17909
+ // oracle). Same CSRF posture as /cart/coupon.
17910
+ router.post("/cart/coupon/remove", async function (req, res) {
17911
+ var body = req.body || {};
17912
+ var typed = typeof body.code === "string" ? body.code.trim() : "";
17913
+ var dest = "/cart?code_removed=1";
17914
+ try {
17915
+ var resolved = await _getOrCreateCart(req, res, "USD");
17916
+ if (typed) await deps.cart.removeDiscountCode(resolved.cart.id, typed);
17917
+ } catch (e) {
17918
+ if (!(e instanceof TypeError)) throw e;
17919
+ // A malformed code can't be on the cart anyway — treat as removed.
17920
+ dest = "/cart?code_removed=1";
17921
+ }
17922
+ res.status(303);
17923
+ res.setHeader && res.setHeader("location", dest);
17924
+ return res.end ? res.end() : res.send("");
17925
+ });
17926
+ }
17927
+
17560
17928
  // POST /cart/bundle — add every member of a bundle to the cart at the
17561
17929
  // bundle price, atomically. Reads `bundle_sku` from the form body.
17562
17930
  // The price is recomputed server-side from the catalog + the bundle
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.67",
3
+ "version": "0.3.69",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {