@blamejs/blamejs-shop 0.3.66 → 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,10 @@ 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
+
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.
14
+
11
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.
12
16
 
13
17
  - v0.3.65 (2026-06-04) — **Operator-tuned search ranking applies to edge-served visitors.** Search-ranking weight sets and manual pins applied only on container-served search — anonymous visitors, who are served from the edge cache and make up most traffic, got unranked results no matter what the operator tuned. The edge search path now applies the identical ranking: pinned products lead in their configured positions, the rest order by the active weight set, with byte-identical scoring pinned by a parity test in the same style as the existing faceting and synonym parity guards. Ranking and pin changes reach edge visitors within the same sixty-second cache window as every other operator-tunable edge surface. If the ranking tables are absent or unconfigured, edge search degrades to its previous unranked order rather than failing. **Fixed:** *Edge search results honor ranking weights and manual pins* — The /search route served from the edge ordered results by filter match alone, ignoring the active search weight set and any merchandiser pins, so ranking tuning was visible only to signed-in (container-served) visitors. The edge now loads the active weight set and pins on each cache fill and applies the same scoring as the container — pins first in position order, then weighted score descending, with identical tie-breaking. The two implementations are locked together by a parity test that runs the same fixtures through both. Changes the operator makes take effect for edge visitors within the standard sixty-second cache freshness window; a missing or unconfigured ranking table degrades to the unranked order, never an error.
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. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, and Discounts 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.66",
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-pEf1nxijlK9mXs8LacGqzNmr+WzjMR7c17Gsx24YS1PharS/BWa62RU/fUL8XSa6",
10
- "fingerprinted": "css/main.bb97cd4c4b867440.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",
@@ -401,7 +401,14 @@ function create(opts) {
401
401
  if (typeof raw === "boolean") n = raw ? 1 : 0;
402
402
  else if (typeof raw === "number" && isFinite(raw)) n = raw;
403
403
  else continue;
404
- score += weights[k] * n;
404
+ // Defensive: a hand-edited weights_json row can carry a non-numeric
405
+ // weight; multiplying by it would NaN-poison the score and garble
406
+ // the sort. Skip it — the edge mirror (worker/data/search-ranking.js)
407
+ // applies the same filter, keeping the two rankers order-identical
408
+ // on malformed config.
409
+ var w = weights[k];
410
+ if (typeof w !== "number" || !isFinite(w)) continue;
411
+ score += w * n;
405
412
  }
406
413
  return score;
407
414
  }
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)
@@ -6412,12 +6470,19 @@ var CART_LINE_EDITABLE =
6412
6470
  // `p` pre-fills each field (from a signed-in customer's default shipping
6413
6471
  // address, or the shopper's own typed values on a validation re-render);
6414
6472
  // every value is escaped by `_addrField` / the email builder.
6415
- // Street line 1 + city are marked required for the common physical-goods
6416
- // path, and the POST handler enforces the same set server-side via
6417
- // _requireCheckoutFields (backend validates, frontend displays). The
6418
- // service tier (checkout._shipTo) stays presence-optional so a non-form
6473
+ //
6474
+ // `allDigital` flips the address block to optional: when every cart line
6475
+ // is a no-shipment good (variant requires_shipping = 0), street line 1 +
6476
+ // city drop their `required` attr + `*` marker and a short note explains
6477
+ // the order needs no shipping address. Country stays required because the
6478
+ // tax quote keys off it (checkout._shipTo validates a country on every
6479
+ // quote, digital included). For a cart with ANY shippable line, line1 +
6480
+ // city stay required for the common physical-goods path. The POST handler
6481
+ // enforces the SAME contract server-side via _requireCheckoutFields with
6482
+ // the same flag (backend validates, frontend displays). The service tier
6483
+ // (checkout._shipTo) stays presence-optional regardless so a non-form
6419
6484
  // caller can still complete a digital-only order with a bare country.
6420
- function _checkoutShippingFields(p, inv) {
6485
+ function _checkoutShippingFields(p, inv, allDigital) {
6421
6486
  p = p || {};
6422
6487
  var esc = b.template.escapeHtml;
6423
6488
  // Merge the per-field invalid marker (a { field, message } from
@@ -6439,12 +6504,20 @@ function _checkoutShippingFields(p, inv) {
6439
6504
  _fieldAriaAttr("co-err-", "email", inv) + ">" +
6440
6505
  _fieldErrorSpan("co-err-", "email", inv) +
6441
6506
  "</label>";
6507
+ // For an all-digital cart the address is optional — line1 + city lose
6508
+ // the `required` attr/marker and a note says so. A mixed/physical cart
6509
+ // keeps them required (the un-flagged default).
6510
+ var addrRequired = !allDigital;
6511
+ var digitalNote = allDigital
6512
+ ? "<p class=\"checkout-page__digital-note\">This is a digital order — no shipping address needed. Just confirm your country below for tax.</p>"
6513
+ : "";
6442
6514
  return email +
6443
6515
  _addrField("name", "Full name", p.name, _mark("name", { required: true, maxlength: 120, autocomplete: "name" })) +
6444
- _addrField("line1", "Street address", p.line1, _mark("line1", { required: true, maxlength: 200, autocomplete: "address-line1" })) +
6516
+ digitalNote +
6517
+ _addrField("line1", "Street address", p.line1, _mark("line1", { required: addrRequired, maxlength: 200, autocomplete: "address-line1" })) +
6445
6518
  _addrField("line2", "Apt / suite / unit (optional)", p.line2, _mark("line2", { maxlength: 200, autocomplete: "address-line2" })) +
6446
6519
  "<div class=\"form-row form-row--inline\">" +
6447
- _addrField("city", "City", p.city, _mark("city", { required: true, maxlength: 120, autocomplete: "address-level2" })) +
6520
+ _addrField("city", "City", p.city, _mark("city", { required: addrRequired, maxlength: 120, autocomplete: "address-level2" })) +
6448
6521
  _addrField("state", "State / province code", p.state, _mark("state", { maxlength: 5, pattern: "[A-Za-z0-9]{1,5}", autocomplete: "address-level1" })) +
6449
6522
  "</div>" +
6450
6523
  "<div class=\"form-row form-row--inline\">" +
@@ -6483,13 +6556,23 @@ function _shipToFromBody(body) {
6483
6556
  // _checkoutFieldFromError maps each to its input. The PayPal create route
6484
6557
  // shares _shipToFromBody but not this gate — its format errors surface
6485
6558
  // through the same service-tier validators.
6486
- function _requireCheckoutFields(body, shipTo) {
6559
+ //
6560
+ // `allDigital` (every cart line is a no-shipment good) drops the line1 +
6561
+ // city + region/postal presence gates: the order ships nothing, so only
6562
+ // email + name + a country (for the tax quote) are required. The country
6563
+ // itself is still validated for shape downstream by checkout._shipTo. Any
6564
+ // region/postal values the shopper DID supply still get format-validated
6565
+ // there — relaxing presence never relaxes format. A cart with ANY
6566
+ // shippable line keeps the full requirement set (the un-flagged default),
6567
+ // matching the form the GET path renders.
6568
+ function _requireCheckoutFields(body, shipTo, allDigital) {
6487
6569
  if (!body.email || !String(body.email).trim()) {
6488
6570
  throw new TypeError("checkout: customer.email — Enter your email address.");
6489
6571
  }
6490
6572
  if (!body.name || !String(body.name).trim()) {
6491
6573
  throw new TypeError("checkout: customer.name — Enter the recipient's full name.");
6492
6574
  }
6575
+ if (allDigital) return; // digital order — no shipping address required (country shape is validated downstream)
6493
6576
  if (!shipTo.line1) throw new TypeError("checkout: ship_to.line1 — Enter a street address.");
6494
6577
  if (!shipTo.city) throw new TypeError("checkout: ship_to.city — Enter a city.");
6495
6578
  if (shipTo.country === "US" || shipTo.country === "CA") {
@@ -6712,7 +6795,7 @@ function renderCheckoutForm(opts) {
6712
6795
  body = _spliceRaw(body, "RAW_TOTALS_ROWS", totalsRows);
6713
6796
  body = _spliceRaw(body, "RAW_SUMMARY_NOTE", summaryNote);
6714
6797
  body = _spliceRaw(body, "RAW_INLINE_ERROR", inlineError);
6715
- body = _spliceRaw(body, "RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill, opts.invalid_field));
6798
+ body = _spliceRaw(body, "RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill, opts.invalid_field, !!opts.all_digital));
6716
6799
  body = _spliceRaw(body, "RAW_SUMMARY_LINES", summaryLines);
6717
6800
  // Pick-up-in-store picker, gift personalization, loyalty redeem, and the
6718
6801
  // CAPTCHA widget are spliced in ABOVE the submit action row — inside the
@@ -7542,6 +7625,7 @@ var CART_PAGE =
7542
7625
  " <dl class=\"totals-list\">\n" +
7543
7626
  "RAW_TOTALS_ROWS" +
7544
7627
  " </dl>\n" +
7628
+ "RAW_CART_DELIVERYESTIMATE_PLACEHOLDER" +
7545
7629
  "RAW_CHECKOUT_CTA" +
7546
7630
  " </aside>\n" +
7547
7631
  " </div>\n" +
@@ -7825,10 +7909,20 @@ function renderCart(opts) {
7825
7909
  " <div><dt>Subtotal</dt><dd>" + subtotal + "</dd></div>\n" +
7826
7910
  " <div class=\"totals-list__grand\"><dt>Total</dt><dd>" + total + "</dd></div>\n";
7827
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" : "";
7828
7921
  body = _render(CART_PAGE, {
7829
7922
  line_rows: "RAW_LINES",
7830
7923
  }).replace("RAW_LINES", rows)
7831
7924
  .replace("RAW_TOTALS_ROWS", totalsRows)
7925
+ .replace("RAW_CART_DELIVERYESTIMATE_PLACEHOLDER", cartEstimateHtml)
7832
7926
  .replace("RAW_CHECKOUT_CTA", checkoutCta)
7833
7927
  .replace("RAW_CART_NOTICE", notice);
7834
7928
  // CONTAINER-ONLY gift wrap disclosure, appended after the cart grid (a
@@ -11082,6 +11176,80 @@ function mount(router, deps) {
11082
11176
  return { ship_to: { country: "US" }, from_saved: false };
11083
11177
  }
11084
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
+
11085
11253
  // Compute the cart/checkout totals the shopper sees BEFORE paying.
11086
11254
  // Composes the SAME tax + shipping primitives the charge runs through
11087
11255
  // (via checkout.quote, which prices tax against the pre-discount
@@ -11568,10 +11736,17 @@ function mount(router, deps) {
11568
11736
  // to a banner; only meaningful when a campaign is present.
11569
11737
  var pdpUrl = req.url ? new URL(req.url, "http://localhost") : null;
11570
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 });
11571
11745
  var html = renderProduct(Object.assign({
11572
11746
  product: product,
11573
11747
  variants: variants,
11574
11748
  prices: prices,
11749
+ delivery_estimate: deliveryEstimate,
11575
11750
  preorder_campaign: preorderCampaign,
11576
11751
  preorder_notice: preorderNotice,
11577
11752
  media: media,
@@ -12025,10 +12200,17 @@ function mount(router, deps) {
12025
12200
  }
12026
12201
  } catch (_e) { giftWraps = []; giftWrapInCart = null; }
12027
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, {});
12028
12209
  _send(res, 200, renderCart(Object.assign({
12029
12210
  lines: lines,
12030
12211
  totals: totals,
12031
12212
  totals_detail: totalsDetail,
12213
+ delivery_estimate: cartEstimate,
12032
12214
  line_stock: lineStock,
12033
12215
  product_lookup: productLookup,
12034
12216
  can_save: !!(deps.saveForLater && deps.customers),
@@ -12077,6 +12259,35 @@ function mount(router, deps) {
12077
12259
  }
12078
12260
 
12079
12261
  if (deps.checkout && deps.order) {
12262
+ // True when EVERY cart line is a no-shipment good (its variant's
12263
+ // requires_shipping is 0) — the cart ships nothing, so the checkout
12264
+ // form drops the address requirement and the POST gate requires only
12265
+ // email + name + country (for the tax quote). A missing variant row,
12266
+ // a null/absent column, or an empty cart all read as "needs an
12267
+ // address" (shippable) — relaxing the gate is the exception, never the
12268
+ // default, so a lookup gap can't hide a required field. Mirrors
12269
+ // checkout._buildQuote's per-line `!!v.requires_shipping` enrichment.
12270
+ // The GET render path computes this inline from the variant rows it
12271
+ // already fetches for the order-summary lookup (no second query pass);
12272
+ // the POST gate has no such lookup, so it resolves here.
12273
+ async function _cartAllDigital(lines) {
12274
+ if (!lines || !lines.length) return false;
12275
+ if (!deps.catalog || !deps.catalog.variants || typeof deps.catalog.variants.get !== "function") {
12276
+ return false; // can't prove all-digital → keep the address gate
12277
+ }
12278
+ var shipByVariant = {};
12279
+ for (var i = 0; i < lines.length; i += 1) {
12280
+ var vid = lines[i].variant_id;
12281
+ if (shipByVariant[vid] !== undefined) continue;
12282
+ var v = await deps.catalog.variants.get(vid);
12283
+ shipByVariant[vid] = !v || v.requires_shipping == null || Number(v.requires_shipping) === 1;
12284
+ }
12285
+ for (var j = 0; j < lines.length; j += 1) {
12286
+ if (shipByVariant[lines[j].variant_id] !== false) return false; // a shippable line
12287
+ }
12288
+ return true;
12289
+ }
12290
+
12080
12291
  // Build the renderCheckoutForm() opts for an active cart `c`: repriced
12081
12292
  // lines, totals, the thumbnail lookup, and the signed-in customer's
12082
12293
  // loyalty balance + prefill. Shared by the GET handler and the POST
@@ -12097,16 +12308,41 @@ function mount(router, deps) {
12097
12308
  var totals = totalsDetail.totals;
12098
12309
  // variant_id → { product, hero_media } lookup for the summary
12099
12310
  // thumbnails + titles — same shape (and caching) the cart route uses.
12311
+ // The SAME variant row (catalog returns SELECT * — requires_shipping
12312
+ // is on it) feeds the all-digital determination below, so the form
12313
+ // can drop the address requirement on a no-shipment cart without a
12314
+ // second query pass. `shipBySku` caches requires_shipping per variant
12315
+ // so a duplicate cart line never re-fetches. A line whose variant row
12316
+ // is missing is treated as shippable (the never-relax-on-missing-data
12317
+ // stance), so a lookup gap can never accidentally hide a required
12318
+ // address.
12100
12319
  var checkoutLookup = {};
12320
+ var shipByVariant = {};
12101
12321
  for (var li = 0; li < lines.length; li += 1) {
12102
12322
  var lvId = lines[li].variant_id;
12103
- if (checkoutLookup[lvId]) continue;
12323
+ if (checkoutLookup[lvId] !== undefined) continue;
12104
12324
  var lv = await deps.catalog.variants.get(lvId);
12105
- if (!lv) { checkoutLookup[lvId] = null; continue; }
12325
+ if (!lv) {
12326
+ checkoutLookup[lvId] = null;
12327
+ shipByVariant[lvId] = true; // missing variant → treat as shippable
12328
+ continue;
12329
+ }
12106
12330
  var lprod = await deps.catalog.products.get(lv.product_id);
12107
12331
  var lmedia = await deps.catalog.media.listForProduct(lv.product_id);
12108
12332
  checkoutLookup[lvId] = { product: lprod, hero_media: lmedia.length ? lmedia[0] : null };
12333
+ // requires_shipping defaults to 1 (shippable) when the column is
12334
+ // absent — mirrors checkout._buildQuote's `!!v.requires_shipping`
12335
+ // with a null-safe default of shippable.
12336
+ shipByVariant[lvId] = lv.requires_shipping == null || Number(lv.requires_shipping) === 1;
12337
+ }
12338
+ // All-digital = a non-empty cart where NO line needs shipping. The
12339
+ // service tier already completes such an order (checkout's
12340
+ // digital_none fallback); this lets the form drop the address gate.
12341
+ var anyShippable = false;
12342
+ for (var si = 0; si < lines.length; si += 1) {
12343
+ if (shipByVariant[lines[si].variant_id] !== false) { anyShippable = true; break; }
12109
12344
  }
12345
+ var allDigital = lines.length > 0 && !anyShippable;
12110
12346
  // A signed-in customer drives two best-effort lookups, both keyed
12111
12347
  // off the same auth env: the loyalty balance (for the redeem field)
12112
12348
  // and the default shipping address (to pre-fill the form). Either
@@ -12170,6 +12406,9 @@ function mount(router, deps) {
12170
12406
  lines: lines, totals: totals, totals_detail: totalsDetail,
12171
12407
  shop_name: shopName, theme: theme,
12172
12408
  product_lookup: checkoutLookup,
12409
+ // Every cart line is a no-shipment good → the form drops the
12410
+ // address requirement (and the POST gate mirrors the flag).
12411
+ all_digital: allDigital,
12173
12412
  paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
12174
12413
  loyalty_balance: loyaltyBalance,
12175
12414
  loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
@@ -12334,7 +12573,13 @@ function mount(router, deps) {
12334
12573
  }
12335
12574
  var shipTo = _shipToFromBody(body);
12336
12575
  try {
12337
- _requireCheckoutFields(body, shipTo);
12576
+ // A cart whose every line is a no-shipment good needs no shipping
12577
+ // address — only email + name + a country for the tax quote. The
12578
+ // form rendered the address block optional (all_digital); the gate
12579
+ // here mirrors that so the two never disagree. A mixed/physical
12580
+ // cart keeps the full requirement set.
12581
+ var coAllDigital = await _cartAllDigital(await deps.cart.listLines(c.id));
12582
+ _requireCheckoutFields(body, shipTo, coAllDigital);
12338
12583
  // default_shipping_id may be a literal string or an
12339
12584
  // operator-supplied async resolver (e.g. backed by the
12340
12585
  // config primitive) so re-reads happen per request without
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.66",
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": {