@blamejs/blamejs-shop 0.3.66 → 0.3.67
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 +2 -0
- package/README.md +1 -1
- package/lib/asset-manifest.json +3 -3
- package/lib/search-ranking.js +8 -1
- package/lib/storefront.js +100 -12
- package/package.json +1 -1
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.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
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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
|
@@ -96,7 +96,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
96
96
|
| **`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
97
|
| **`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
98
|
| **`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
|
|
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
100
|
| **`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
101
|
| **`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
102
|
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.3.
|
|
2
|
+
"version": "0.3.67",
|
|
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-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-fzlpYGSv30HHY+ir9ym4VxwwgwzXQ9Sn153QMXLVKOPJ02eDNO/cI6Ww3rPluQ8R",
|
|
10
|
+
"fingerprinted": "css/main.15dfd64f4536314d.css"
|
|
11
11
|
},
|
|
12
12
|
"js/announcement.js": {
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
package/lib/search-ranking.js
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -6412,12 +6412,19 @@ var CART_LINE_EDITABLE =
|
|
|
6412
6412
|
// `p` pre-fills each field (from a signed-in customer's default shipping
|
|
6413
6413
|
// address, or the shopper's own typed values on a validation re-render);
|
|
6414
6414
|
// every value is escaped by `_addrField` / the email builder.
|
|
6415
|
-
//
|
|
6416
|
-
//
|
|
6417
|
-
//
|
|
6418
|
-
//
|
|
6415
|
+
//
|
|
6416
|
+
// `allDigital` flips the address block to optional: when every cart line
|
|
6417
|
+
// is a no-shipment good (variant requires_shipping = 0), street line 1 +
|
|
6418
|
+
// city drop their `required` attr + `*` marker and a short note explains
|
|
6419
|
+
// the order needs no shipping address. Country stays required because the
|
|
6420
|
+
// tax quote keys off it (checkout._shipTo validates a country on every
|
|
6421
|
+
// quote, digital included). For a cart with ANY shippable line, line1 +
|
|
6422
|
+
// city stay required for the common physical-goods path. The POST handler
|
|
6423
|
+
// enforces the SAME contract server-side via _requireCheckoutFields with
|
|
6424
|
+
// the same flag (backend validates, frontend displays). The service tier
|
|
6425
|
+
// (checkout._shipTo) stays presence-optional regardless so a non-form
|
|
6419
6426
|
// caller can still complete a digital-only order with a bare country.
|
|
6420
|
-
function _checkoutShippingFields(p, inv) {
|
|
6427
|
+
function _checkoutShippingFields(p, inv, allDigital) {
|
|
6421
6428
|
p = p || {};
|
|
6422
6429
|
var esc = b.template.escapeHtml;
|
|
6423
6430
|
// Merge the per-field invalid marker (a { field, message } from
|
|
@@ -6439,12 +6446,20 @@ function _checkoutShippingFields(p, inv) {
|
|
|
6439
6446
|
_fieldAriaAttr("co-err-", "email", inv) + ">" +
|
|
6440
6447
|
_fieldErrorSpan("co-err-", "email", inv) +
|
|
6441
6448
|
"</label>";
|
|
6449
|
+
// For an all-digital cart the address is optional — line1 + city lose
|
|
6450
|
+
// the `required` attr/marker and a note says so. A mixed/physical cart
|
|
6451
|
+
// keeps them required (the un-flagged default).
|
|
6452
|
+
var addrRequired = !allDigital;
|
|
6453
|
+
var digitalNote = allDigital
|
|
6454
|
+
? "<p class=\"checkout-page__digital-note\">This is a digital order — no shipping address needed. Just confirm your country below for tax.</p>"
|
|
6455
|
+
: "";
|
|
6442
6456
|
return email +
|
|
6443
6457
|
_addrField("name", "Full name", p.name, _mark("name", { required: true, maxlength: 120, autocomplete: "name" })) +
|
|
6444
|
-
|
|
6458
|
+
digitalNote +
|
|
6459
|
+
_addrField("line1", "Street address", p.line1, _mark("line1", { required: addrRequired, maxlength: 200, autocomplete: "address-line1" })) +
|
|
6445
6460
|
_addrField("line2", "Apt / suite / unit (optional)", p.line2, _mark("line2", { maxlength: 200, autocomplete: "address-line2" })) +
|
|
6446
6461
|
"<div class=\"form-row form-row--inline\">" +
|
|
6447
|
-
_addrField("city", "City", p.city, _mark("city", { required:
|
|
6462
|
+
_addrField("city", "City", p.city, _mark("city", { required: addrRequired, maxlength: 120, autocomplete: "address-level2" })) +
|
|
6448
6463
|
_addrField("state", "State / province code", p.state, _mark("state", { maxlength: 5, pattern: "[A-Za-z0-9]{1,5}", autocomplete: "address-level1" })) +
|
|
6449
6464
|
"</div>" +
|
|
6450
6465
|
"<div class=\"form-row form-row--inline\">" +
|
|
@@ -6483,13 +6498,23 @@ function _shipToFromBody(body) {
|
|
|
6483
6498
|
// _checkoutFieldFromError maps each to its input. The PayPal create route
|
|
6484
6499
|
// shares _shipToFromBody but not this gate — its format errors surface
|
|
6485
6500
|
// through the same service-tier validators.
|
|
6486
|
-
|
|
6501
|
+
//
|
|
6502
|
+
// `allDigital` (every cart line is a no-shipment good) drops the line1 +
|
|
6503
|
+
// city + region/postal presence gates: the order ships nothing, so only
|
|
6504
|
+
// email + name + a country (for the tax quote) are required. The country
|
|
6505
|
+
// itself is still validated for shape downstream by checkout._shipTo. Any
|
|
6506
|
+
// region/postal values the shopper DID supply still get format-validated
|
|
6507
|
+
// there — relaxing presence never relaxes format. A cart with ANY
|
|
6508
|
+
// shippable line keeps the full requirement set (the un-flagged default),
|
|
6509
|
+
// matching the form the GET path renders.
|
|
6510
|
+
function _requireCheckoutFields(body, shipTo, allDigital) {
|
|
6487
6511
|
if (!body.email || !String(body.email).trim()) {
|
|
6488
6512
|
throw new TypeError("checkout: customer.email — Enter your email address.");
|
|
6489
6513
|
}
|
|
6490
6514
|
if (!body.name || !String(body.name).trim()) {
|
|
6491
6515
|
throw new TypeError("checkout: customer.name — Enter the recipient's full name.");
|
|
6492
6516
|
}
|
|
6517
|
+
if (allDigital) return; // digital order — no shipping address required (country shape is validated downstream)
|
|
6493
6518
|
if (!shipTo.line1) throw new TypeError("checkout: ship_to.line1 — Enter a street address.");
|
|
6494
6519
|
if (!shipTo.city) throw new TypeError("checkout: ship_to.city — Enter a city.");
|
|
6495
6520
|
if (shipTo.country === "US" || shipTo.country === "CA") {
|
|
@@ -6712,7 +6737,7 @@ function renderCheckoutForm(opts) {
|
|
|
6712
6737
|
body = _spliceRaw(body, "RAW_TOTALS_ROWS", totalsRows);
|
|
6713
6738
|
body = _spliceRaw(body, "RAW_SUMMARY_NOTE", summaryNote);
|
|
6714
6739
|
body = _spliceRaw(body, "RAW_INLINE_ERROR", inlineError);
|
|
6715
|
-
body = _spliceRaw(body, "RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill, opts.invalid_field));
|
|
6740
|
+
body = _spliceRaw(body, "RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill, opts.invalid_field, !!opts.all_digital));
|
|
6716
6741
|
body = _spliceRaw(body, "RAW_SUMMARY_LINES", summaryLines);
|
|
6717
6742
|
// Pick-up-in-store picker, gift personalization, loyalty redeem, and the
|
|
6718
6743
|
// CAPTCHA widget are spliced in ABOVE the submit action row — inside the
|
|
@@ -12077,6 +12102,35 @@ function mount(router, deps) {
|
|
|
12077
12102
|
}
|
|
12078
12103
|
|
|
12079
12104
|
if (deps.checkout && deps.order) {
|
|
12105
|
+
// True when EVERY cart line is a no-shipment good (its variant's
|
|
12106
|
+
// requires_shipping is 0) — the cart ships nothing, so the checkout
|
|
12107
|
+
// form drops the address requirement and the POST gate requires only
|
|
12108
|
+
// email + name + country (for the tax quote). A missing variant row,
|
|
12109
|
+
// a null/absent column, or an empty cart all read as "needs an
|
|
12110
|
+
// address" (shippable) — relaxing the gate is the exception, never the
|
|
12111
|
+
// default, so a lookup gap can't hide a required field. Mirrors
|
|
12112
|
+
// checkout._buildQuote's per-line `!!v.requires_shipping` enrichment.
|
|
12113
|
+
// The GET render path computes this inline from the variant rows it
|
|
12114
|
+
// already fetches for the order-summary lookup (no second query pass);
|
|
12115
|
+
// the POST gate has no such lookup, so it resolves here.
|
|
12116
|
+
async function _cartAllDigital(lines) {
|
|
12117
|
+
if (!lines || !lines.length) return false;
|
|
12118
|
+
if (!deps.catalog || !deps.catalog.variants || typeof deps.catalog.variants.get !== "function") {
|
|
12119
|
+
return false; // can't prove all-digital → keep the address gate
|
|
12120
|
+
}
|
|
12121
|
+
var shipByVariant = {};
|
|
12122
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
12123
|
+
var vid = lines[i].variant_id;
|
|
12124
|
+
if (shipByVariant[vid] !== undefined) continue;
|
|
12125
|
+
var v = await deps.catalog.variants.get(vid);
|
|
12126
|
+
shipByVariant[vid] = !v || v.requires_shipping == null || Number(v.requires_shipping) === 1;
|
|
12127
|
+
}
|
|
12128
|
+
for (var j = 0; j < lines.length; j += 1) {
|
|
12129
|
+
if (shipByVariant[lines[j].variant_id] !== false) return false; // a shippable line
|
|
12130
|
+
}
|
|
12131
|
+
return true;
|
|
12132
|
+
}
|
|
12133
|
+
|
|
12080
12134
|
// Build the renderCheckoutForm() opts for an active cart `c`: repriced
|
|
12081
12135
|
// lines, totals, the thumbnail lookup, and the signed-in customer's
|
|
12082
12136
|
// loyalty balance + prefill. Shared by the GET handler and the POST
|
|
@@ -12097,16 +12151,41 @@ function mount(router, deps) {
|
|
|
12097
12151
|
var totals = totalsDetail.totals;
|
|
12098
12152
|
// variant_id → { product, hero_media } lookup for the summary
|
|
12099
12153
|
// thumbnails + titles — same shape (and caching) the cart route uses.
|
|
12154
|
+
// The SAME variant row (catalog returns SELECT * — requires_shipping
|
|
12155
|
+
// is on it) feeds the all-digital determination below, so the form
|
|
12156
|
+
// can drop the address requirement on a no-shipment cart without a
|
|
12157
|
+
// second query pass. `shipBySku` caches requires_shipping per variant
|
|
12158
|
+
// so a duplicate cart line never re-fetches. A line whose variant row
|
|
12159
|
+
// is missing is treated as shippable (the never-relax-on-missing-data
|
|
12160
|
+
// stance), so a lookup gap can never accidentally hide a required
|
|
12161
|
+
// address.
|
|
12100
12162
|
var checkoutLookup = {};
|
|
12163
|
+
var shipByVariant = {};
|
|
12101
12164
|
for (var li = 0; li < lines.length; li += 1) {
|
|
12102
12165
|
var lvId = lines[li].variant_id;
|
|
12103
|
-
if (checkoutLookup[lvId]) continue;
|
|
12166
|
+
if (checkoutLookup[lvId] !== undefined) continue;
|
|
12104
12167
|
var lv = await deps.catalog.variants.get(lvId);
|
|
12105
|
-
if (!lv) {
|
|
12168
|
+
if (!lv) {
|
|
12169
|
+
checkoutLookup[lvId] = null;
|
|
12170
|
+
shipByVariant[lvId] = true; // missing variant → treat as shippable
|
|
12171
|
+
continue;
|
|
12172
|
+
}
|
|
12106
12173
|
var lprod = await deps.catalog.products.get(lv.product_id);
|
|
12107
12174
|
var lmedia = await deps.catalog.media.listForProduct(lv.product_id);
|
|
12108
12175
|
checkoutLookup[lvId] = { product: lprod, hero_media: lmedia.length ? lmedia[0] : null };
|
|
12176
|
+
// requires_shipping defaults to 1 (shippable) when the column is
|
|
12177
|
+
// absent — mirrors checkout._buildQuote's `!!v.requires_shipping`
|
|
12178
|
+
// with a null-safe default of shippable.
|
|
12179
|
+
shipByVariant[lvId] = lv.requires_shipping == null || Number(lv.requires_shipping) === 1;
|
|
12180
|
+
}
|
|
12181
|
+
// All-digital = a non-empty cart where NO line needs shipping. The
|
|
12182
|
+
// service tier already completes such an order (checkout's
|
|
12183
|
+
// digital_none fallback); this lets the form drop the address gate.
|
|
12184
|
+
var anyShippable = false;
|
|
12185
|
+
for (var si = 0; si < lines.length; si += 1) {
|
|
12186
|
+
if (shipByVariant[lines[si].variant_id] !== false) { anyShippable = true; break; }
|
|
12109
12187
|
}
|
|
12188
|
+
var allDigital = lines.length > 0 && !anyShippable;
|
|
12110
12189
|
// A signed-in customer drives two best-effort lookups, both keyed
|
|
12111
12190
|
// off the same auth env: the loyalty balance (for the redeem field)
|
|
12112
12191
|
// and the default shipping address (to pre-fill the form). Either
|
|
@@ -12170,6 +12249,9 @@ function mount(router, deps) {
|
|
|
12170
12249
|
lines: lines, totals: totals, totals_detail: totalsDetail,
|
|
12171
12250
|
shop_name: shopName, theme: theme,
|
|
12172
12251
|
product_lookup: checkoutLookup,
|
|
12252
|
+
// Every cart line is a no-shipment good → the form drops the
|
|
12253
|
+
// address requirement (and the POST gate mirrors the flag).
|
|
12254
|
+
all_digital: allDigital,
|
|
12173
12255
|
paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
|
|
12174
12256
|
loyalty_balance: loyaltyBalance,
|
|
12175
12257
|
loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
|
|
@@ -12334,7 +12416,13 @@ function mount(router, deps) {
|
|
|
12334
12416
|
}
|
|
12335
12417
|
var shipTo = _shipToFromBody(body);
|
|
12336
12418
|
try {
|
|
12337
|
-
|
|
12419
|
+
// A cart whose every line is a no-shipment good needs no shipping
|
|
12420
|
+
// address — only email + name + a country for the tax quote. The
|
|
12421
|
+
// form rendered the address block optional (all_digital); the gate
|
|
12422
|
+
// here mirrors that so the two never disagree. A mixed/physical
|
|
12423
|
+
// cart keeps the full requirement set.
|
|
12424
|
+
var coAllDigital = await _cartAllDigital(await deps.cart.listLines(c.id));
|
|
12425
|
+
_requireCheckoutFields(body, shipTo, coAllDigital);
|
|
12338
12426
|
// default_shipping_id may be a literal string or an
|
|
12339
12427
|
// operator-supplied async resolver (e.g. backed by the
|
|
12340
12428
|
// config primitive) so re-reads happen per request without
|
package/package.json
CHANGED