@blamejs/blamejs-shop 0.1.31 → 0.1.33

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.
Files changed (98) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +4 -0
  3. package/lib/admin.js +18 -10
  4. package/lib/asset-manifest.json +17 -5
  5. package/lib/bundles.js +28 -0
  6. package/lib/checkout.js +48 -2
  7. package/lib/storefront.js +792 -45
  8. package/lib/vendor/MANIFEST.json +2 -2
  9. package/lib/vendor/blamejs/CHANGELOG.md +8 -0
  10. package/lib/vendor/blamejs/README.md +3 -1
  11. package/lib/vendor/blamejs/api-snapshot.json +99 -2
  12. package/lib/vendor/blamejs/index.js +3 -0
  13. package/lib/vendor/blamejs/lib/crypto-oprf.js +110 -0
  14. package/lib/vendor/blamejs/lib/network-tsig.js +404 -0
  15. package/lib/vendor/blamejs/lib/network.js +1 -0
  16. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +44 -9
  17. package/lib/vendor/blamejs/lib/vendor/noble-curves.cjs +19 -0
  18. package/lib/vendor/blamejs/lib/worm.js +246 -0
  19. package/lib/vendor/blamejs/package.json +1 -1
  20. package/lib/vendor/blamejs/release-notes/v0.12.x.json +1844 -0
  21. package/lib/vendor/blamejs/release-notes/v0.13.0.json +22 -0
  22. package/lib/vendor/blamejs/release-notes/v0.13.1.json +18 -0
  23. package/lib/vendor/blamejs/scripts/vendor-update.sh +11 -1
  24. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +3 -1
  25. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-oprf.test.js +101 -0
  26. package/lib/vendor/blamejs/test/layer-0-primitives/network-tsig.test.js +149 -0
  27. package/lib/vendor/blamejs/test/layer-0-primitives/sandbox.test.js +2 -2
  28. package/lib/vendor/blamejs/test/layer-0-primitives/testing.test.js +3 -3
  29. package/lib/vendor/blamejs/test/layer-0-primitives/worm.test.js +126 -0
  30. package/package.json +2 -2
  31. package/lib/vendor/blamejs/release-notes/v0.12.0.json +0 -64
  32. package/lib/vendor/blamejs/release-notes/v0.12.1.json +0 -32
  33. package/lib/vendor/blamejs/release-notes/v0.12.10.json +0 -65
  34. package/lib/vendor/blamejs/release-notes/v0.12.11.json +0 -39
  35. package/lib/vendor/blamejs/release-notes/v0.12.12.json +0 -48
  36. package/lib/vendor/blamejs/release-notes/v0.12.13.json +0 -31
  37. package/lib/vendor/blamejs/release-notes/v0.12.14.json +0 -18
  38. package/lib/vendor/blamejs/release-notes/v0.12.15.json +0 -27
  39. package/lib/vendor/blamejs/release-notes/v0.12.16.json +0 -18
  40. package/lib/vendor/blamejs/release-notes/v0.12.17.json +0 -22
  41. package/lib/vendor/blamejs/release-notes/v0.12.18.json +0 -22
  42. package/lib/vendor/blamejs/release-notes/v0.12.19.json +0 -22
  43. package/lib/vendor/blamejs/release-notes/v0.12.2.json +0 -45
  44. package/lib/vendor/blamejs/release-notes/v0.12.20.json +0 -18
  45. package/lib/vendor/blamejs/release-notes/v0.12.21.json +0 -27
  46. package/lib/vendor/blamejs/release-notes/v0.12.22.json +0 -18
  47. package/lib/vendor/blamejs/release-notes/v0.12.23.json +0 -18
  48. package/lib/vendor/blamejs/release-notes/v0.12.24.json +0 -18
  49. package/lib/vendor/blamejs/release-notes/v0.12.25.json +0 -18
  50. package/lib/vendor/blamejs/release-notes/v0.12.26.json +0 -30
  51. package/lib/vendor/blamejs/release-notes/v0.12.27.json +0 -26
  52. package/lib/vendor/blamejs/release-notes/v0.12.28.json +0 -26
  53. package/lib/vendor/blamejs/release-notes/v0.12.29.json +0 -31
  54. package/lib/vendor/blamejs/release-notes/v0.12.3.json +0 -23
  55. package/lib/vendor/blamejs/release-notes/v0.12.30.json +0 -18
  56. package/lib/vendor/blamejs/release-notes/v0.12.31.json +0 -18
  57. package/lib/vendor/blamejs/release-notes/v0.12.32.json +0 -27
  58. package/lib/vendor/blamejs/release-notes/v0.12.33.json +0 -31
  59. package/lib/vendor/blamejs/release-notes/v0.12.34.json +0 -18
  60. package/lib/vendor/blamejs/release-notes/v0.12.35.json +0 -22
  61. package/lib/vendor/blamejs/release-notes/v0.12.36.json +0 -18
  62. package/lib/vendor/blamejs/release-notes/v0.12.37.json +0 -27
  63. package/lib/vendor/blamejs/release-notes/v0.12.38.json +0 -18
  64. package/lib/vendor/blamejs/release-notes/v0.12.39.json +0 -18
  65. package/lib/vendor/blamejs/release-notes/v0.12.4.json +0 -19
  66. package/lib/vendor/blamejs/release-notes/v0.12.40.json +0 -18
  67. package/lib/vendor/blamejs/release-notes/v0.12.41.json +0 -18
  68. package/lib/vendor/blamejs/release-notes/v0.12.42.json +0 -18
  69. package/lib/vendor/blamejs/release-notes/v0.12.43.json +0 -18
  70. package/lib/vendor/blamejs/release-notes/v0.12.44.json +0 -18
  71. package/lib/vendor/blamejs/release-notes/v0.12.45.json +0 -18
  72. package/lib/vendor/blamejs/release-notes/v0.12.46.json +0 -18
  73. package/lib/vendor/blamejs/release-notes/v0.12.47.json +0 -18
  74. package/lib/vendor/blamejs/release-notes/v0.12.48.json +0 -22
  75. package/lib/vendor/blamejs/release-notes/v0.12.49.json +0 -31
  76. package/lib/vendor/blamejs/release-notes/v0.12.5.json +0 -40
  77. package/lib/vendor/blamejs/release-notes/v0.12.50.json +0 -18
  78. package/lib/vendor/blamejs/release-notes/v0.12.51.json +0 -18
  79. package/lib/vendor/blamejs/release-notes/v0.12.52.json +0 -18
  80. package/lib/vendor/blamejs/release-notes/v0.12.53.json +0 -18
  81. package/lib/vendor/blamejs/release-notes/v0.12.54.json +0 -18
  82. package/lib/vendor/blamejs/release-notes/v0.12.55.json +0 -18
  83. package/lib/vendor/blamejs/release-notes/v0.12.56.json +0 -27
  84. package/lib/vendor/blamejs/release-notes/v0.12.57.json +0 -18
  85. package/lib/vendor/blamejs/release-notes/v0.12.58.json +0 -22
  86. package/lib/vendor/blamejs/release-notes/v0.12.6.json +0 -45
  87. package/lib/vendor/blamejs/release-notes/v0.12.60.json +0 -18
  88. package/lib/vendor/blamejs/release-notes/v0.12.61.json +0 -18
  89. package/lib/vendor/blamejs/release-notes/v0.12.62.json +0 -18
  90. package/lib/vendor/blamejs/release-notes/v0.12.63.json +0 -27
  91. package/lib/vendor/blamejs/release-notes/v0.12.64.json +0 -18
  92. package/lib/vendor/blamejs/release-notes/v0.12.65.json +0 -27
  93. package/lib/vendor/blamejs/release-notes/v0.12.66.json +0 -18
  94. package/lib/vendor/blamejs/release-notes/v0.12.68.json +0 -27
  95. package/lib/vendor/blamejs/release-notes/v0.12.69.json +0 -27
  96. package/lib/vendor/blamejs/release-notes/v0.12.7.json +0 -86
  97. package/lib/vendor/blamejs/release-notes/v0.12.8.json +0 -81
  98. package/lib/vendor/blamejs/release-notes/v0.12.9.json +0 -61
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - v0.1.33 (2026-05-26) — **Search filters and synonyms — narrow results by facet, and match what shoppers mean, not just what they typed.** Search gains filters and smarter matching. A search result page now shows facet groups — collection, price range, and in-stock — as filter controls; choosing one narrows the results, the counts beside each option reflect the current result set, and choices combine across groups. Active filters show with a one-click clear and carry through pagination rather than dropping on page two. All filtering is server-side from query parameters, so it works with no client JavaScript. Separately, the query is expanded through an operator-curated synonym and typo vocabulary before the catalog is searched, so a search for "tee" matches "t-shirt" and a near-miss spelling still finds the product, with a "Showing results for" note when the query was corrected. Filters and synonym matching work identically whether the page is served from the edge or the container. This release also moves the shipped theme's stylesheets and island scripts to content-fingerprinted filenames so an asset and the page that references it can never fall out of sync. **Added:** *Faceted search filters* — A search result page shows facet groups — collection, price range, and in-stock availability — as server-rendered filter controls. Selecting a facet narrows the results and is reflected in the URL (for example `/search?q=shirt&collection=summer&in_stock=1`), the count beside each option reflects how many results it would yield against the current query, and facets combine across groups. Active filters render as removable chips with a clear-all path, and the active filters carry through result pagination so page two keeps the filter set. All filtering is server-side from the query string — no client JavaScript — and unknown facet keys, out-of-range price values, and garbage filter values are ignored rather than erroring. With filters applied and no matches, the page shows an empty state with a clear-filters link. · *Synonym and typo-tolerant query matching* — Before the catalog is searched, the query is expanded through an operator-curated vocabulary: synonym groups (so "tee" matches "t-shirt"), common typo corrections, and stopword removal. When the query is corrected or expanded, the page shows a "Showing results for" note. A query that still matches nothing falls back to the raw terms so search never silently empties on an unknown word. **Changed:** *Search filters and synonyms render identically at the edge and the container* — The faceting and synonym logic runs in both the edge worker and the container search path, producing the same results and the same filter chrome whichever serves the page. The edge path reads the catalog and the facet/synonym vocabulary directly from the database, missing-table-resilient, so a shop without the optional facet configuration still searches normally. · *Theme stylesheets and scripts use content-fingerprinted filenames* — The shipped default theme's stylesheets and island scripts are now referenced by a content-hashed filename (`main.<hash>.css`) rather than a `?v=` query, emitted identically by the edge and container renderers and served from object storage under the fingerprinted key. Each URL maps to exactly one byte-content, so an asset and the page that references it can never disagree on a Subresource Integrity digest, and previously-served pages keep working across an asset change. The edge stylesheet link now also carries an integrity attribute, matching the container.
12
+
13
+ - v0.1.32 (2026-05-26) — **Bundles and quantity discounts — sell products together at a set price, and reward buying more.** Two merchandising surfaces go live on the product page and in the cart, both priced authoritatively on the server. A product that belongs to a bundle shows a "Bundle & save" offer with its member products and the bundle price against the sum of the parts; one action adds the whole bundle to the cart at that price, all-or-nothing, and the discount is split across the member lines so the cart subtotal equals the quoted bundle price. Separately, a product with quantity breaks shows its price tiers ("1–2 / 3–5 / 6+") on the page, and the cart applies the right unit price for each line's quantity — recomputed on every cart render and again at checkout, so the line, cart, and order totals all reflect the break. The client never sends a price: it sends a bundle code or a quantity, and the server prices both from the live catalog. A bundle whose member is archived or out of stock is shown unavailable rather than adding a broken line, and a missing offer simply renders nothing. **Added:** *Bundles on the product page* — A product that is part of one or more bundles shows a "Bundle & save" offer listing the bundle's member products, the bundle price, and the saving against buying the parts separately. `POST /cart/bundle` adds the whole bundle to the cart in one action: the bundle price is recomputed from the live catalog and allocated across the member lines (proportional to each member's list price, with the rounding remainder on the last line) so the cart subtotal equals the quoted price. The add is atomic — if any member is archived or out of stock the bundle is shown unavailable and the action adds nothing rather than a partial set. A product in no bundle shows no offer; an unpriceable bundle (a missing member price or a currency mismatch) is shown unavailable, never a server error. The offer renders identically from the edge worker and the container. · *Quantity discounts on the product page and in the cart* — A product with quantity breaks shows its price tiers on the page (for example 1–2, 3–5, 6+ at descending unit prices). In the cart, each line is priced at the unit price for its quantity — applied server-side on every cart render so changing the quantity re-prices the line, and applied again at checkout so the per-line price and the order total written onto the order reflect the break, not the list price. A quantity below the first tier falls back to the base price; a quantity above the top tier takes the top tier. Stacks with the rest of the cart math. The tier table renders identically from the edge worker and the container. **Fixed:** *Themed product template carries the compare control* — The file-backed default product template now includes the Add-to-compare slot, matching the built-in render path so the comparison control appears on the product page whichever template is in use.
14
+
11
15
  - v0.1.31 (2026-05-26) — **Product compare — a side-by-side comparison table any shopper can build from the product pages.** Shoppers can now line products up against each other. Every product page gains an "Add to compare" control; a "Compare" link in the footer opens a side-by-side table of the chosen products — image, price, availability, and each product's attributes in aligned rows. No sign-in is required: the comparison basket rides the visitor's existing sealed session cookie, and a signed-in shopper's customer id is carried alongside. The basket holds up to four products (a fifth add is refused with a notice rather than quietly dropping one), the toggle is idempotent, and a product that goes archived or out of stock resolves out of the table with a remove control instead of breaking the page. The control and table render identically from the edge and the container, and the comparison page reuses the catalog grid styling, so no new stylesheet ships. **Added:** *Add to compare on the product page* — Each product page shows an "Add to compare" / "Remove from compare" control reflecting whether the product is already in the basket. `POST /compare/toggle` adds or removes it and redirects back to where the shopper was (the redirect target is validated, so a crafted `return_to` can't bounce the visitor off-site). Comparing needs no account — the basket is keyed on the visitor's existing sealed session cookie; a signed-in shopper's customer id is captured alongside. The basket is capped at four products: a fifth add is refused with a notice and the basket is left unchanged. · *The comparison table* — `GET /compare` renders the selected products side by side — image, title, price, availability, and the per-product attributes resolved through the catalog, in aligned rows. Each column has a remove control, and `POST /compare/clear` empties the whole basket. A product that has since been archived or sold out shows in a gone/out-of-stock state with a remove control rather than failing the render. An empty basket shows a friendly empty state and mints no cookie. A "Compare" link sits in the footer on every page.
12
16
 
13
17
  - v0.1.30 (2026-05-26) — **Product Q&A — customer questions and seller answers on the product page.** Products gain a question-and-answer thread alongside reviews. A signed-in shopper asks a question from the product page; it lands pending and surfaces only after an operator approves it. Operators moderate at `/admin/questions`: a status-filtered queue of every question across the catalog, and a per-question detail page where they approve or reject the question, post the authoritative seller answer, approve / reject individual answers, and pin one answer as the definitive 'top answer'. Approved questions and their approved answers render on the product page in both the edge and container render paths, reusing the reviews section's styling so no new CSS ships. As with reviews, a missing Q&A table degrades the product page to an empty Q&A state rather than failing the render. **Added:** *Ask a question on the product page* — `GET /products/:slug/question` renders an auth-gated question form; `POST /products/:slug/question` submits it. Asking requires a signed-in customer — a logged-out shopper is redirected to sign in. The question is stored pending and is not shown on the product page until an operator approves it. A malformed submission re-renders the form with the reason and a 400; an unknown product is a 404. · *Published Q&A on the product page* — The product page shows approved questions with their approved answers below the reviews section. Each answer carries a source badge — 'Answered by the seller', 'Customer answer', or 'Automated answer' — and the operator-pinned answer is flagged 'Top answer'. The empty state invites the first question. Rendered identically by the edge worker and the container so the page is the same whichever serves it; the read degrades to the empty state when the Q&A tables aren't present. · *Q&A moderation console* — `/admin/questions` is the question queue across all products, with pending / approved / rejected status-filter chips. Each question opens a detail page showing its full answer thread (every status), an approve / reject control for the question, a form to post the seller's answer, and per-answer approve / reject / pin controls. Posting an answer leaves it pending; approving it publishes it on the product page. Pinning floats one operator answer to the top of the thread. Every path content-negotiates: a bearer-token client gets a JSON contract, a signed-in browser gets the HTML console. An unknown id or a refused transition redirects back with a notice, never a 500. · *productQA read APIs for the storefront and console* — `answersForQuestion` returns the approved answers under a question (pinned first) for the product-page thread; `listAnswersForQuestion` returns every answer regardless of status for the moderation detail; `listQuestionsByStatus` and `listAnswersByStatus` return the cross-product moderation queues. **Changed:** *Console nav gains Q&A; product page wires the Q&A primitive* — The signed-in admin nav includes Q&A, shown only when the Q&A primitive is wired so the link never points at an unmounted route. The storefront and admin share one Q&A instance, wired with the customer primitive so an authenticated questioner's id is verified before the row is stored.
package/README.md CHANGED
@@ -72,10 +72,14 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
72
72
  | **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
73
73
  | **`lib/referrals.js`** + **`lib/referral-leaderboard.js`** | Refer-a-friend with two-sided rewards. `referrals` issues each customer an 8-character code (32-glyph confusion-resistant alphabet via `b.crypto.generateBytes`) and tracks each referred friend through the funnel (invited → visited → joined → converted); referred emails are stored hash-only (`b.crypto.namespaceHash`), never plaintext. `referral-leaderboard` sits on top for top-referrer rankings + operator-tunable tier bonuses. Customers see all of it at `/account/referrals` — their code, an absolute shareable link, the friends they've referred (funnel stage + dates, no personal data), and an in-account leaderboard (rank + initials only). A public `/r/<code>` landing captures an inbound referral into a short-lived sealed first-party cookie (first-touch), which attributes the new customer to the referrer when they sign up by passkey / Google / Apple — self-referral and double-attribution guarded. When a referred customer's first order reaches paid, the order FSM's paid transition marks that referral converted and bumps the referrer's count fire-and-forget, deduped on the order id so a re-delivered webhook never double-counts; guest orders and a customer's later orders don't qualify. Issuing the payout itself (gift card / store credit / loyalty points) is an explicit operator action via the referrals reward API, so the reward instrument and any fraud review stay the operator's. |
74
74
  | **`lib/product-compare.js`** | Side-by-side product comparison, no sign-in required. Each product page gets an "Add to compare" / "Remove from compare" control; `POST /compare/toggle` adds or removes the product and `GET /compare` renders the comparison table — image, price, availability, and the per-product attributes resolved through the catalog. The basket is keyed on the visitor's existing sealed session cookie (a signed-in shopper's customer id rides alongside), capped at four products (a fifth add is refused with a notice rather than silently dropping one), and is idempotent. Products that go archived or out of stock resolve out of the table with a remove control instead of breaking the render; an empty basket shows a friendly empty state. `POST /compare/clear` empties it. Linked from the footer on every page. |
75
+ | **`lib/bundles.js`** | Sell products together at a set price. A product page shows the bundles it belongs to as a "Bundle & save" offer — the member products, the bundle price, and the saving against the parts. `POST /cart/bundle` adds the whole bundle in one action: the price is recomputed from the live catalog and allocated across the member lines (proportional to list price, remainder on the last line) so the cart subtotal matches the quoted price. The add is atomic — an archived or out-of-stock member shows the bundle unavailable and adds nothing rather than a partial set; an unpriceable bundle (missing member price / currency mismatch) is shown unavailable, never a 500. The client sends only the bundle code; the server prices it. The offer renders identically from the edge worker and the container. |
76
+ | **`lib/quantity-discounts.js`** | Reward buying more. A product with quantity breaks shows its price tiers on the page (e.g. 1–2 / 3–5 / 6+ at descending unit prices). In the cart, each line is priced at the unit price for its quantity — reapplied on every cart render so changing the quantity re-prices, and again at checkout so the per-line price and order total written onto the order reflect the break, not the list price. A quantity under the first tier falls back to the base price; over the top tier takes the top tier. The client sends only a quantity; the server prices it. The tier table renders identically from the edge worker and the container. |
75
77
  | **`lib/recently-viewed.js`** | Signed-in customer browse history. A product-page visit records the view server-side against the customer's account (drop-silent — never blocks the page); `/account/recently-viewed` lists them newest-first as a grid with a Clear-history control. De-duped + capped per customer, archived products drop out, login-gated. Guest/session history is opt-in (a client beacon) and not shipped — the lib's `forSession` + `merge` support it. |
76
78
  | **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. |
77
79
  | **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
78
80
  | **`lib/category-navigation.js`** | Hierarchical category tree surfaced as public browse pages. `GET /categories` lists the active top-level categories as a card grid; `GET /categories/:slug` renders one category — its title and optional description, a breadcrumb chain from the catalog root down to the current category, an optional hero image, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived / unpublished categories drop out of every surface. Public, no sign-in; an unknown, archived, or malformed slug is a 404 (never a 500), and a category with no children renders a graceful empty state. Linked from the footer on every page. The tree itself (define / move / reorder / archive, with cycle defense bounded by `MAX_TREE_DEPTH`) is operator-managed through the primitive's write API. |
81
+ | **`lib/search-facets.js`** | Filterable search. A search result page renders facet groups — collection, price range, in-stock — as server-rendered controls; selecting one narrows the results and rides the URL query string (`?q=…&collection=…&in_stock=1`), counts beside each option reflect the current result set, facets combine across groups, and active filters show as removable chips with a clear-all path that survives result pagination. All filtering is server-side from the query string (no client JS); unknown facet keys, out-of-range prices, and garbage values are ignored rather than erroring; an empty filtered result shows a clear-filters state. Runs identically at the edge worker and the container — the edge reads the catalog and facet registry straight from D1, missing-table-resilient. |
82
+ | **`lib/search-synonyms.js`** | Typo-tolerant, synonym-aware query matching. Before the catalog is searched, the query is expanded through an operator-curated vocabulary — synonym groups (so "tee" matches "t-shirt"), common typo corrections, and stopword removal — and the page shows a "Showing results for" note when the query was corrected or expanded. A query that still matches nothing falls back to the raw terms so search never silently empties on an unknown word. Shared rewrite instance; runs identically at the edge and the container. |
79
83
  | **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. Customers view + cancel their own subscriptions at `/account/subscriptions` (ownership-checked; cancel mounts when the payment handle is wired). |
80
84
  | **`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). |
81
85
  | **`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. |
package/lib/admin.js CHANGED
@@ -41,22 +41,30 @@ var AUDIT_NAMESPACE = "shop_admin";
41
41
  // <style> block (or inline style="" attributes) is refused by the strict
42
42
  // `style-src 'self'` CSP that governs container-served routes, which is
43
43
  // why the console renders unstyled when those are inlined. `'self'`
44
- // allows this file; the deploy's R2 asset sync uploads it. The `?v=` is
45
- // the per-release cache-buster; the sha384 Subresource Integrity digest
44
+ // allows this file; the deploy's R2 asset sync uploads it. The asset is
45
+ // referenced by its content-fingerprinted name (`admin.<hash>.css`), so
46
+ // the hash is the cache-buster — no `?v=` query — and the URL maps
47
+ // one-to-one onto a byte-content. The sha384 Subresource Integrity digest
46
48
  // (b.crypto.sri, W3C SRI 1.0) makes the browser reject a tampered or
47
- // stale object. Same-origin, so no `crossorigin` is needed. The digest +
48
- // version come from the build-time manifest (lib/asset-manifest.json)
49
- // rather than hashing the file at render time, so they're present in the
50
- // container image too — which doesn't ship the theme asset files. A key
51
- // absent from the manifest yields no attribute.
49
+ // mismatched object. Same-origin, so no `crossorigin` is needed. The
50
+ // digest + fingerprinted path come from the build-time manifest
51
+ // (lib/asset-manifest.json) rather than hashing the file at render time,
52
+ // so they're present in the container image too — which doesn't ship the
53
+ // theme asset files. A key absent from the manifest yields the plain path
54
+ // and no integrity attribute.
52
55
  var _assetManifest = require("./asset-manifest.json");
53
- var _ASSET_VERSION = _assetManifest.version;
54
56
  function _assetSri(relUnderThemeAssets) {
55
- return _assetManifest.assets[relUnderThemeAssets] || null;
57
+ var entry = _assetManifest.assets[relUnderThemeAssets];
58
+ return (entry && entry.integrity) || null;
59
+ }
60
+ function _assetUrl(relUnderThemeAssets) {
61
+ var entry = _assetManifest.assets[relUnderThemeAssets];
62
+ var fp = (entry && entry.fingerprinted) || relUnderThemeAssets;
63
+ return "/assets/themes/default/" + fp;
56
64
  }
57
65
  function _adminStylesheetLink() {
58
66
  var sri = _assetSri("css/admin.css");
59
- return "<link rel=\"stylesheet\" href=\"/assets/themes/default/css/admin.css?v=" + _ASSET_VERSION + "\"" +
67
+ return "<link rel=\"stylesheet\" href=\"" + _assetUrl("css/admin.css") + "\"" +
60
68
  (sri ? " integrity=\"" + sri + "\"" : "") + ">";
61
69
  }
62
70
 
@@ -1,9 +1,21 @@
1
1
  {
2
- "version": "0.1.31",
2
+ "version": "0.1.33",
3
3
  "assets": {
4
- "css/admin.css": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
5
- "css/main.css": "sha384-qBY97FWZDGOploZRppwrDi0A/+R4R3iCFtyS1dfrAlqHNEPluPmY0R3HB9cJntyV",
6
- "js/passkey-login.js": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
7
- "js/passkey-register.js": "sha384-RaSkJbTpc6ClfPy5QyaY96doIqBqa9CLeIB1CzWnL05pSSJEA9IrIK0S7V7igRxY"
4
+ "css/admin.css": {
5
+ "integrity": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
6
+ "fingerprinted": "css/admin.4dc78dfc45d0736d.css"
7
+ },
8
+ "css/main.css": {
9
+ "integrity": "sha384-A2m9cYLHIlIrFvTluTOaZz9r8F90yWIKd3SHOlyNwQwtaOBrzlsn/NDR1vso6dSF",
10
+ "fingerprinted": "css/main.91c0dfab0198569c.css"
11
+ },
12
+ "js/passkey-login.js": {
13
+ "integrity": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
14
+ "fingerprinted": "js/passkey-login.f3d6787bdd5e20df.js"
15
+ },
16
+ "js/passkey-register.js": {
17
+ "integrity": "sha384-RaSkJbTpc6ClfPy5QyaY96doIqBqa9CLeIB1CzWnL05pSSJEA9IrIK0S7V7igRxY",
18
+ "fingerprinted": "js/passkey-register.8f6ee49284ab4226.js"
19
+ }
8
20
  }
9
21
  }
package/lib/bundles.js CHANGED
@@ -333,6 +333,33 @@ function create(opts) {
333
333
  return { rows: r.rows, next_cursor: next };
334
334
  }
335
335
 
336
+ // ---- bundlesForComponent ----------------------------------------------
337
+
338
+ // Every bundle that lists `sku` as a direct component, newest-edited
339
+ // first. Backs the storefront "Bundle & save" rail — a product page
340
+ // shows the bundles its variant SKUs belong to. Returns the same
341
+ // shape as getBundle (definition + ordered components) so the caller
342
+ // can price + render each without a second round trip per bundle.
343
+ // Only direct membership is resolved; a bundle that contains `sku`
344
+ // transitively (via a nested child bundle) is reachable by walking
345
+ // its own bundlesForComponent, which the storefront doesn't surface
346
+ // on a leaf PDP.
347
+ async function bundlesForComponent(sku) {
348
+ _sku(sku, "sku");
349
+ var r = await query(
350
+ "SELECT DISTINCT bc.bundle_sku FROM bundle_components bc " +
351
+ "JOIN bundles b ON b.bundle_sku = bc.bundle_sku " +
352
+ "WHERE bc.sku = ?1 ORDER BY b.updated_at DESC, b.bundle_sku DESC",
353
+ [sku],
354
+ );
355
+ var out = [];
356
+ for (var i = 0; i < r.rows.length; i += 1) {
357
+ var full = await getBundle(r.rows[i].bundle_sku);
358
+ if (full) out.push(full);
359
+ }
360
+ return out;
361
+ }
362
+
336
363
  // ---- expand ------------------------------------------------------------
337
364
 
338
365
  // Flatten a bundle into its leaf components with multipliers applied.
@@ -569,6 +596,7 @@ function create(opts) {
569
596
  defineBundle: defineBundle,
570
597
  getBundle: getBundle,
571
598
  listBundles: listBundles,
599
+ bundlesForComponent: bundlesForComponent,
572
600
  expand: expand,
573
601
  priceBundle: priceBundle,
574
602
  updateBundle: updateBundle,
package/lib/checkout.js CHANGED
@@ -121,6 +121,47 @@ function create(deps) {
121
121
  // checkout never re-derives the conversion constant. Disabled when
122
122
  // the handle is absent — the rest of the flow is unchanged.
123
123
  var loyalty = deps.loyalty || null;
124
+ // Optional quantity-discount engine. When wired, each cart line is
125
+ // repriced at confirm/quote time by reapplying the active per-line
126
+ // quantity break at the line's current quantity, so the order total
127
+ // (and the per-line price written onto the order) reflects the break
128
+ // — never the bare add-time snapshot. Disabled when absent: lines
129
+ // pass through at their snapshot price. The repricing is the same
130
+ // server-authoritative computation the cart page renders, so the
131
+ // "review your order" subtotal and the charged amount agree.
132
+ var quantityDiscounts = deps.quantityDiscounts || null;
133
+
134
+ // Reprice a list of cart lines through the quantity-discount engine.
135
+ // Returns a shallow copy with `unit_amount_minor` overwritten by the
136
+ // discounted unit for each line's SKU at its quantity. A line whose
137
+ // SKU has no active tier (or any apply failure) keeps its snapshot.
138
+ // No-op pass-through when the engine isn't wired.
139
+ async function _repriceLines(c, lines) {
140
+ if (!quantityDiscounts) return lines;
141
+ var out = [];
142
+ for (var i = 0; i < lines.length; i += 1) {
143
+ var l = lines[i];
144
+ var unit = l.unit_amount_minor;
145
+ try {
146
+ var v = await catalog.variants.get(l.variant_id);
147
+ var applied = await quantityDiscounts.applyToLine({
148
+ line: {
149
+ sku: l.sku,
150
+ quantity: l.qty,
151
+ unit_price_minor: l.unit_amount_minor,
152
+ product_id: v ? v.product_id : undefined,
153
+ },
154
+ });
155
+ unit = applied.discounted_unit_minor;
156
+ } catch (_e) {
157
+ // Unpriceable line keeps its snapshot — the quantity-break is
158
+ // an adjustment, never a hard gate on the buy path.
159
+ unit = l.unit_amount_minor;
160
+ }
161
+ out.push(Object.assign({}, l, { unit_amount_minor: unit }));
162
+ }
163
+ return out;
164
+ }
124
165
 
125
166
  // Validate a gift-card code against a priced quote: the card exists,
126
167
  // is active, not expired, and matches the order currency. Returns
@@ -289,8 +330,13 @@ function create(deps) {
289
330
  var c = await cart.get(input.cart_id);
290
331
  if (!c) throw new TypeError("checkout: cart " + input.cart_id + " not found");
291
332
  if (c.status !== "active") throw new TypeError("checkout: cart status is " + c.status + ", cannot quote");
292
- var lines = await cart.listLines(c.id);
293
- if (lines.length === 0) throw new TypeError("checkout: cart is empty");
333
+ var rawLines = await cart.listLines(c.id);
334
+ if (rawLines.length === 0) throw new TypeError("checkout: cart is empty");
335
+ // Reapply the active quantity-break for each line at confirm time —
336
+ // the order total + the per-line price written onto the order
337
+ // reflect the break, not the bare add-time snapshot. Pass-through
338
+ // when the engine isn't wired.
339
+ var lines = await _repriceLines(c, rawLines);
294
340
 
295
341
  // Variants for shipping weight + requires_shipping flag.
296
342
  var enrichedLines = [];