@blamejs/blamejs-shop 0.1.30 → 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,10 @@ 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
+
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.
14
+
11
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.
12
16
 
13
17
  - v0.1.29 (2026-05-26) — **Category navigation goes live — public browse pages for the storefront's category tree, with breadcrumbs and sub-category grids.** The category tree now has a customer-facing surface. A new /categories index lists the active top-level categories as a card grid, and a new /categories/<slug> page renders a single category: its title and optional description, a breadcrumb chain from the catalog root down to the current category, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived or unpublished categories drop out of every surface. An unknown, archived, or malformed slug returns a 404, and a category with no children renders a graceful empty state. The pages are linked from the site footer's Shop column. **Added:** *Category index at /categories* — A public page listing the active top-level categories as a card grid, each card carrying the category's title, optional description, and optional hero image, and linking into its browse page. Archived and unpublished categories are excluded. Linked from the site footer's Shop column. · *Category browse page at /categories/<slug>* — A public page for a single category showing its title and optional description, a breadcrumb chain from the catalog root down to the current category (each ancestor linked, the current category shown as plain text), an optional hero image, and a card grid of the category's direct child sub-categories. A category with no children renders a graceful empty state. An unknown, archived, or malformed slug returns a 404.
package/README.md CHANGED
@@ -71,6 +71,9 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
71
71
  | **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
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
+ | **`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. |
74
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. |
75
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. |
76
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.30",
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 = [];