@blamejs/blamejs-shop 0.1.31 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - 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.
12
+
11
13
  - 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
14
 
13
15
  - 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,6 +72,8 @@ 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. |
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.1.31",
2
+ "version": "0.1.32",
3
3
  "assets": {
4
4
  "css/admin.css": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
5
- "css/main.css": "sha384-qBY97FWZDGOploZRppwrDi0A/+R4R3iCFtyS1dfrAlqHNEPluPmY0R3HB9cJntyV",
5
+ "css/main.css": "sha384-A2m9cYLHIlIrFvTluTOaZz9r8F90yWIKd3SHOlyNwQwtaOBrzlsn/NDR1vso6dSF",
6
6
  "js/passkey-login.js": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
7
7
  "js/passkey-register.js": "sha384-RaSkJbTpc6ClfPy5QyaY96doIqBqa9CLeIB1CzWnL05pSSJEA9IrIK0S7V7igRxY"
8
8
  }
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 = [];
package/lib/storefront.js CHANGED
@@ -745,10 +745,12 @@ var PRODUCT_PAGE =
745
745
  " </table>\n" +
746
746
  " </div>\n" +
747
747
  " </div>\n" +
748
+ " RAW_QTYBREAK_PLACEHOLDER\n" +
748
749
  " RAW_WISHLIST_PLACEHOLDER\n" +
749
750
  " RAW_COMPARE_PLACEHOLDER\n" +
750
751
  " </div>\n" +
751
752
  " </div>\n" +
753
+ " RAW_BUNDLES_PLACEHOLDER\n" +
752
754
  " RAW_REVIEWS_PLACEHOLDER\n" +
753
755
  " RAW_QA_PLACEHOLDER\n" +
754
756
  "</section>\n";
@@ -890,6 +892,109 @@ function _buildReviews(summary, reviews, ctaHtml) {
890
892
  "</section>";
891
893
  }
892
894
 
895
+ // Builds the PDP "Bundle & save" rail from the offers the route
896
+ // resolved. Each offer carries its component list (with display
897
+ // titles + per-member quantity), the sum-of-parts list price, the
898
+ // bundle price, and the saving — every figure already formatted to a
899
+ // currency string by the route (price math stays server-side; the
900
+ // builder is pure string assembly so the container + edge renderers
901
+ // emit byte-identical markup). An unavailable offer (a member is
902
+ // archived / out of stock) renders disabled with a reason instead of
903
+ // the add form, so a customer never hits a broken atomic add. Returns
904
+ // "" when there are no offers so the PDP shows no empty rail. Mirrors
905
+ // the edge renderer (`worker/render/product.js#_buildBundles`).
906
+ function _buildBundles(offers) {
907
+ return _renderBundles(offers, b.template.escapeHtml);
908
+ }
909
+
910
+ // Shared pure-string assembler. `esc` is the caller's HTML-escaper
911
+ // (b.template.escapeHtml in the container, escapeHtml at the edge) —
912
+ // both produce the same output, so the markup is identical across
913
+ // render paths.
914
+ function _renderBundles(offers, esc) {
915
+ offers = offers || [];
916
+ if (offers.length === 0) return "";
917
+ var cards = "";
918
+ for (var i = 0; i < offers.length; i += 1) {
919
+ var o = offers[i];
920
+ var members = "";
921
+ for (var j = 0; j < o.components.length; j += 1) {
922
+ var c = o.components[j];
923
+ members +=
924
+ "<li class=\"bundle-card__member\">" +
925
+ "<span class=\"bundle-card__member-qty\">" + esc(String(c.quantity)) + "&times;</span> " +
926
+ "<span class=\"bundle-card__member-title\">" + esc(String(c.title)) + "</span> " +
927
+ "<code class=\"bundle-card__member-sku\">" + esc(String(c.sku)) + "</code>" +
928
+ "</li>";
929
+ }
930
+ var pricing =
931
+ "<div class=\"bundle-card__pricing\">" +
932
+ "<span class=\"bundle-card__list\">Buy separately " + esc(o.list_total_str) + "</span>" +
933
+ "<span class=\"bundle-card__price price\">Bundle price " + esc(o.amount_str) + "</span>" +
934
+ (o.discount_str ? "<span class=\"bundle-card__save\">You save " + esc(o.discount_str) + "</span>" : "") +
935
+ "</div>";
936
+ var action;
937
+ if (o.available) {
938
+ action =
939
+ "<form method=\"post\" action=\"/cart/bundle\" class=\"bundle-card__form\">" +
940
+ "<input type=\"hidden\" name=\"bundle_sku\" value=\"" + esc(o.bundle_sku) + "\">" +
941
+ "<button type=\"submit\" class=\"btn-primary btn-primary--sm\">Add bundle to cart</button>" +
942
+ "</form>";
943
+ } else {
944
+ action =
945
+ "<p class=\"bundle-card__unavailable\">" +
946
+ esc(o.unavailable_reason || "This bundle is currently unavailable.") +
947
+ "</p>";
948
+ }
949
+ cards +=
950
+ "<article class=\"bundle-card" + (o.available ? "" : " bundle-card--unavailable") + "\">" +
951
+ "<h3 class=\"bundle-card__title\">" + esc(String(o.title)) + "</h3>" +
952
+ "<ul class=\"bundle-card__members\">" + members + "</ul>" +
953
+ pricing +
954
+ action +
955
+ "</article>";
956
+ }
957
+ return "<section class=\"bundles\" aria-labelledby=\"bundles-title\">" +
958
+ "<h2 id=\"bundles-title\" class=\"bundles__heading\">Bundle &amp; save</h2>" +
959
+ "<div class=\"bundles__grid\">" + cards + "</div>" +
960
+ "</section>";
961
+ }
962
+
963
+ // Builds the PDP quantity-break table for the displayed variant. Each
964
+ // row is a (range label, unit price) pair the route already resolved +
965
+ // formatted (the unit price comes from the quantity-discount
966
+ // primitive's tierBreakdown against the variant's list price — the math
967
+ // is server-side; the builder only lays out strings). Returns "" when
968
+ // the product has no active breaks so the PDP shows no empty table.
969
+ // Mirrors the edge renderer (`worker/render/product.js#_buildQtyBreaks`).
970
+ function _buildQtyBreaks(breaks) {
971
+ return _renderQtyBreaks(breaks, b.template.escapeHtml);
972
+ }
973
+
974
+ function _renderQtyBreaks(breaks, esc) {
975
+ breaks = breaks || [];
976
+ if (breaks.length === 0) return "";
977
+ var rows = "";
978
+ for (var i = 0; i < breaks.length; i += 1) {
979
+ var br = breaks[i];
980
+ rows +=
981
+ "<tr>" +
982
+ "<td class=\"qty-break__range\">" + esc(String(br.label)) + "</td>" +
983
+ "<td class=\"qty-break__unit price\">" + esc(String(br.unit_str)) + "</td>" +
984
+ "</tr>";
985
+ }
986
+ return "<div class=\"qty-breaks\">" +
987
+ "<h2 class=\"qty-breaks__title\">Buy more, save more</h2>" +
988
+ "<div class=\"table-scroll\">" +
989
+ "<table class=\"qty-break-table\">" +
990
+ "<thead><tr><th>Quantity</th><th>Price each</th></tr></thead>" +
991
+ "<tbody>" + rows + "</tbody>" +
992
+ "</table>" +
993
+ "</div>" +
994
+ "<p class=\"qty-breaks__note\">Discount applies automatically in your cart.</p>" +
995
+ "</div>";
996
+ }
997
+
893
998
  var REVIEW_FORM_PAGE =
894
999
  "<section class=\"review-form-page\">\n" +
895
1000
  " <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
@@ -2321,6 +2426,8 @@ function renderProduct(opts) {
2321
2426
  // include the slot; a custom theme opts in by adding it.
2322
2427
  reviews_html: _buildReviews(opts.review_summary, opts.reviews, opts.review_cta),
2323
2428
  qa_html: _buildProductQa(opts.qa_questions, opts.qa_cta),
2429
+ bundles_html: _buildBundles(opts.bundle_offers),
2430
+ qty_breaks_html: _buildQtyBreaks(opts.qty_breaks),
2324
2431
  wishlist_html: _buildWishlist(opts.product.id, opts.wishlist_count),
2325
2432
  compare_html: _buildCompare(opts.product.id),
2326
2433
  asset_css_main: opts.theme.assetUrl("css/main.css"),
@@ -2333,6 +2440,8 @@ function renderProduct(opts) {
2333
2440
  var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
2334
2441
  var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
2335
2442
  var qaHtml = _buildProductQa(opts.qa_questions, opts.qa_cta);
2443
+ var bundlesHtml = _buildBundles(opts.bundle_offers);
2444
+ var qtyBreaksHtml = _buildQtyBreaks(opts.qty_breaks);
2336
2445
  var wishlistHtml = _buildWishlist(opts.product.id, opts.wishlist_count);
2337
2446
  var compareHtml = _buildCompare(opts.product.id);
2338
2447
  var body = _render(PRODUCT_PAGE, {
@@ -2342,8 +2451,10 @@ function renderProduct(opts) {
2342
2451
  })
2343
2452
  .replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
2344
2453
  .replace("RAW_ROWS_PLACEHOLDER", rows)
2454
+ .replace("RAW_QTYBREAK_PLACEHOLDER", qtyBreaksHtml)
2345
2455
  .replace("RAW_WISHLIST_PLACEHOLDER", wishlistHtml)
2346
2456
  .replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
2457
+ .replace("RAW_BUNDLES_PLACEHOLDER", bundlesHtml)
2347
2458
  .replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml)
2348
2459
  .replace("RAW_QA_PLACEHOLDER", qaHtml);
2349
2460
  // Product-specific OpenGraph + Twitter Card values so shares
@@ -3610,6 +3721,204 @@ function mount(router, deps) {
3610
3721
  return { sid: sid, cart: created };
3611
3722
  }
3612
3723
 
3724
+ // ---- bundle + quantity-discount pricing (server-authoritative) --------
3725
+ //
3726
+ // Every price below is recomputed from the catalog + the bundle /
3727
+ // quantity-discount primitives on each render. The client never
3728
+ // sends a price; the cart line's stored snapshot is the add-time
3729
+ // catalog price, and the quantity-break adjustment is reapplied each
3730
+ // time the cart renders (idempotent — the same line quantity always
3731
+ // yields the same adjusted unit), so a stale or forged client value
3732
+ // can't survive a round trip.
3733
+
3734
+ // A pricing.priceFor(sku) adapter over the catalog, for
3735
+ // bundles.priceBundle. Returns the current price row in `currency`
3736
+ // (or null when a member SKU has no price configured, which the
3737
+ // caller treats as "bundle unavailable"). Memoized per call site so
3738
+ // a bundle that repeats a SKU only hits the catalog once.
3739
+ function _skuPricer(currency) {
3740
+ var cache = Object.create(null);
3741
+ return {
3742
+ priceFor: async function (sku) {
3743
+ if (cache[sku] !== undefined) return cache[sku];
3744
+ var variant = await deps.catalog.variants.bySku(sku);
3745
+ if (!variant) { cache[sku] = null; return null; }
3746
+ var price = await deps.catalog.prices.current(variant.id, currency);
3747
+ cache[sku] = price
3748
+ ? { amount_minor: price.amount_minor, currency: price.currency }
3749
+ : null;
3750
+ return cache[sku];
3751
+ },
3752
+ };
3753
+ }
3754
+
3755
+ // Is a SKU buyable right now — a real, in-stock catalog variant?
3756
+ // A bundle with any unbuyable member is shown as unavailable so the
3757
+ // atomic add never half-completes. Inventory is optional: a SKU with
3758
+ // no inventory row is treated as available (the operator hasn't opted
3759
+ // into stock tracking for it), matching the rest of the storefront's
3760
+ // never-block-on-missing-inventory stance.
3761
+ async function _skuBuyable(sku) {
3762
+ var variant = await deps.catalog.variants.bySku(sku);
3763
+ if (!variant) return null;
3764
+ var inv = await deps.catalog.inventory.get(sku);
3765
+ if (inv && (inv.stock_on_hand - inv.stock_held) <= 0) return false;
3766
+ return variant;
3767
+ }
3768
+
3769
+ // Resolve the "Bundle & save" offers for a product's variant SKUs.
3770
+ // For each bundle a SKU belongs to: price it (sum-of-parts vs bundle
3771
+ // price via the primitive's stored discount), check every member is
3772
+ // buyable, and shape the display offer the renderer consumes. An
3773
+ // unpriceable or unbuyable-member bundle is surfaced as unavailable
3774
+ // (never hidden silently — the customer sees why). Dedupes bundles
3775
+ // reachable via multiple variant SKUs. Drop-silent on a primitive
3776
+ // error so a bundles read failure can't 500 the PDP — the rail just
3777
+ // doesn't render (mirrors the reviews/Q&A degrade-to-empty stance).
3778
+ async function _resolveBundleOffers(variantSkus, currency) {
3779
+ if (!deps.bundles) return [];
3780
+ var seen = Object.create(null);
3781
+ var offers = [];
3782
+ try {
3783
+ for (var s = 0; s < variantSkus.length; s += 1) {
3784
+ var bundleList = await deps.bundles.bundlesForComponent(variantSkus[s]);
3785
+ for (var i = 0; i < bundleList.length; i += 1) {
3786
+ var bundle = bundleList[i];
3787
+ if (seen[bundle.bundle_sku]) continue;
3788
+ seen[bundle.bundle_sku] = true;
3789
+
3790
+ // Decorate each member with a display title; flag unbuyable.
3791
+ var componentsOut = [];
3792
+ var allBuyable = true;
3793
+ for (var j = 0; j < bundle.components.length; j += 1) {
3794
+ var comp = bundle.components[j];
3795
+ var buyable = await _skuBuyable(comp.sku);
3796
+ if (!buyable) allBuyable = false;
3797
+ var memberVariant = buyable || (await deps.catalog.variants.bySku(comp.sku));
3798
+ var memberTitle = comp.sku;
3799
+ if (memberVariant) {
3800
+ var memberProduct = await deps.catalog.products.get(memberVariant.product_id);
3801
+ memberTitle = (memberProduct && memberProduct.title) || memberVariant.title || comp.sku;
3802
+ }
3803
+ componentsOut.push({ sku: comp.sku, quantity: comp.quantity, title: memberTitle });
3804
+ }
3805
+
3806
+ var priced = null;
3807
+ try {
3808
+ priced = await deps.bundles.priceBundle({ bundle_sku: bundle.bundle_sku, pricing: _skuPricer(currency) });
3809
+ } catch (_e) {
3810
+ // A missing member price / mixed currency makes the bundle
3811
+ // unpriceable — surface it as unavailable rather than 500.
3812
+ priced = null;
3813
+ }
3814
+
3815
+ var available = allBuyable && priced != null;
3816
+ var listMinor = priced ? priced.list_total_minor : 0;
3817
+ var amountMinor = priced ? priced.amount_minor : 0;
3818
+ var discountMinor = priced ? priced.discount_minor : 0;
3819
+ var cur = (priced && priced.currency) || currency;
3820
+ offers.push({
3821
+ bundle_sku: bundle.bundle_sku,
3822
+ title: bundle.title,
3823
+ components: componentsOut,
3824
+ list_total_str: pricing.format(listMinor, cur),
3825
+ amount_str: pricing.format(amountMinor, cur),
3826
+ discount_str: discountMinor > 0 ? pricing.format(discountMinor, cur) : null,
3827
+ available: available,
3828
+ unavailable_reason: available
3829
+ ? null
3830
+ : (priced == null
3831
+ ? "Pricing for this bundle isn't available right now."
3832
+ : "One or more items in this bundle are out of stock."),
3833
+ });
3834
+ }
3835
+ }
3836
+ } catch (_e) {
3837
+ // Bundles table not migrated / read failure — degrade to no rail.
3838
+ return [];
3839
+ }
3840
+ return offers;
3841
+ }
3842
+
3843
+ // Resolve the quantity-break table rows for a variant's SKU, against
3844
+ // its list price. Composes the quantity-discount primitive's
3845
+ // tierBreakdown with a sample unit = the variant's current price, so
3846
+ // the displayed "price each" matches what the cart will charge at
3847
+ // that quantity. Rows read as ascending ranges ("1–4", "5–9",
3848
+ // "10+"). Returns [] when no active sku-scoped tier set exists.
3849
+ // Drop-silent on a read failure so the PDP still renders.
3850
+ async function _resolveQtyBreaks(sku, unitMinor, currency) {
3851
+ if (!deps.quantityDiscounts || unitMinor == null) return [];
3852
+ var breakdown;
3853
+ try {
3854
+ breakdown = await deps.quantityDiscounts.tierBreakdown({
3855
+ scope: "sku",
3856
+ scope_id: sku,
3857
+ sample_unit_price_minor: unitMinor,
3858
+ });
3859
+ } catch (_e) {
3860
+ return [];
3861
+ }
3862
+ var rows = breakdown.rows || [];
3863
+ if (rows.length === 0) return [];
3864
+ // Order by min_quantity ascending; build the implicit range each
3865
+ // tier covers (this tier's min up to the next tier's min minus 1,
3866
+ // open-ended on the last). The base price (below the first tier)
3867
+ // rides as the leading "1–(min-1)" row so the table shows the full
3868
+ // ladder including the undiscounted band.
3869
+ var sorted = rows.slice().sort(function (a, b2) { return a.min_quantity - b2.min_quantity; });
3870
+ var out = [];
3871
+ if (sorted[0].min_quantity > 1) {
3872
+ out.push({
3873
+ label: "1–" + (sorted[0].min_quantity - 1),
3874
+ unit_str: pricing.format(unitMinor, currency),
3875
+ });
3876
+ }
3877
+ for (var i = 0; i < sorted.length; i += 1) {
3878
+ var t = sorted[i];
3879
+ var next = sorted[i + 1];
3880
+ var label = next ? (t.min_quantity + "–" + (next.min_quantity - 1)) : (t.min_quantity + "+");
3881
+ var unit = t.sample_discounted_unit_minor != null ? t.sample_discounted_unit_minor : unitMinor;
3882
+ out.push({ label: label, unit_str: pricing.format(unit, currency) });
3883
+ }
3884
+ return out;
3885
+ }
3886
+
3887
+ // Reprice cart lines for display + totals by reapplying the active
3888
+ // quantity-break for each line's SKU at its current quantity. Returns
3889
+ // a shallow copy of each line with `unit_amount_minor` overwritten by
3890
+ // the discounted unit (the stored snapshot is never mutated — the
3891
+ // adjustment is recomputed every render, so it stays correct as the
3892
+ // quantity changes and as the operator edits tier schedules). When
3893
+ // the quantity-discount primitive isn't wired, the lines pass through
3894
+ // unchanged. Drop-silent per line so one unpriceable SKU can't break
3895
+ // the cart total.
3896
+ async function _repriceCartLines(lines) {
3897
+ if (!deps.quantityDiscounts) return lines;
3898
+ var out = [];
3899
+ for (var i = 0; i < lines.length; i += 1) {
3900
+ var l = lines[i];
3901
+ var unit = l.unit_amount_minor;
3902
+ try {
3903
+ var variant = await deps.catalog.variants.get(l.variant_id);
3904
+ var applied = await deps.quantityDiscounts.applyToLine({
3905
+ line: {
3906
+ sku: l.sku,
3907
+ quantity: l.qty,
3908
+ unit_price_minor: l.unit_amount_minor,
3909
+ product_id: variant ? variant.product_id : undefined,
3910
+ },
3911
+ });
3912
+ unit = applied.discounted_unit_minor;
3913
+ } catch (_e) {
3914
+ // Unpriceable line (no tier / bad shape) keeps its snapshot.
3915
+ unit = l.unit_amount_minor;
3916
+ }
3917
+ out.push(Object.assign({}, l, { unit_amount_minor: unit }));
3918
+ }
3919
+ return out;
3920
+ }
3921
+
3613
3922
  router.get("/", async function (_req, res) {
3614
3923
  var page = await deps.catalog.products.list({ status: "active", limit: 24 });
3615
3924
  // Best-effort "starting price" + "hero media" lookup. Each
@@ -3768,6 +4077,19 @@ function mount(router, deps) {
3768
4077
  catch (_e) { /* drop-silent — recently-viewed is supplementary to the buy path */ }
3769
4078
  }
3770
4079
  }
4080
+ // Bundle offers + quantity-break table. Both compose the
4081
+ // server-authoritative pricing primitives (the customer never sends
4082
+ // a price); both degrade to empty on a missing-table / read failure
4083
+ // so the buy path renders regardless. The quantity-break table is
4084
+ // built against the first variant's list price — the band a shopper
4085
+ // sees on the PDP matches what the cart charges at that quantity.
4086
+ var variantSkus = variants.map(function (v) { return v.sku; });
4087
+ var bundleOffers = await _resolveBundleOffers(variantSkus, "USD");
4088
+ var firstVariant = variants[0] || null;
4089
+ var firstPrice = firstVariant ? prices[firstVariant.id] : null;
4090
+ var qtyBreaks = firstVariant && firstPrice
4091
+ ? await _resolveQtyBreaks(firstVariant.sku, firstPrice.amount_minor, firstPrice.currency)
4092
+ : [];
3771
4093
  var html = renderProduct({
3772
4094
  product: product,
3773
4095
  variants: variants,
@@ -3778,6 +4100,8 @@ function mount(router, deps) {
3778
4100
  review_cta: reviewCta,
3779
4101
  qa_questions: qaQuestions,
3780
4102
  qa_cta: qaCta,
4103
+ bundle_offers: bundleOffers,
4104
+ qty_breaks: qtyBreaks,
3781
4105
  wishlist_count: wishlistCount,
3782
4106
  shop_name: shopName,
3783
4107
  cart_count: cartCount,
@@ -4111,7 +4435,13 @@ function mount(router, deps) {
4111
4435
  shop_name: shopName, theme: theme,
4112
4436
  }));
4113
4437
  }
4114
- var lines = await deps.cart.listLines(c.id);
4438
+ var rawLines = await deps.cart.listLines(c.id);
4439
+ // Reapply the active quantity-break for each line at its current
4440
+ // quantity before totals — the unit price the customer sees on the
4441
+ // line, the line total, and the cart subtotal all reflect the break.
4442
+ // Recomputed every render (idempotent); the stored snapshot is never
4443
+ // mutated, so changing a line's quantity re-prices it automatically.
4444
+ var lines = await _repriceCartLines(rawLines);
4115
4445
  var totals = pricing.totals(c, lines, {});
4116
4446
  // Build the variant_id → { product, hero_media } lookup the
4117
4447
  // renderer uses to decorate each line with a thumbnail + title.
@@ -6142,6 +6472,141 @@ function mount(router, deps) {
6142
6472
  res.end ? res.end() : res.send("");
6143
6473
  });
6144
6474
 
6475
+ // POST /cart/bundle — add every member of a bundle to the cart at the
6476
+ // bundle price, atomically. Reads `bundle_sku` from the form body.
6477
+ // The price is recomputed server-side from the catalog + the bundle
6478
+ // primitive (the client sends only the SKU, never a price). The
6479
+ // bundle discount is allocated across the member lines proportional
6480
+ // to each member's list contribution, so the cart subtotal equals the
6481
+ // bundle's quoted price; integer-cent remainder lands on the last
6482
+ // line. Mounted only when the bundles primitive is wired.
6483
+ if (deps.bundles) {
6484
+ router.post("/cart/bundle", async function (req, res) {
6485
+ var body = req.body || {};
6486
+ var bundleSku = body.bundle_sku;
6487
+ if (!bundleSku || typeof bundleSku !== "string") {
6488
+ res.status(400);
6489
+ return res.end ? res.end("Invalid request") : res.send("Invalid request");
6490
+ }
6491
+ var currency = "USD";
6492
+ // Resolve + price the bundle. A malformed sku throws TypeError
6493
+ // (→ 400); an unknown sku resolves to no bundle (→ 404).
6494
+ var bundle, leaves, priced;
6495
+ try {
6496
+ bundle = await deps.bundles.getBundle(bundleSku);
6497
+ if (!bundle) {
6498
+ res.status(404);
6499
+ return res.end ? res.end("Bundle not found") : res.send("Bundle not found");
6500
+ }
6501
+ leaves = await deps.bundles.expand({ bundle_sku: bundleSku, quantity: 1 });
6502
+ priced = await deps.bundles.priceBundle({ bundle_sku: bundleSku, pricing: _skuPricer(currency) });
6503
+ } catch (e) {
6504
+ if (e instanceof TypeError) {
6505
+ res.status(400);
6506
+ return res.end ? res.end((e && e.message) || "Invalid bundle") : res.send((e && e.message) || "Invalid bundle");
6507
+ }
6508
+ res.status(500);
6509
+ return res.end ? res.end("Error") : res.send("Error");
6510
+ }
6511
+
6512
+ // Pre-flight: every leaf must resolve to a buyable variant + a
6513
+ // current price. All-or-nothing — if any member is unavailable,
6514
+ // add nothing and bounce back to the PDP with a notice (no
6515
+ // half-filled cart). `leaf.quantity` is the per-bundle demand.
6516
+ var members = [];
6517
+ for (var i = 0; i < leaves.length; i += 1) {
6518
+ var leaf = leaves[i];
6519
+ var variant = await _skuBuyable(leaf.sku);
6520
+ if (!variant) {
6521
+ res.status(303);
6522
+ res.setHeader && res.setHeader("location", "/cart?bundle=unavailable");
6523
+ return res.end ? res.end() : res.send("");
6524
+ }
6525
+ var price = await deps.catalog.prices.current(variant.id, currency);
6526
+ if (!price) {
6527
+ res.status(303);
6528
+ res.setHeader && res.setHeader("location", "/cart?bundle=unavailable");
6529
+ return res.end ? res.end() : res.send("");
6530
+ }
6531
+ members.push({
6532
+ variant_id: variant.id,
6533
+ qty: leaf.quantity,
6534
+ list_each: price.amount_minor,
6535
+ list_line: price.amount_minor * leaf.quantity,
6536
+ });
6537
+ }
6538
+
6539
+ // Allocate the bundle total across members proportional to each
6540
+ // member's list contribution, then express each share as an integer
6541
+ // per-unit price. The cart stores one integer unit price per line and
6542
+ // charges qty*unit, so flooring a share to a per-unit price loses up
6543
+ // to (qty-1) cents on a multi-unit line. Those cents are added back
6544
+ // below so the cart subtotal equals the bundle price exactly — never
6545
+ // undercharging the advertised bundle.
6546
+ var listTotal = priced.list_total_minor;
6547
+ var bundleTotal = priced.amount_minor;
6548
+ var allocated = 0;
6549
+ for (var m = 0; m < members.length; m += 1) {
6550
+ var mem = members[m];
6551
+ var share = listTotal > 0
6552
+ ? Math.floor((bundleTotal * mem.list_line) / listTotal)
6553
+ : Math.floor(bundleTotal / members.length);
6554
+ mem.alloc_line = share;
6555
+ allocated += share;
6556
+ }
6557
+ // The proportional floor leaves a whole-cent remainder; park it on the
6558
+ // last member's line so the per-line allocations sum to the bundle
6559
+ // total exactly.
6560
+ if (members.length > 0) members[members.length - 1].alloc_line += (bundleTotal - allocated);
6561
+
6562
+ // Convert each member's line allocation to a uniform integer per-unit
6563
+ // price (floor) and tally the cents lost to that floor across all
6564
+ // lines. A single-unit line (the usual bundle shape) absorbs the whole
6565
+ // shortfall exactly; failing that, the cents are returned in qty-sized
6566
+ // steps to the smallest-quantity lines, with any final sub-step residual
6567
+ // rounded up onto the smallest line — so the realized subtotal is never
6568
+ // below the quoted bundle price.
6569
+ var lostCents = 0;
6570
+ for (var u = 0; u < members.length; u += 1) {
6571
+ var me = members[u];
6572
+ me.unit = me.qty > 0 ? Math.floor(me.alloc_line / me.qty) : me.alloc_line;
6573
+ lostCents += me.alloc_line - (me.unit * me.qty);
6574
+ }
6575
+ if (lostCents > 0 && members.length > 0) {
6576
+ var byQty = members.slice().sort(function (a, b) { return a.qty - b.qty; });
6577
+ var single = null;
6578
+ for (var s = 0; s < byQty.length; s += 1) { if (byQty[s].qty === 1) { single = byQty[s]; break; } }
6579
+ if (single) {
6580
+ single.unit += lostCents;
6581
+ } else {
6582
+ for (var g = 0; g < byQty.length && lostCents > 0; g += 1) {
6583
+ while (lostCents >= byQty[g].qty) { byQty[g].unit += 1; lostCents -= byQty[g].qty; }
6584
+ }
6585
+ if (lostCents > 0) { byQty[0].unit += 1; lostCents = 0; }
6586
+ }
6587
+ }
6588
+
6589
+ var resolved = await _getOrCreateCart(req, res, currency);
6590
+ try {
6591
+ for (var k = 0; k < members.length; k += 1) {
6592
+ var mk = members[k];
6593
+ await deps.cart.addLine(resolved.cart.id, {
6594
+ variant_id: mk.variant_id,
6595
+ qty: mk.qty,
6596
+ unit_amount_minor: mk.unit,
6597
+ unit_currency: currency,
6598
+ });
6599
+ }
6600
+ } catch (e) {
6601
+ res.status(e instanceof TypeError ? 400 : 500);
6602
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
6603
+ }
6604
+ res.status(303);
6605
+ res.setHeader && res.setHeader("location", "/cart");
6606
+ res.end ? res.end() : res.send("");
6607
+ });
6608
+ }
6609
+
6145
6610
  // POST /cart/lines/:line_id/update — change qty on an existing
6146
6611
  // line. Form value `qty` is the new quantity (1..99). HTML forms
6147
6612
  // only support GET/POST so the verb is in the path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
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": {