@blamejs/blamejs-shop 0.3.67 → 0.3.68

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.68 (2026-06-05) — **Delivery estimates: "Get it by" dates on the product and cart pages.** The product page and cart can now promise a delivery date. Operators configure the inputs at /admin/delivery-estimates — carrier transit times, warehouse cutoff hours, shipping holidays, and postal-prefix zones — and name the origin warehouse via the SHOP_ESTIMATE_ORIGIN environment variable or the shop.estimate_origin config row. A signed-in customer with a saved shipping address then sees "Get it by <date>" computed from the cutoff clock, transit days, and the holiday calendar. Anonymous visitors served from the edge cache deliberately see no date: an estimate is destination-specific, and a date baked into a shared cached page would show one visitor's delivery promise to everyone while going stale in the cache. The product and cart markup builders are byte-identical across the edge and container renderers with a deterministic date formatter, locked by a parity test. A store that has not configured estimates renders exactly what it does today — nothing — and a configuration gap can never produce a customer-facing error. **Added:** *Configurable delivery-date estimates* — The delivery-estimate primitive is wired end to end: an /admin/delivery-estimates console manages carrier transit times, order-cutoff hours per warehouse, shipping holidays, and postal-prefix zone mappings, with each mutation audited; the product and cart pages render "Get it by <date>" for signed-in customers with a saved shipping address once an origin warehouse is configured. Estimates render only where the destination is actually known — never on shared edge-cached pages — and every estimate read is fail-quiet: missing configuration, an unmatched destination, or a computation error renders no estimate rather than an error. Operators enable it by setting the origin (SHOP_ESTIMATE_ORIGIN or the shop.estimate_origin config row) and authoring at least one cutoff, transit time, and postal zone.
12
+
11
13
  - v0.3.67 (2026-06-05) — **Digital orders no longer require a shipping address, and search ranking shrugs off malformed weights.** A cart containing only digital goods completes checkout since 0.3.63, but the shipping form still demanded a street address and city the order would never use. For an all-digital cart, the form now marks the address fields optional with an honest note — email stays required for the receipt and country for tax — and the backend enforces exactly the same relaxed set, while any value the shopper does supply is still format-validated. Carts with any shippable item keep the full requirements. Separately, the search ranker now skips a non-numeric weight in a hand-edited weight set instead of letting it poison every score into NaN and garble the result order; the edge and container rankers apply the identical filter, keeping their orders locked together even on malformed configuration. **Fixed:** *Digital-only carts check out without a street address* — When no line in the cart requires shipping, the checkout form renders the address block as optional — the required markers move off street and city, a note explains that a digital order ships nothing and country is kept for tax, and the backend requires exactly email, name, and country. Values the shopper supplies anyway are still format-validated, including the per-field error rendering. A cart with any physical item keeps the full address requirements, and a missing variant record counts as physical, so the relaxation can never trigger on incomplete data. · *Search ranking ignores non-numeric weights instead of corrupting the order* — A weight set carrying a non-numeric value — possible through a hand-edited database row — multiplied into every product's score as NaN, making the result order effectively random on the container while the edge filtered the bad weight and produced a different order. Both rankers now skip non-numeric weights identically, so a malformed entry degrades to "that signal contributes nothing" with edge and container orders staying byte-identical.
12
14
 
13
15
  - v0.3.66 (2026-06-05) — **Production server errors are readable from the admin console and the CLI.** When a server-side failure was scrubbed to a clean customer-facing page — a payment-processor rejection at checkout, a public-API failure, an admin-action error — the underlying detail was only visible in the hosting dashboard's container log viewer. The error-log primitive now captures those failures into the database: route, method, status, a length-bounded message, and a timestamp, ring-buffered to the newest five hundred entries, written fire-and-forget so a logging failure can never affect a customer request, and carrying no customer or session data. A new Errors screen in the admin console lists them newest-first, and the same route answers a bearer-token request with JSON, so operators and tooling can read production errors with one curl. A schema migration adds the message column and applies on deploy. **Added:** *Server-error capture with an admin Errors screen and JSON API* — Scrubbed 500-class failures — the checkout confirm step, the public catalog API, and admin console actions — now also record route, method, status, and a truncated error message into the error-log table (newest five hundred kept; writes are drop-silent and never awaited in the request path; no customer, session, or header data is stored). GET /admin/errors renders the newest entries in the console with every cell escaped, and the same path with an Authorization: Bearer admin token returns JSON for command-line and tooling access. Migration 0208 adds the message column plus an index and applies with the normal deploy.
package/README.md CHANGED
@@ -66,6 +66,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
66
66
  | **`lib/pricing.js`** | Pure-function money math — `lineTotal`, `subtotal`, `totals`, `format`. Multi-currency refused, banker's-style rounding, locale-aware via `Intl.NumberFormat`. |
67
67
  | **`lib/tax.js`** | Operator-table adapter. Country / state / postal_prefix → rate_bps. Most-specific-first match, banker's rounding. Pluggable adapter shape for future Stripe Tax / TaxJar / Avalara. |
68
68
  | **`lib/shipping.js`** | Operator-table adapter. Services with zones (flat or per-gram + base + min/max), free-over-threshold, `digital_only` flag. |
69
+ | **`lib/delivery-estimate.js`** | "Get it by <date>" promises. Operators define carrier transit times, warehouse cutoffs, holidays, and postal-prefix zones at `/admin/delivery-estimates`; the product and cart pages then show a signed-in customer a delivery date computed against their saved shipping address and the configured origin (`SHOP_ESTIMATE_ORIGIN` or the `shop.estimate_origin` config row). Anonymous, edge-cached pages deliberately render no date — estimates are destination-specific and never bake into the shared cache. Unconfigured stores render nothing, never an error. |
69
70
  | **`lib/payment.js`** | Payment adapters — **Stripe** (verify webhook HMAC-SHA256 via upstream `b.webhook.verify`, create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains for Apple/Google Pay) and **PayPal** (`adapter: "paypal"` — OAuth2 client-credentials token, create / capture / get / refund Orders v2, webhook verify via PayPal's verify-webhook-signature API). No `stripe` / `paypal` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
70
71
  | **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
71
72
  | **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` validates the ship-to address (real ISO 3166-1 country; US/CA state + ZIP/postal formats; lenient elsewhere) and the customer email shape, then creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
@@ -96,7 +97,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
96
97
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
97
98
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
98
99
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
99
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
100
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
100
101
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
101
102
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
102
103
 
package/lib/admin.js CHANGED
@@ -573,7 +573,7 @@ function mount(router, deps) {
573
573
  // `reports` is always present in the nav (read-only sales summary needs no
574
574
  // extra dep); its route mounts unconditionally and renders an unconfigured
575
575
  // notice when the salesReports primitive isn't wired.
576
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog };
576
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog };
577
577
 
578
578
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
579
579
 
@@ -8910,6 +8910,170 @@ function mount(router, deps) {
8910
8910
  ));
8911
8911
  }
8912
8912
 
8913
+ // ---- delivery estimates ---------------------------------------------
8914
+ //
8915
+ // The "Get it by <date>" tables on one console screen: carrier transits
8916
+ // (per from→to zone / carrier / service-level transit-day budget), origin
8917
+ // cutoffs (the daily local-time ship-by per origin location + IANA tz),
8918
+ // observed holidays (per region, dropped from the business-day count), and
8919
+ // postal-prefix → zone mappings (destination lookup). Each is create +
8920
+ // list + archive (transits + holidays archive by uuid; cutoffs + postal
8921
+ // zones are upserts with no archive — re-author to change). The only free-
8922
+ // text field is a holiday's `name`, escaped at render. Every date field
8923
+ // validates against the primitive's DATE_RE (it re-validates server-side,
8924
+ // so a bad date is a 400 notice, never a 500); carrier / service-level /
8925
+ // zone / postal-prefix are enum/regex-bounded by the primitive.
8926
+ var deliveryEstimate = deps.deliveryEstimate || null;
8927
+ if (deliveryEstimate) {
8928
+ // Re-read every table for the screen in one helper so create / archive
8929
+ // re-renders all share the same shape. Drop-silent per table: an
8930
+ // unmigrated table degrades to an empty list, never a 500.
8931
+ async function _deliveryEstimateModel() {
8932
+ var model = { transits: [], cutoffs: [], holidays: [], postal_zones: [] };
8933
+ try { model.transits = await deliveryEstimate.listTransits({}); } catch (_e) { model.transits = []; }
8934
+ try { model.cutoffs = await deliveryEstimate.listCutoffs(); } catch (_e) { model.cutoffs = []; }
8935
+ try { model.holidays = await deliveryEstimate.listHolidays({}); } catch (_e) { model.holidays = []; }
8936
+ try { model.postal_zones = await deliveryEstimate.listPostalZones({}); } catch (_e) { model.postal_zones = []; }
8937
+ return model;
8938
+ }
8939
+
8940
+ // One handler factory per create surface — each defines a row, then on
8941
+ // the HTML path re-renders the screen with a 400 notice on a TypeError
8942
+ // (the primitive's own validation throws carry operator-safe text) and
8943
+ // routes everything else through the shared classifier so a raw
8944
+ // constraint / unknown error can't reach the banner.
8945
+ function _deCreate(kind, auditAction, define, transform) {
8946
+ return _pageOrApi(false,
8947
+ W(auditAction, async function (req, res) {
8948
+ var row = await define(transform(req.body || {}));
8949
+ _json(res, 201, row);
8950
+ return { id: (row && (row.id || row.origin_location || row.postal_prefix)) || kind };
8951
+ }),
8952
+ async function (req, res) {
8953
+ try {
8954
+ await define(transform(req.body || {}));
8955
+ } catch (e) {
8956
+ var n = _safeNotice(e, auditAction);
8957
+ var noticeMsg = n.message.replace(/^deliveryEstimate[.:]\s*/, "");
8958
+ var model = await _deliveryEstimateModel();
8959
+ return _sendHtml(res, n.status, renderAdminDeliveryEstimates(Object.assign({
8960
+ shop_name: deps.shop_name, nav_available: navAvailable, notice: noticeMsg,
8961
+ }, model)));
8962
+ }
8963
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditAction, outcome: "success" });
8964
+ _redirect(res, "/admin/delivery-estimates?created=" + kind);
8965
+ },
8966
+ );
8967
+ }
8968
+
8969
+ router.post("/admin/delivery-estimates/transits", _deCreate(
8970
+ "transit", "delivery_transit.create",
8971
+ function (input) { return deliveryEstimate.defineCarrierTransit(input); },
8972
+ function (body) {
8973
+ return {
8974
+ from_zone: typeof body.from_zone === "string" ? body.from_zone.trim() : body.from_zone,
8975
+ to_zone: typeof body.to_zone === "string" ? body.to_zone.trim() : body.to_zone,
8976
+ carrier: typeof body.carrier === "string" ? body.carrier.trim() : body.carrier,
8977
+ service_level: typeof body.service_level === "string" ? body.service_level.trim() : body.service_level,
8978
+ transit_days: _strictMinorInt(body.transit_days, "deliveryEstimate", "transit_days"),
8979
+ };
8980
+ },
8981
+ ));
8982
+
8983
+ router.post("/admin/delivery-estimates/cutoffs", _deCreate(
8984
+ "cutoff", "delivery_cutoff.create",
8985
+ function (input) { return deliveryEstimate.defineCutoff(input); },
8986
+ function (body) {
8987
+ return {
8988
+ origin_location: typeof body.origin_location === "string" ? body.origin_location.trim() : body.origin_location,
8989
+ daily_cutoff_local_time: typeof body.daily_cutoff_local_time === "string" ? body.daily_cutoff_local_time.trim() : body.daily_cutoff_local_time,
8990
+ timezone: typeof body.timezone === "string" ? body.timezone.trim() : body.timezone,
8991
+ };
8992
+ },
8993
+ ));
8994
+
8995
+ router.post("/admin/delivery-estimates/holidays", _deCreate(
8996
+ "holiday", "delivery_holiday.create",
8997
+ function (input) { return deliveryEstimate.defineHoliday(input); },
8998
+ function (body) {
8999
+ return {
9000
+ region: typeof body.region === "string" ? body.region.trim() : body.region,
9001
+ date: typeof body.date === "string" ? body.date.trim() : body.date,
9002
+ name: body.name,
9003
+ };
9004
+ },
9005
+ ));
9006
+
9007
+ router.post("/admin/delivery-estimates/postal-zones", _deCreate(
9008
+ "postal_zone", "delivery_postal_zone.create",
9009
+ function (input) { return deliveryEstimate.definePostalZone(input); },
9010
+ function (body) {
9011
+ return {
9012
+ country: typeof body.country === "string" ? body.country.trim().toUpperCase() : body.country,
9013
+ postal_prefix: typeof body.postal_prefix === "string" ? body.postal_prefix.trim() : body.postal_prefix,
9014
+ zone: typeof body.zone === "string" ? body.zone.trim() : body.zone,
9015
+ };
9016
+ },
9017
+ ));
9018
+
9019
+ router.get("/admin/delivery-estimates", _pageOrApi(true,
9020
+ R(async function (req, res) {
9021
+ _json(res, 200, await _deliveryEstimateModel());
9022
+ }),
9023
+ async function (req, res) {
9024
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
9025
+ var model = await _deliveryEstimateModel();
9026
+ _sendHtml(res, 200, renderAdminDeliveryEstimates(Object.assign({
9027
+ shop_name: deps.shop_name, nav_available: navAvailable,
9028
+ created: url && url.searchParams.get("created"),
9029
+ archived: url && url.searchParams.get("archived"),
9030
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
9031
+ }, model)));
9032
+ },
9033
+ ));
9034
+
9035
+ // Archive a transit / holiday by its uuid. Both archive surfaces share
9036
+ // the same shape: a TypeError (bad uuid) is a 400; a null result (no
9037
+ // such row) re-renders with a notice; success redirects with ?archived.
9038
+ router.post("/admin/delivery-estimates/transits/:id/archive", _pageOrApi(false,
9039
+ W("delivery_transit.archive", async function (req, res) {
9040
+ var row;
9041
+ try { row = await deliveryEstimate.archiveTransit(req.params.id); }
9042
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
9043
+ if (!row) return _problem(res, 404, "transit-not-found");
9044
+ _json(res, 200, row);
9045
+ return { id: row.id };
9046
+ }),
9047
+ async function (req, res) {
9048
+ var row = null;
9049
+ try { row = await deliveryEstimate.archiveTransit(req.params.id); }
9050
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
9051
+ if (!row) return _redirect(res, "/admin/delivery-estimates?err=1");
9052
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".delivery_transit.archive", outcome: "success", metadata: { id: req.params.id } });
9053
+ _redirect(res, "/admin/delivery-estimates?archived=transit");
9054
+ },
9055
+ ));
9056
+
9057
+ router.post("/admin/delivery-estimates/holidays/:id/archive", _pageOrApi(false,
9058
+ W("delivery_holiday.archive", async function (req, res) {
9059
+ var row;
9060
+ try { row = await deliveryEstimate.archiveHoliday(req.params.id); }
9061
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
9062
+ if (!row) return _problem(res, 404, "holiday-not-found");
9063
+ _json(res, 200, row);
9064
+ return { id: row.id };
9065
+ }),
9066
+ async function (req, res) {
9067
+ var row = null;
9068
+ try { row = await deliveryEstimate.archiveHoliday(req.params.id); }
9069
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
9070
+ if (!row) return _redirect(res, "/admin/delivery-estimates?err=1");
9071
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".delivery_holiday.archive", outcome: "success", metadata: { id: req.params.id } });
9072
+ _redirect(res, "/admin/delivery-estimates?archived=holiday");
9073
+ },
9074
+ ));
9075
+ }
9076
+
8913
9077
  // Translate the create form / JSON body into a defineZone input. The
8914
9078
  // console form is the single-country, single-flat-rate shape; the
8915
9079
  // bearer JSON path can pass the full regions[]/rates[] arrays directly,
@@ -10710,6 +10874,7 @@ var ADMIN_NAV_ITEMS = [
10710
10874
  { key: "tax", href: "/admin/tax-rates", label: "Tax", requires: "taxRates" },
10711
10875
  { key: "tax-filings", href: "/admin/tax-filings", label: "Tax filings", requires: "salesTaxFilings" },
10712
10876
  { key: "shipping", href: "/admin/shipping", label: "Shipping", requires: "shippingZones" },
10877
+ { key: "delivery-estimates", href: "/admin/delivery-estimates", label: "Delivery estimates", requires: "deliveryEstimate" },
10713
10878
  { key: "shipping-labels", href: "/admin/shipping-labels", label: "Shipping labels", requires: "shippingLabels" },
10714
10879
  { key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
10715
10880
  { key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
@@ -14211,6 +14376,138 @@ function renderAdminShippingZone(opts) {
14211
14376
  return _renderAdminShell(opts.shop_name, "Zone " + z.slug, body, "shipping", opts.nav_available);
14212
14377
  }
14213
14378
 
14379
+ // Delivery-estimate console — the four "Get it by <date>" tables (carrier
14380
+ // transits, origin cutoffs, observed holidays, postal-prefix → zone) each
14381
+ // with a list + a create form, transits + holidays carrying an archive
14382
+ // button. The only operator free-text field is a holiday's `name`, escaped
14383
+ // on render. Carrier is a fixed <select> (the primitive's CARRIERS enum);
14384
+ // every other field is regex-bounded server-side, so a bad value is a 400
14385
+ // notice, never a 500.
14386
+ function renderAdminDeliveryEstimates(opts) {
14387
+ opts = opts || {};
14388
+ var transits = opts.transits || [];
14389
+ var cutoffs = opts.cutoffs || [];
14390
+ var holidays = opts.holidays || [];
14391
+ var zones = opts.postal_zones || [];
14392
+
14393
+ var created = opts.created ? "<div class=\"banner banner--ok\">Entry created.</div>" : "";
14394
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Entry archived.</div>" : "";
14395
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
14396
+
14397
+ var CARRIERS = ["ups", "fedex", "usps", "dhl", "flat_rate", "custom"];
14398
+ var carrierOpts = CARRIERS.map(function (c) {
14399
+ return "<option value=\"" + _htmlEscape(c) + "\">" + _htmlEscape(c) + "</option>";
14400
+ }).join("");
14401
+
14402
+ // ---- carrier transits ----
14403
+ var transitRows = transits.map(function (t) {
14404
+ return "<tr>" +
14405
+ "<td><code class=\"order-id\">" + _htmlEscape(t.from_zone) + "</code> &rarr; <code class=\"order-id\">" + _htmlEscape(t.to_zone) + "</code></td>" +
14406
+ "<td>" + _htmlEscape(t.carrier) + "</td>" +
14407
+ "<td>" + _htmlEscape(t.service_level) + "</td>" +
14408
+ "<td class=\"num\">" + _htmlEscape(String(t.transit_days)) + "</td>" +
14409
+ "<td><form method=\"post\" action=\"/admin/delivery-estimates/transits/" + _htmlEscape(encodeURIComponent(t.id)) + "/archive\" class=\"form-inline\">" +
14410
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form></td>" +
14411
+ "</tr>";
14412
+ }).join("");
14413
+ var transitTable = transits.length
14414
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Zone pair</th><th scope=\"col\">Carrier</th><th scope=\"col\">Service</th><th scope=\"col\" class=\"num\">Transit days</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + transitRows + "</tbody></table>") + "</div>"
14415
+ : "<p class=\"empty\">No carrier transits yet.</p>";
14416
+ var transitForm =
14417
+ "<div class=\"panel mt mw-40\">" +
14418
+ "<h3 class=\"subhead\">Add a carrier transit</h3>" +
14419
+ "<p class=\"meta\">The carrier-declared business-day budget for one zone pair and service level.</p>" +
14420
+ "<form method=\"post\" action=\"/admin/delivery-estimates/transits\">" +
14421
+ _setupField("From zone", "from_zone", "", "text", "Origin zone slug, e.g. dc-east.", " maxlength=\"64\" class=\"input-code\" required") +
14422
+ _setupField("To zone", "to_zone", "", "text", "Destination zone slug, e.g. us-west.", " maxlength=\"64\" class=\"input-code\" required") +
14423
+ "<label class=\"form-field\"><span>Carrier</span><select name=\"carrier\" required>" + carrierOpts + "</select></label>" +
14424
+ _setupField("Service level", "service_level", "", "text", "e.g. GROUND, TWO_DAY.", " maxlength=\"64\" class=\"input-code\" required") +
14425
+ _setupField("Transit days", "transit_days", "", "number", "Business days in transit, 0–365.", " min=\"0\" max=\"365\" required") +
14426
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add transit</button></div>" +
14427
+ "</form>" +
14428
+ "</div>";
14429
+
14430
+ // ---- origin cutoffs ----
14431
+ var cutoffRows = cutoffs.map(function (c) {
14432
+ return "<tr>" +
14433
+ "<td><code class=\"order-id\">" + _htmlEscape(c.origin_location) + "</code></td>" +
14434
+ "<td>" + _htmlEscape(c.daily_cutoff_local_time) + "</td>" +
14435
+ "<td>" + _htmlEscape(c.timezone) + "</td>" +
14436
+ "</tr>";
14437
+ }).join("");
14438
+ var cutoffTable = cutoffs.length
14439
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Origin</th><th scope=\"col\">Cutoff (local)</th><th scope=\"col\">Timezone</th></tr></thead><tbody>" + cutoffRows + "</tbody></table>") + "</div>"
14440
+ : "<p class=\"empty\">No origin cutoffs yet.</p>";
14441
+ var cutoffForm =
14442
+ "<div class=\"panel mt mw-40\">" +
14443
+ "<h3 class=\"subhead\">Add an origin cutoff</h3>" +
14444
+ "<p class=\"meta\">The daily local-time cutoff for an origin. Orders after it ship the next business day. Re-add to change.</p>" +
14445
+ "<form method=\"post\" action=\"/admin/delivery-estimates/cutoffs\">" +
14446
+ _setupField("Origin location", "origin_location", "", "text", "Origin slug, e.g. dc-east.", " maxlength=\"80\" class=\"input-code\" required") +
14447
+ _setupField("Daily cutoff (HH:MM)", "daily_cutoff_local_time", "", "text", "24-hour local time, e.g. 14:00.", " maxlength=\"5\" class=\"input-code\" required") +
14448
+ _setupField("Timezone", "timezone", "", "text", "IANA name, e.g. America/New_York.", " maxlength=\"64\" required") +
14449
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add cutoff</button></div>" +
14450
+ "</form>" +
14451
+ "</div>";
14452
+
14453
+ // ---- observed holidays ----
14454
+ var holidayRows = holidays.map(function (h) {
14455
+ return "<tr>" +
14456
+ "<td>" + _htmlEscape(h.region) + "</td>" +
14457
+ "<td>" + _htmlEscape(h.date) + "</td>" +
14458
+ "<td>" + _htmlEscape(h.name) + "</td>" +
14459
+ "<td><form method=\"post\" action=\"/admin/delivery-estimates/holidays/" + _htmlEscape(encodeURIComponent(h.id)) + "/archive\" class=\"form-inline\">" +
14460
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form></td>" +
14461
+ "</tr>";
14462
+ }).join("");
14463
+ var holidayTable = holidays.length
14464
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Region</th><th scope=\"col\">Date</th><th scope=\"col\">Name</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + holidayRows + "</tbody></table>") + "</div>"
14465
+ : "<p class=\"empty\">No holidays yet.</p>";
14466
+ var holidayForm =
14467
+ "<div class=\"panel mt mw-40\">" +
14468
+ "<h3 class=\"subhead\">Add an observed holiday</h3>" +
14469
+ "<p class=\"meta\">A non-shipping date dropped from a region's business-day count.</p>" +
14470
+ "<form method=\"post\" action=\"/admin/delivery-estimates/holidays\">" +
14471
+ _setupField("Region", "region", "", "text", "Operator region slug, e.g. us or us-ca.", " maxlength=\"16\" class=\"input-code\" required") +
14472
+ _setupField("Date", "date", "", "text", "YYYY-MM-DD.", " maxlength=\"10\" class=\"input-code\" required") +
14473
+ _setupField("Name", "name", "", "text", "e.g. Independence Day.", " maxlength=\"200\" required") +
14474
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add holiday</button></div>" +
14475
+ "</form>" +
14476
+ "</div>";
14477
+
14478
+ // ---- postal-prefix → zone ----
14479
+ var zoneRows = zones.map(function (z) {
14480
+ return "<tr>" +
14481
+ "<td>" + _htmlEscape(z.country) + "</td>" +
14482
+ "<td><code class=\"order-id\">" + _htmlEscape(z.postal_prefix) + "</code></td>" +
14483
+ "<td><code class=\"order-id\">" + _htmlEscape(z.zone) + "</code></td>" +
14484
+ "</tr>";
14485
+ }).join("");
14486
+ var zoneTable = zones.length
14487
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Country</th><th scope=\"col\">Postal prefix</th><th scope=\"col\">Zone</th></tr></thead><tbody>" + zoneRows + "</tbody></table>") + "</div>"
14488
+ : "<p class=\"empty\">No postal zones yet.</p>";
14489
+ var zoneForm =
14490
+ "<div class=\"panel mt mw-40\">" +
14491
+ "<h3 class=\"subhead\">Add a postal-prefix zone</h3>" +
14492
+ "<p class=\"meta\">Map a destination postal-code prefix to a transit zone. Longest matching prefix wins. Re-add to change.</p>" +
14493
+ "<form method=\"post\" action=\"/admin/delivery-estimates/postal-zones\">" +
14494
+ _setupField("Country", "country", "", "text", "ISO 3166-1 alpha-2, e.g. US.", " maxlength=\"2\" class=\"input-code\" required") +
14495
+ _setupField("Postal prefix", "postal_prefix", "", "text", "e.g. 902 for US 90xxx.", " maxlength=\"16\" class=\"input-code\" required") +
14496
+ _setupField("Zone", "zone", "", "text", "Destination zone slug, e.g. us-west.", " maxlength=\"64\" class=\"input-code\" required") +
14497
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add postal zone</button></div>" +
14498
+ "</form>" +
14499
+ "</div>";
14500
+
14501
+ var body = "<section><h2>Delivery estimates</h2>" + created + archived + notice +
14502
+ "<p class=\"meta\">These four tables compose the storefront's “Get it by &lt;date&gt;” window. A customer sees a date only once an origin cutoff is configured for the origin their order ships from.</p>" +
14503
+ "<h3 class=\"subhead subhead--sp-lg\">Carrier transits</h3>" + transitTable + transitForm +
14504
+ "<h3 class=\"subhead subhead--sp-lg\">Origin cutoffs</h3>" + cutoffTable + cutoffForm +
14505
+ "<h3 class=\"subhead subhead--sp-lg\">Observed holidays</h3>" + holidayTable + holidayForm +
14506
+ "<h3 class=\"subhead subhead--sp-lg\">Postal-prefix zones</h3>" + zoneTable + zoneForm +
14507
+ "</section>";
14508
+ return _renderAdminShell(opts.shop_name, "Delivery estimates", body, "delivery-estimates", opts.nav_available);
14509
+ }
14510
+
14214
14511
  // Human one-liner for a rule's trigger / value JSON.
14215
14512
  function _fmtTrigger(t) {
14216
14513
  if (!t || !t.kind) return "—";
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.3.67",
2
+ "version": "0.3.68",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
6
6
  "fingerprinted": "css/admin.44eb97700c660798.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-fzlpYGSv30HHY+ir9ym4VxwwgwzXQ9Sn153QMXLVKOPJ02eDNO/cI6Ww3rPluQ8R",
10
- "fingerprinted": "css/main.15dfd64f4536314d.css"
9
+ "integrity": "sha384-kjpDvd8TisWdb45G5S2FyHOo4plk5DiOMO0aTZWxKCJhAhBEo55yABnv5BbUpzS4",
10
+ "fingerprinted": "css/main.337a7be6244cd7c0.css"
11
11
  },
12
12
  "js/announcement.js": {
13
13
  "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
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)
@@ -7567,6 +7625,7 @@ var CART_PAGE =
7567
7625
  " <dl class=\"totals-list\">\n" +
7568
7626
  "RAW_TOTALS_ROWS" +
7569
7627
  " </dl>\n" +
7628
+ "RAW_CART_DELIVERYESTIMATE_PLACEHOLDER" +
7570
7629
  "RAW_CHECKOUT_CTA" +
7571
7630
  " </aside>\n" +
7572
7631
  " </div>\n" +
@@ -7850,10 +7909,20 @@ function renderCart(opts) {
7850
7909
  " <div><dt>Subtotal</dt><dd>" + subtotal + "</dd></div>\n" +
7851
7910
  " <div class=\"totals-list__grand\"><dt>Total</dt><dd>" + total + "</dd></div>\n";
7852
7911
  }
7912
+ // "Get it by <date>" for the whole cart — the slowest line's window, so
7913
+ // the shopper sees when EVERYTHING arrives, not the optimistic item. The
7914
+ // SAME shared builder the PDP uses (byte-identical across the dual render),
7915
+ // spliced into the summary aside. Container-only: the route resolves a
7916
+ // per-customer estimate (signed-in + saved address); absent that, or on
7917
+ // any primitive failure, opts.delivery_estimate is null and this renders
7918
+ // empty. Indented to match the aside's two-space block.
7919
+ var cartEstimateHtml = _buildDeliveryEstimate(opts.delivery_estimate, b.template.escapeHtml);
7920
+ cartEstimateHtml = cartEstimateHtml ? " " + cartEstimateHtml + "\n" : "";
7853
7921
  body = _render(CART_PAGE, {
7854
7922
  line_rows: "RAW_LINES",
7855
7923
  }).replace("RAW_LINES", rows)
7856
7924
  .replace("RAW_TOTALS_ROWS", totalsRows)
7925
+ .replace("RAW_CART_DELIVERYESTIMATE_PLACEHOLDER", cartEstimateHtml)
7857
7926
  .replace("RAW_CHECKOUT_CTA", checkoutCta)
7858
7927
  .replace("RAW_CART_NOTICE", notice);
7859
7928
  // CONTAINER-ONLY gift wrap disclosure, appended after the cart grid (a
@@ -11107,6 +11176,80 @@ function mount(router, deps) {
11107
11176
  return { ship_to: { country: "US" }, from_saved: false };
11108
11177
  }
11109
11178
 
11179
+ // Resolve the "Get it by <date>" delivery estimate for the PDP / cart. This
11180
+ // is a DROP-SILENT defensive reader (the storefront read tier): it returns
11181
+ // null on ANYTHING short of a clean, dated estimate — no deliveryEstimate
11182
+ // dep, no signed-in customer, no saved shipping address with a postal code,
11183
+ // an unconfigured origin/cutoff (the primitive THROWS a config-state
11184
+ // TypeError there), or a no-match `{ ok:false }`. The route renders no
11185
+ // estimate in every one of those cases, never a 500.
11186
+ //
11187
+ // WHY signed-in + saved-address only: the arrival date is destination-
11188
+ // specific. The edge serves a shared cache across anonymous visitors and
11189
+ // must never bake a per-visitor date, so the date is a container-only
11190
+ // enhancement for a customer whose destination we already know (their
11191
+ // default shipping address). v1 ships no anonymous ZIP-entry widget — that's
11192
+ // a separate slice; an anonymous shopper simply sees no date.
11193
+ //
11194
+ // `originLocation` comes from operator config (`shop.estimate_origin`); the
11195
+ // primitive REFUSES to guess an origin (no defaultLocation resolver is
11196
+ // wired), so without that config row no estimate renders — by design.
11197
+ // `weightGrams` (optional) lets the primitive's weight-aware rows apply.
11198
+ //
11199
+ // Returns the display shape the dual-render builder consumes:
11200
+ // { deliver_by, latest_by, service_label }
11201
+ // or null.
11202
+ async function _resolveDeliveryEstimate(req, opts) {
11203
+ opts = opts || {};
11204
+ if (!deps.deliveryEstimate) return null;
11205
+ try {
11206
+ // Per-customer destination: only a SIGNED-IN customer with a saved
11207
+ // shipping address that carries a postal code yields a date. _normalize
11208
+ // (inside _estimateDestination) drops a garbage postal, so a saved
11209
+ // address with no usable postal falls through to null here.
11210
+ var coAuth = _currentCustomerEnv(req);
11211
+ if (!coAuth) return null;
11212
+ var dest = await _estimateDestination(req);
11213
+ if (!dest || !dest.from_saved || !dest.ship_to || !dest.ship_to.postal) return null;
11214
+ // Operator-configured origin — the primitive won't guess one. Resolved
11215
+ // at boot into `deps.delivery_estimate_origin` (a plain slug string) from
11216
+ // SHOP_ESTIMATE_ORIGIN / the config primitive, since the storefront's
11217
+ // sfDeps.config is a bare { shop_name } stub with no live .get(). Falls
11218
+ // back to a per-request config read if a future caller wires a real
11219
+ // config handle. Absent any origin, no date renders — by design.
11220
+ var origin = (typeof deps.delivery_estimate_origin === "string" && deps.delivery_estimate_origin)
11221
+ ? deps.delivery_estimate_origin
11222
+ : null;
11223
+ if (!origin && deps.config && typeof deps.config.get === "function") {
11224
+ try { origin = await deps.config.get("shop.estimate_origin", null); }
11225
+ catch (_e) { origin = null; }
11226
+ }
11227
+ if (typeof origin !== "string" || !origin) return null;
11228
+ var est = await deps.deliveryEstimate.estimate({
11229
+ origin_location: origin,
11230
+ destination_postal: dest.ship_to.postal,
11231
+ destination_country: dest.ship_to.country,
11232
+ destination_region: dest.ship_to.state ? dest.ship_to.state.toLowerCase() : undefined,
11233
+ weight_grams: Number.isInteger(opts.weight_grams) ? opts.weight_grams : undefined,
11234
+ });
11235
+ if (!est || !est.ok || !est.est_min_delivery_date) return null;
11236
+ // The fastest service is the headline date (service_levels are sorted
11237
+ // ascending by transit_days inside the primitive); the slowest gives the
11238
+ // range upper bound so the line reads as a window, not a promise.
11239
+ var fastest = est.service_levels && est.service_levels.length ? est.service_levels[0] : null;
11240
+ return {
11241
+ deliver_by: est.est_min_delivery_date,
11242
+ latest_by: est.est_max_delivery_date || est.est_min_delivery_date,
11243
+ service_label: fastest ? fastest.label : null,
11244
+ };
11245
+ } catch (_e) {
11246
+ // Drop-silent — every config-state / validation throw the primitive
11247
+ // raises (no cutoff row, origin_location required, a malformed saved
11248
+ // postal) collapses to "no estimate", never a broken page.
11249
+ return null;
11250
+ }
11251
+ }
11252
+
11110
11253
  // Compute the cart/checkout totals the shopper sees BEFORE paying.
11111
11254
  // Composes the SAME tax + shipping primitives the charge runs through
11112
11255
  // (via checkout.quote, which prices tax against the pre-discount
@@ -11593,10 +11736,17 @@ function mount(router, deps) {
11593
11736
  // to a banner; only meaningful when a campaign is present.
11594
11737
  var pdpUrl = req.url ? new URL(req.url, "http://localhost") : null;
11595
11738
  var preorderNotice = pdpUrl ? pdpUrl.searchParams.get("preorder") : null;
11739
+ // "Get it by <date>" — container-only, per-customer (see
11740
+ // _resolveDeliveryEstimate). Weighted by the lead variant so the
11741
+ // primitive's weight-aware transit rows apply. Drop-silent → null.
11742
+ var leadWeight = firstVariant && Number.isInteger(firstVariant.weight_grams)
11743
+ ? firstVariant.weight_grams : undefined;
11744
+ var deliveryEstimate = await _resolveDeliveryEstimate(req, { weight_grams: leadWeight });
11596
11745
  var html = renderProduct(Object.assign({
11597
11746
  product: product,
11598
11747
  variants: variants,
11599
11748
  prices: prices,
11749
+ delivery_estimate: deliveryEstimate,
11600
11750
  preorder_campaign: preorderCampaign,
11601
11751
  preorder_notice: preorderNotice,
11602
11752
  media: media,
@@ -12050,10 +12200,17 @@ function mount(router, deps) {
12050
12200
  }
12051
12201
  } catch (_e) { giftWraps = []; giftWrapInCart = null; }
12052
12202
  }
12203
+ // "Get it by <date>" for the whole cart — container-only, per-customer
12204
+ // (see _resolveDeliveryEstimate). No weight is summed across lines (the
12205
+ // cart estimate is the destination window, not a per-parcel weight quote);
12206
+ // the primitive falls back to its weight-agnostic transit rows. Drop-
12207
+ // silent → null, and the summary renders no estimate.
12208
+ var cartEstimate = await _resolveDeliveryEstimate(req, {});
12053
12209
  _send(res, 200, renderCart(Object.assign({
12054
12210
  lines: lines,
12055
12211
  totals: totals,
12056
12212
  totals_detail: totalsDetail,
12213
+ delivery_estimate: cartEstimate,
12057
12214
  line_stock: lineStock,
12058
12215
  product_lookup: productLookup,
12059
12216
  can_save: !!(deps.saveForLater && deps.customers),
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.68",
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": {