@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/CHANGELOG.md +4 -0
- package/README.md +2 -1
- package/lib/admin.js +298 -1
- package/lib/asset-manifest.json +3 -3
- package/lib/auto-discount.js +104 -2
- package/lib/cart.js +104 -0
- package/lib/checkout.js +6 -2
- package/lib/storefront.js +382 -14
- package/package.json +1 -1
package/lib/storefront.js
CHANGED
|
@@ -2083,6 +2083,55 @@ function _pdpShippingNote(availability) {
|
|
|
2083
2083
|
"See our <a href=\"/terms\">shipping & 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
|
|
7135
|
-
// render nothing so a digital
|
|
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
|
-
//
|
|
7151
|
-
//
|
|
7152
|
-
//
|
|
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
|
|
7155
|
-
var latestHtml = latest
|
|
7156
|
-
? "<p class=\"order-shipment__event\">" +
|
|
7157
|
-
esc(String(latest.status)) +
|
|
7158
|
-
(latest.location ? " · " + 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
|
-
|
|
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