@blamejs/blamejs-shop 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.3 (2026-05-30) — **Manage quantity-break (volume) pricing from the admin console.** The admin console gains a Quantity breaks section for creating and managing volume-pricing tier sets — buy-more-save-more rules scoped to a SKU, product, collection, vendor, category, or the whole store. Each set defines one or more quantity thresholds (e.g. buy 5, buy 10) with a percentage off, an amount off each unit, an amount off the line, or a fixed per-unit price, and can be marked exclusive or left to stack with others. A sample-price preview shows the discounted unit at each threshold before you commit, and sets archive and restore. The tiers take effect immediately on the product page, in the cart, and at checkout. **Added:** *Quantity Breaks admin console* — A new Quantity breaks section manages volume-pricing tier sets: create a set scoped to a SKU, product, collection, vendor, category, or globally; define one or more quantity thresholds with a percent-off, amount-off-each, amount-off-line, or fixed-per-unit discount; mark a set exclusive or let it stack. A sample-price preview shows the discounted unit at each threshold, and sets archive and restore. The tiers apply automatically on the product page, the cart, and at checkout.
12
+
13
+ - v0.3.2 (2026-05-30) — **Creating a product lands you on its detail screen to finish setup.** Creating a product from the admin console now takes you straight to that product's detail screen — where you add a variant, set its price, and add stock — instead of returning you to the product list. A banner on the new product names the remaining steps to make it sellable. This removes the hunt-for-the-product-you-just-made step from the catalog setup flow. **Changed:** *Product create flows straight into editing* — Submitting the new-product form now redirects to the product's detail screen with a banner naming the next steps — add a variant with a SKU, set its price, and add stock — rather than bouncing back to the product list. Configuring a just-created product no longer requires finding it again first.
14
+
11
15
  - v0.3.1 (2026-05-30) — **Fix CSRF origin check refusing authenticated forms behind a TLS-terminating proxy.** The token-based CSRF protection added in 0.3.0 runs an Origin/Referer pre-check on every state-changing request. When the app is served behind a proxy that terminates TLS and forwards plain HTTP to the application — a CDN or load balancer, the standard production topology — the application saw the connection as HTTP and rebuilt its own origin as `http://<host>`, while the browser's request carried `Origin: https://<host>`. That bare scheme mismatch made the check refuse every legitimate same-origin POST as cross-origin, blocking sign-in and every account and admin form. The CSRF gate now trusts the forwarded protocol header — the same proxy stance the session cookies already use — so it derives the real public origin, and it matches against an explicit allowlist of the public origin(s), configurable via SHOP_PUBLIC_ORIGINS. The token cookie also regains its `__Host-` prefix over the forwarded HTTPS connection. No operator action is required for the default origin. **Fixed:** *Authenticated forms no longer refused behind a TLS-terminating proxy* — The CSRF origin pre-check derived the application origin from the raw proxied connection scheme, so a same-origin browser POST carrying `Origin: https://<host>` was refused as cross-origin under a CDN that forwards plain HTTP. The gate now trusts the forwarded `x-forwarded-proto` and matches against an explicit public-origin allowlist — `SHOP_PUBLIC_ORIGINS` (comma-separated), defaulting to the canonical origin — so sign-in and the account and admin forms submit correctly. The double-submit token cookie also regains its `__Host-` prefix over the forwarded HTTPS connection.
12
16
 
13
17
  - v0.3.0 (2026-05-30) — **Token-based CSRF protection on the account and admin forms.** Every state-changing form on the customer account surface — subscriptions, addresses, wishlist, returns, reviews, loyalty, referrals, passkey management, profile, checkout — and the entire admin console now carries a per-request double-submit CSRF token that is validated on submission. This is a defense-in-depth layer on top of the existing SameSite session cookies and the fetch-metadata cross-site gate. It lands with an update of the vendored blamejs runtime to v0.13.46, whose createApp now wires CSRF into the request lifecycle by default (closing the long-standing gap where it was documented but not enforced). The edge-rendered, cached storefront forms (add-to-cart, cookie consent, newsletter, currency switch) keep their SameSite + fetch-metadata defense — a per-session token cannot be embedded in HTML that is cached and identical for every visitor, and forcing one would break no-JS submission. No operator action or configuration change is required. **Security:** *Per-form CSRF tokens on account and admin* — The container-rendered account and admin forms now embed a hidden `_csrf` field carrying a per-request double-submit token; the server validates it (constant-time) on every state-changing POST. The passkey enrolment / sign-in islands send the matching token in the `X-CSRF-Token` header. This is layered on top of — not a replacement for — the SameSite session cookies and the fetch-metadata cross-site refusal already in place. · *Vendored blamejs runtime updated to v0.13.46* — createApp now wires CSRF (double-submit cookie), plus the cookie / CSP-nonce / fetch-metadata / body-parser layers, into the request lifecycle by default. The shop adopts the CSRF default scoped so the edge-cached, dual-rendered storefront endpoints (cart, consent, newsletter, currency) remain on their SameSite + fetch-metadata defense, while the authenticated account and admin surfaces gain the token check. A lint rule now flags any new edge-rendered POST form that would need that exemption.
package/lib/admin.js CHANGED
@@ -32,6 +32,7 @@
32
32
 
33
33
  var pricing = require("./pricing");
34
34
  var collectionsModule = require("./collections");
35
+ var quantityDiscountsModule = require("./quantity-discounts");
35
36
  var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
36
37
 
37
38
  var b = require("./vendor/blamejs");
@@ -444,7 +445,7 @@ function mount(router, deps) {
444
445
  // `reports` is always present in the nav (read-only sales summary needs no
445
446
  // extra dep); its route mounts unconditionally and renders an unconfigured
446
447
  // notice when the salesReports primitive isn't wired.
447
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, pickLists: !!pickLists };
448
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists };
448
449
 
449
450
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
450
451
 
@@ -507,10 +508,13 @@ function mount(router, deps) {
507
508
  return p;
508
509
  }),
509
510
  async function (req, res) {
510
- // Browser form submit — create, then redirect (PRG). Bad input
511
+ // Browser form submit — create, then redirect (PRG) straight to the
512
+ // new product's detail screen so the operator continues setup (variant,
513
+ // price, stock) there instead of hunting for it in the list. Bad input
511
514
  // re-renders the products page with a notice, never a 500.
515
+ var made;
512
516
  try {
513
- await catalog.products.create(req.body || {});
517
+ made = await catalog.products.create(req.body || {});
514
518
  } catch (e) {
515
519
  if (e instanceof TypeError || e.code === "CATALOG_DUPLICATE" || /slug|exists|duplicate/i.test(e.message || "")) {
516
520
  var page = await catalog.products.list({ limit: 100 });
@@ -521,8 +525,8 @@ function mount(router, deps) {
521
525
  }
522
526
  throw e;
523
527
  }
524
- b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".product.create", outcome: "success", metadata: {} });
525
- _redirect(res, "/admin/products?created=1");
528
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".product.create", outcome: "success", metadata: { id: made.id } });
529
+ _redirect(res, "/admin/products/" + encodeURIComponent(made.id) + "?created=1");
526
530
  },
527
531
  ));
528
532
 
@@ -629,8 +633,9 @@ function mount(router, deps) {
629
633
  prices_by_variant: model.prices_by_variant, media: model.media,
630
634
  asset_prefix: assetPrefix, upload_available: !!r2,
631
635
  default_currency: await _defaultCurrency(),
632
- saved: url && url.searchParams.get("saved"),
633
- notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
636
+ saved: url && url.searchParams.get("saved"),
637
+ created: url && url.searchParams.get("created"),
638
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
634
639
  }));
635
640
  },
636
641
  ));
@@ -4961,6 +4966,254 @@ function mount(router, deps) {
4961
4966
  }
4962
4967
  }
4963
4968
 
4969
+ // ---- quantity discounts ---------------------------------------------
4970
+ // Tier-set CRUD for automatic per-line quantity breaks ("buy 5, save
4971
+ // 10%"). The pricing engine already applies these at PDP / cart /
4972
+ // checkout; this console manages the schedules. Content-negotiated like
4973
+ // the other screens: bearer → the JSON contract; signed-in browser →
4974
+ // the HTML table + create form. The detail screen renders the schedule,
4975
+ // a rewrite form, archive/unarchive, and a sample tierBreakdown preview.
4976
+ if (deps.quantityDiscounts) {
4977
+ var quantityDiscounts = deps.quantityDiscounts;
4978
+
4979
+ // The engine has no get(id) — fetch a single set by walking the
4980
+ // both-states list (operators have tens of sets, not thousands).
4981
+ async function _qdSetById(id) {
4982
+ var rows = await quantityDiscounts.list({ archived: null, limit: 200 });
4983
+ for (var i = 0; i < rows.length; i += 1) {
4984
+ if (rows[i].id === id) return rows[i];
4985
+ }
4986
+ return null;
4987
+ }
4988
+
4989
+ // Map the ?archived= query to a list filter: 1/true → archived-only,
4990
+ // 0/false → active-only, absent → both. The list view defaults to
4991
+ // showing everything so a freshly-archived set stays visible.
4992
+ function _qdFilter(archivedS) {
4993
+ if (archivedS === "1" || archivedS === "true") return { archived: true };
4994
+ if (archivedS === "0" || archivedS === "false") return { archived: false };
4995
+ return { archived: null };
4996
+ }
4997
+
4998
+ // Parse the create / edit form's flat tier fields into the engine's
4999
+ // tiers array. The form posts parallel arrays tier_min[] /
5000
+ // tier_kind[] / tier_value[]; a row with every field blank is
5001
+ // dropped (the spare append row), a row with any field set is kept
5002
+ // and validated by the engine. min_quantity + value go through the
5003
+ // strict integer reader so "", floats, and "12abc" are refused as a
5004
+ // 4xx rather than coerced.
5005
+ function _qdAsArray(v) {
5006
+ if (v == null) return [];
5007
+ return Array.isArray(v) ? v : [v];
5008
+ }
5009
+ function _qdTiersFromForm(body) {
5010
+ var mins = _qdAsArray(body.tier_min);
5011
+ var kinds = _qdAsArray(body.tier_kind);
5012
+ var values = _qdAsArray(body.tier_value);
5013
+ var n = Math.max(mins.length, kinds.length, values.length);
5014
+ var tiers = [];
5015
+ for (var i = 0; i < n; i += 1) {
5016
+ var minRaw = mins[i] == null ? "" : String(mins[i]).trim();
5017
+ var kindRaw = kinds[i] == null ? "" : String(kinds[i]).trim();
5018
+ var valueRaw = values[i] == null ? "" : String(values[i]).trim();
5019
+ if (minRaw === "" && kindRaw === "" && valueRaw === "") continue; // spare blank row
5020
+ tiers.push({
5021
+ min_quantity: _strictMinorInt(minRaw, "quantityDiscounts", "min_quantity"),
5022
+ discount_kind: kindRaw,
5023
+ value: _strictMinorInt(valueRaw, "quantityDiscounts", "value"),
5024
+ });
5025
+ }
5026
+ return tiers;
5027
+ }
5028
+
5029
+ // Translate the create form / JSON body into a defineTier input. A
5030
+ // body already carrying a tiers array (bearer JSON client) passes
5031
+ // through untouched; the browser form is flattened first.
5032
+ function _qdDefineInput(body) {
5033
+ if (Array.isArray(body.tiers)) return body;
5034
+ var scope = typeof body.scope === "string" ? body.scope.trim() : body.scope;
5035
+ var input = {
5036
+ scope: scope,
5037
+ exclusive: (body.exclusive === "on" || body.exclusive === "1" || body.exclusive === true),
5038
+ tiers: _qdTiersFromForm(body),
5039
+ };
5040
+ // scope_id is null exactly for global; the engine enforces the
5041
+ // pairing. Trim + omit for global so a stray blank field doesn't
5042
+ // trip the "must be null when global" check.
5043
+ if (scope !== "global") {
5044
+ input.scope_id = typeof body.scope_id === "string" ? body.scope_id.trim() : body.scope_id;
5045
+ }
5046
+ return input;
5047
+ }
5048
+
5049
+ function _qdCleanMessage(e) {
5050
+ return (e && e.message || "Couldn't save that tier set.").replace(/^quantityDiscounts[.:]\s*/, "");
5051
+ }
5052
+
5053
+ async function _renderQdList(flags) {
5054
+ flags = flags || {};
5055
+ var rows = await quantityDiscounts.list(_qdFilter(flags.archived_filter));
5056
+ return renderAdminQuantityDiscounts(Object.assign({
5057
+ shop_name: deps.shop_name, nav_available: navAvailable,
5058
+ scopes: quantityDiscountsModule.VALID_SCOPES, kinds: quantityDiscountsModule.VALID_KINDS,
5059
+ tier_sets: rows,
5060
+ }, flags));
5061
+ }
5062
+
5063
+ router.get("/admin/quantity-discounts", _pageOrApi(true,
5064
+ R(async function (req, res) {
5065
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5066
+ var archivedS = url && url.searchParams.get("archived");
5067
+ _json(res, 200, { rows: await quantityDiscounts.list(_qdFilter(archivedS)) });
5068
+ }),
5069
+ async function (req, res) {
5070
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5071
+ var archivedS = url && url.searchParams.get("archived");
5072
+ _sendHtml(res, 200, await _renderQdList({
5073
+ archived_filter: archivedS,
5074
+ created: url && url.searchParams.get("created"),
5075
+ saved: url && url.searchParams.get("saved"),
5076
+ archived: url && url.searchParams.get("archived_ok"),
5077
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the tier set." : null,
5078
+ }));
5079
+ },
5080
+ ));
5081
+
5082
+ router.post("/admin/quantity-discounts", _pageOrApi(false,
5083
+ W("quantity_discount.create", async function (req, res) {
5084
+ var set = await quantityDiscounts.defineTier(_qdDefineInput(req.body || {}));
5085
+ _json(res, 201, set);
5086
+ return { id: set.id };
5087
+ }),
5088
+ async function (req, res) {
5089
+ try {
5090
+ await quantityDiscounts.defineTier(_qdDefineInput(req.body || {}));
5091
+ } catch (e) {
5092
+ if (!(e instanceof TypeError)) throw e;
5093
+ return _sendHtml(res, 400, await _renderQdList({ notice: _qdCleanMessage(e) }));
5094
+ }
5095
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quantity_discount.create", outcome: "success" });
5096
+ _redirect(res, "/admin/quantity-discounts?created=1");
5097
+ },
5098
+ ));
5099
+
5100
+ // Detail: the tier set + its schedule, a rewrite form, archive /
5101
+ // unarchive, and a sample-price tierBreakdown preview. A bad / unknown
5102
+ // id is a 404 page, never a 500.
5103
+ async function _qdDetailModel(id, sampleMinor) {
5104
+ var set = await _qdSetById(id);
5105
+ if (!set) return null;
5106
+ var breakdown = null;
5107
+ try {
5108
+ breakdown = await quantityDiscounts.tierBreakdown({
5109
+ scope: set.scope,
5110
+ scope_id: set.scope_id,
5111
+ sample_unit_price_minor: (sampleMinor == null ? 1000 : sampleMinor),
5112
+ });
5113
+ } catch (_e) { breakdown = null; }
5114
+ return { set: set, breakdown: breakdown, sample_minor: (sampleMinor == null ? 1000 : sampleMinor) };
5115
+ }
5116
+
5117
+ router.get("/admin/quantity-discounts/:id", _pageOrApi(true,
5118
+ R(async function (req, res) {
5119
+ var set = await _qdSetById(req.params.id);
5120
+ if (!set) return _problem(res, 404, "quantity-discount-not-found");
5121
+ _json(res, 200, set);
5122
+ }),
5123
+ async function (req, res) {
5124
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5125
+ var sampleS = url && url.searchParams.get("sample");
5126
+ var sampleMinor = null;
5127
+ if (sampleS != null && /^\d+$/.test(sampleS)) sampleMinor = Number(sampleS);
5128
+ var model = await _qdDetailModel(req.params.id, sampleMinor);
5129
+ if (!model) return _sendHtml(res, 404, renderAdminQuantityDiscount({
5130
+ shop_name: deps.shop_name, nav_available: navAvailable, tier_set: null,
5131
+ }));
5132
+ _sendHtml(res, 200, renderAdminQuantityDiscount({
5133
+ shop_name: deps.shop_name, nav_available: navAvailable,
5134
+ kinds: quantityDiscountsModule.VALID_KINDS,
5135
+ tier_set: model.set, breakdown: model.breakdown, sample_minor: model.sample_minor,
5136
+ saved: url && url.searchParams.get("saved"),
5137
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
5138
+ }));
5139
+ },
5140
+ ));
5141
+
5142
+ router.post("/admin/quantity-discounts/:id/edit", _pageOrApi(false,
5143
+ W("quantity_discount.update", async function (req, res) {
5144
+ var set;
5145
+ try { set = await quantityDiscounts.update(req.params.id, req.body || {}); }
5146
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5147
+ if (!set) return _problem(res, 404, "quantity-discount-not-found");
5148
+ _json(res, 200, set);
5149
+ return { id: set.id };
5150
+ }),
5151
+ async function (req, res) {
5152
+ var id = req.params.id;
5153
+ var body = req.body || {};
5154
+ var enc = encodeURIComponent(id);
5155
+ try {
5156
+ var patch = {};
5157
+ // exclusive_present marks the checkbox was rendered, so an
5158
+ // unchecked box reads as false rather than "leave unchanged".
5159
+ if (body.exclusive_present === "1") {
5160
+ patch.exclusive = (body.exclusive === "on" || body.exclusive === "1");
5161
+ }
5162
+ var tiers = _qdTiersFromForm(body);
5163
+ if (tiers.length > 0) patch.tiers = tiers;
5164
+ if (Object.keys(patch).length === 0) return _redirect(res, "/admin/quantity-discounts/" + enc + "?err=1");
5165
+ var set = await quantityDiscounts.update(id, patch);
5166
+ if (!set) return _redirect(res, "/admin/quantity-discounts/" + enc + "?err=1");
5167
+ } catch (e) {
5168
+ if (!(e instanceof TypeError)) throw e;
5169
+ return _redirect(res, "/admin/quantity-discounts/" + enc + "?err=1");
5170
+ }
5171
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quantity_discount.update", outcome: "success", metadata: { id: id } });
5172
+ _redirect(res, "/admin/quantity-discounts/" + enc + "?saved=1");
5173
+ },
5174
+ ));
5175
+
5176
+ router.post("/admin/quantity-discounts/:id/archive", _pageOrApi(false,
5177
+ W("quantity_discount.archive", async function (req, res) {
5178
+ var ok;
5179
+ try { ok = await quantityDiscounts.archive(req.params.id); }
5180
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5181
+ if (!ok) return _problem(res, 404, "quantity-discount-not-found");
5182
+ _json(res, 200, { ok: true });
5183
+ return { id: req.params.id };
5184
+ }),
5185
+ async function (req, res) {
5186
+ var id = req.params.id;
5187
+ var ok = false;
5188
+ try { ok = await quantityDiscounts.archive(id); }
5189
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
5190
+ if (!ok) return _redirect(res, "/admin/quantity-discounts?err=1");
5191
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quantity_discount.archive", outcome: "success", metadata: { id: id } });
5192
+ _redirect(res, "/admin/quantity-discounts?archived_ok=1");
5193
+ },
5194
+ ));
5195
+
5196
+ router.post("/admin/quantity-discounts/:id/unarchive", _pageOrApi(false,
5197
+ W("quantity_discount.unarchive", async function (req, res) {
5198
+ var ok;
5199
+ try { ok = await quantityDiscounts.unarchive(req.params.id); }
5200
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5201
+ if (!ok) return _problem(res, 404, "quantity-discount-not-found");
5202
+ _json(res, 200, { ok: true });
5203
+ return { id: req.params.id };
5204
+ }),
5205
+ async function (req, res) {
5206
+ var id = req.params.id;
5207
+ var ok = false;
5208
+ try { ok = await quantityDiscounts.unarchive(id); }
5209
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
5210
+ if (!ok) return _redirect(res, "/admin/quantity-discounts?err=1");
5211
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".quantity_discount.unarchive", outcome: "success", metadata: { id: id } });
5212
+ _redirect(res, "/admin/quantity-discounts?saved=1");
5213
+ },
5214
+ ));
5215
+ }
5216
+
4964
5217
  // Render the discounts screen — gathers rules + (optional) stacking
4965
5218
  // policies, then hands them to the renderer with whatever banner flags
4966
5219
  // the caller passed.
@@ -5390,6 +5643,7 @@ var ADMIN_NAV_ITEMS = [
5390
5643
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
5391
5644
  { key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
5392
5645
  { key: "discounts", href: "/admin/discounts", label: "Discounts", requires: "autoDiscount" },
5646
+ { key: "quantity-discounts", href: "/admin/quantity-discounts", label: "Quantity breaks", requires: "quantityDiscounts" },
5393
5647
  { key: "tax", href: "/admin/tax-rates", label: "Tax", requires: "taxRates" },
5394
5648
  { key: "shipping", href: "/admin/shipping", label: "Shipping", requires: "shippingZones" },
5395
5649
  { key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
@@ -7916,6 +8170,210 @@ function renderAdminCollection(opts) {
7916
8170
  return _renderAdminShell(opts.shop_name, "Collection " + col.slug, body, "collections", opts.nav_available);
7917
8171
  }
7918
8172
 
8173
+ // One labelled <option> list for the discount_kind dropdown, with the
8174
+ // kind whose value is a fixed money amount annotated so the operator
8175
+ // knows whether `value` is basis points or minor units.
8176
+ function _qdKindLabel(kind) {
8177
+ if (kind === "percent_off") return "percent_off (value = basis points; 1000 = 10%)";
8178
+ if (kind === "amount_off_each") return "amount_off_each (value = minor units off each unit)";
8179
+ if (kind === "amount_off_total") return "amount_off_total (value = minor units off the line)";
8180
+ if (kind === "fixed_each_price") return "fixed_each_price (value = new unit price in minor units)";
8181
+ return kind;
8182
+ }
8183
+
8184
+ function _qdKindOptions(kinds, selected) {
8185
+ return (kinds || []).map(function (k) {
8186
+ return "<option value=\"" + _htmlEscape(k) + "\"" + (k === selected ? " selected" : "") + ">" +
8187
+ _htmlEscape(_qdKindLabel(k)) + "</option>";
8188
+ }).join("");
8189
+ }
8190
+
8191
+ // A single editable tier row — min_quantity + discount_kind + value.
8192
+ // `tier` may be a stored row (edit) or {} for a spare append row.
8193
+ function _qdTierRow(kinds, tier) {
8194
+ tier = tier || {};
8195
+ var minVal = tier.min_quantity == null ? "" : String(tier.min_quantity);
8196
+ var valVal = tier.value == null ? "" : String(tier.value);
8197
+ return "<div class=\"actions-row m-04\">" +
8198
+ "<input type=\"number\" name=\"tier_min\" value=\"" + _htmlEscape(minVal) + "\" placeholder=\"min qty\" min=\"1\" step=\"1\" class=\"input-code\">" +
8199
+ "<select name=\"tier_kind\"><option value=\"\">kind…</option>" + _qdKindOptions(kinds, tier.discount_kind) + "</select>" +
8200
+ "<input type=\"number\" name=\"tier_value\" value=\"" + _htmlEscape(valVal) + "\" placeholder=\"value\" min=\"0\" step=\"1\" class=\"input-code\">" +
8201
+ "</div>";
8202
+ }
8203
+
8204
+ // Quantity-discount tier-set list — a table of tier sets (scope,
8205
+ // scope_id, #tiers, exclusive, status) plus a create form. The create
8206
+ // form's scope dropdown + each tier row's kind dropdown use the engine's
8207
+ // exact enums so a console-built set always validates. Mirrors
8208
+ // renderAdminCollections.
8209
+ function renderAdminQuantityDiscounts(opts) {
8210
+ opts = opts || {};
8211
+ var rows = opts.tier_sets || [];
8212
+ var scopes = opts.scopes || quantityDiscountsModule.VALID_SCOPES;
8213
+ var kinds = opts.kinds || quantityDiscountsModule.VALID_KINDS;
8214
+ var created = opts.created ? "<div class=\"banner banner--ok\">Tier set created.</div>" : "";
8215
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
8216
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Tier set archived.</div>" : "";
8217
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
8218
+
8219
+ var af = opts.archived_filter;
8220
+ var chips = "<div class=\"order-filters\">" +
8221
+ "<a class=\"chip" + (af == null ? " chip--on" : "") + "\" href=\"/admin/quantity-discounts\">All</a>" +
8222
+ "<a class=\"chip" + (af === "0" ? " chip--on" : "") + "\" href=\"/admin/quantity-discounts?archived=0\">Active</a>" +
8223
+ "<a class=\"chip" + (af === "1" ? " chip--on" : "") + "\" href=\"/admin/quantity-discounts?archived=1\">Archived</a>" +
8224
+ "</div>";
8225
+
8226
+ var bodyRows = rows.map(function (s) {
8227
+ var isArchived = s.archived_at != null;
8228
+ var enc = encodeURIComponent(s.id);
8229
+ return "<tr>" +
8230
+ "<td><a href=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(s.scope) + "</strong></a></td>" +
8231
+ "<td><code class=\"order-id\">" + _htmlEscape(s.scope_id == null ? "—" : s.scope_id) + "</code></td>" +
8232
+ "<td class=\"num\">" + _htmlEscape(String((s.tiers || []).length)) + "</td>" +
8233
+ "<td><span class=\"status-pill " + (s.exclusive ? "pending" : "paid") + "\">" + (s.exclusive ? "exclusive" : "stacks") + "</span></td>" +
8234
+ "<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
8235
+ "<td><div class=\"actions-row\">" +
8236
+ "<a class=\"btn btn--ghost\" href=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "\">Manage</a>" +
8237
+ (isArchived
8238
+ ? "<form method=\"post\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "/unarchive\" class=\"form-inline\">" +
8239
+ "<button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>"
8240
+ : "<form method=\"post\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
8241
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
8242
+ "</div></td>" +
8243
+ "</tr>";
8244
+ }).join("");
8245
+
8246
+ var table = rows.length
8247
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Scope</th><th scope=\"col\">Scope id</th><th scope=\"col\" class=\"num\">Tiers</th><th scope=\"col\">Stacking</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
8248
+ : "<p class=\"empty\">No quantity breaks" + (af === "1" ? " archived" : af === "0" ? " active" : " yet") + ".</p>";
8249
+
8250
+ var scopeOpts = scopes.map(function (sc) {
8251
+ return "<option value=\"" + _htmlEscape(sc) + "\"" + (sc === "sku" ? " selected" : "") + ">" + _htmlEscape(sc) + "</option>";
8252
+ }).join("");
8253
+
8254
+ // Three starter tier rows on create — enough for a typical "5 / 10 /
8255
+ // 20" schedule without a separate add-row round-trip. Blank rows are
8256
+ // dropped server-side; the operator fills as many as they need.
8257
+ var starterRows = _qdTierRow(kinds, {}) + _qdTierRow(kinds, {}) + _qdTierRow(kinds, {});
8258
+
8259
+ var createForm =
8260
+ "<div class=\"panel mt mw-40\">" +
8261
+ "<h3 class=\"subhead\">Create a tier set</h3>" +
8262
+ "<p class=\"meta\">A quantity break attaches to a scope (one SKU, product, collection, vendor, category, or everything) and a schedule of (min quantity, kind, value) rules. The pricing engine applies the best matching rule per cart line automatically.</p>" +
8263
+ "<form method=\"post\" action=\"/admin/quantity-discounts\">" +
8264
+ "<label class=\"form-field\"><span>Scope</span><select name=\"scope\">" + scopeOpts + "</select></label>" +
8265
+ _setupField("Scope id", "scope_id", "", "text", "The SKU / product id / collection slug / vendor / category. Leave blank only when scope = global.", " maxlength=\"256\"") +
8266
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"exclusive\" value=\"on\"> Exclusive — when this set's best rule applies, no other tier set may stack on the same line.</label>" +
8267
+ "<fieldset class=\"box\">" +
8268
+ "<legend class=\"legend-sm\">Tiers (min quantity → discount)</legend>" +
8269
+ starterRows +
8270
+ "<small class=\"u-mute\">Leave a row blank to drop it. min quantity is a positive integer; value's meaning depends on the kind (percent_off = basis points, the amount/fixed kinds = minor units). Duplicate min quantities in one set are refused.</small>" +
8271
+ "</fieldset>" +
8272
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create tier set</button></div>" +
8273
+ "</form>" +
8274
+ "</div>";
8275
+
8276
+ var bodyHtml = "<section><h2>Quantity breaks</h2>" + created + saved + archived + notice + chips + table + createForm + "</section>";
8277
+ return _renderAdminShell(opts.shop_name, "Quantity breaks", bodyHtml, "quantity-discounts", opts.nav_available);
8278
+ }
8279
+
8280
+ // Quantity-discount tier-set detail — the set's scope + schedule, a
8281
+ // rewrite form (rewrite the tier rows / toggle exclusive), archive or
8282
+ // unarchive, and a sample-price tierBreakdown preview. Mirrors
8283
+ // renderAdminCollection.
8284
+ function renderAdminQuantityDiscount(opts) {
8285
+ opts = opts || {};
8286
+ var s = opts.tier_set;
8287
+ if (!s) {
8288
+ var nf = "<section><h2>Quantity break</h2><p class=\"empty\">Tier set not found.</p>" +
8289
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/quantity-discounts\">Back to quantity breaks</a></div></section>";
8290
+ return _renderAdminShell(opts.shop_name, "Quantity break", nf, "quantity-discounts", opts.nav_available);
8291
+ }
8292
+ var kinds = opts.kinds || quantityDiscountsModule.VALID_KINDS;
8293
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
8294
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
8295
+ var enc = encodeURIComponent(s.id);
8296
+ var isArchived = s.archived_at != null;
8297
+
8298
+ // Render the stored tiers plus one spare empty row so the operator can
8299
+ // append without a separate round-trip. The edit POST rewrites the
8300
+ // whole schedule (the engine's update replaces tiers wholesale).
8301
+ var tierRows = (s.tiers || []).map(function (t) { return _qdTierRow(kinds, t); }).join("") + _qdTierRow(kinds, {});
8302
+
8303
+ var editForm =
8304
+ "<div class=\"panel mw-40\">" +
8305
+ "<h3 class=\"subhead\">Schedule</h3>" +
8306
+ "<form method=\"post\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "/edit\">" +
8307
+ "<input type=\"hidden\" name=\"exclusive_present\" value=\"1\">" +
8308
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"exclusive\" value=\"on\"" + (s.exclusive ? " checked" : "") + "> Exclusive — no other tier set may stack on the same line.</label>" +
8309
+ "<fieldset class=\"box\">" +
8310
+ "<legend class=\"legend-sm\">Tiers (rewrites the whole schedule)</legend>" +
8311
+ tierRows +
8312
+ "<small class=\"u-mute\">Leave a row blank to drop it. Rewriting replaces every rule in this set. Duplicate min quantities are refused.</small>" +
8313
+ "</fieldset>" +
8314
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save schedule</button></div>" +
8315
+ "</form>" +
8316
+ "</div>";
8317
+
8318
+ // tierBreakdown preview — the active schedule at a sample unit price so
8319
+ // the operator can sanity-check the discounted unit / line at each
8320
+ // threshold. The sample defaults to 1000 minor (e.g. $10.00); the
8321
+ // operator can re-sample via the query string.
8322
+ var sampleMinor = opts.sample_minor == null ? 1000 : opts.sample_minor;
8323
+ var breakdown = opts.breakdown;
8324
+ var previewRows = (breakdown && Array.isArray(breakdown.rows) ? breakdown.rows : []).map(function (r) {
8325
+ var du = r.sample_discounted_unit_minor;
8326
+ var ld = r.sample_line_discount_minor;
8327
+ var ls = r.sample_line_subtotal_minor;
8328
+ return "<tr>" +
8329
+ "<td class=\"num\">" + _htmlEscape(String(r.min_quantity)) + "</td>" +
8330
+ "<td>" + _htmlEscape(r.discount_kind) + "</td>" +
8331
+ "<td class=\"num\">" + _htmlEscape(String(r.value)) + "</td>" +
8332
+ "<td class=\"num\">" + _htmlEscape(du == null ? "—" : String(du)) + "</td>" +
8333
+ "<td class=\"num\">" + _htmlEscape(ld == null ? "—" : String(ld)) + "</td>" +
8334
+ "<td class=\"num\">" + _htmlEscape(ls == null ? "—" : String(ls)) + "</td>" +
8335
+ "</tr>";
8336
+ }).join("");
8337
+ var previewTable = previewRows
8338
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\" class=\"num\">Min qty</th><th scope=\"col\">Kind</th><th scope=\"col\" class=\"num\">Value</th><th scope=\"col\" class=\"num\">Unit @ min</th><th scope=\"col\" class=\"num\">Line saved</th><th scope=\"col\" class=\"num\">Line total</th></tr></thead><tbody>" + previewRows + "</tbody></table></div>"
8339
+ : "<p class=\"empty\">No active rules to preview for this scope.</p>";
8340
+ var previewForm =
8341
+ "<form method=\"get\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "\" class=\"actions-row m-04\">" +
8342
+ "<label class=\"form-field\"><span>Sample unit price (minor units)</span>" +
8343
+ "<input type=\"number\" name=\"sample\" value=\"" + _htmlEscape(String(sampleMinor)) + "\" min=\"0\" step=\"1\" class=\"input-code\"></label>" +
8344
+ "<button class=\"btn btn--ghost\" type=\"submit\">Preview</button>" +
8345
+ "</form>";
8346
+ // An archived set is not active, so tierBreakdown (which filters to
8347
+ // active rules at this scope) would preview OTHER sets' rules — or
8348
+ // nothing. Show a clear restore prompt instead of a foreign/empty table.
8349
+ var previewSection = isArchived
8350
+ ? "<section class=\"mt\"><h3 class=\"fs-105\">Schedule preview</h3>" +
8351
+ "<p class=\"empty\">This tier set is archived, so its schedule isn't active. Restore the set to preview it.</p>" +
8352
+ "</section>"
8353
+ : "<section class=\"mt\"><h3 class=\"fs-105\">Schedule preview</h3>" +
8354
+ "<p class=\"meta\">The active rules for this scope at a sample unit price (all values in minor units). Helps sanity-check a schedule before it goes live.</p>" +
8355
+ previewForm + previewTable +
8356
+ "</section>";
8357
+
8358
+ var archiveBlock = isArchived
8359
+ ? "<form method=\"post\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "/unarchive\" class=\"form-inline\">" +
8360
+ "<button class=\"btn btn--ghost\" type=\"submit\">Restore tier set</button></form>"
8361
+ : "<form method=\"post\" action=\"/admin/quantity-discounts/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
8362
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive tier set</button></form>";
8363
+
8364
+ var head =
8365
+ "<p class=\"meta\"><a href=\"/admin/quantity-discounts\">&larr; Quantity breaks</a> · " +
8366
+ "<span class=\"status-pill " + (s.exclusive ? "pending" : "paid") + "\">" + (s.exclusive ? "exclusive" : "stacks") + "</span>" +
8367
+ (isArchived ? " · <span class=\"status-pill cancelled\">archived</span>" : "") +
8368
+ " · <code class=\"order-id\">" + _htmlEscape(s.scope_id == null ? "global" : s.scope_id) + "</code></p>";
8369
+
8370
+ var body = "<section><h2>" + _htmlEscape(s.scope) +
8371
+ (s.scope_id == null ? "" : " · " + _htmlEscape(s.scope_id)) + "</h2>" +
8372
+ saved + notice + head + editForm +
8373
+ "<div class=\"actions-row mt-1\">" + archiveBlock + "</div></section>" + previewSection;
8374
+ return _renderAdminShell(opts.shop_name, "Quantity break", body, "quantity-discounts", opts.nav_available);
8375
+ }
8376
+
7919
8377
  // Product detail / management screen — the console's full editor for a
7920
8378
  // single catalog product: its fields (slug / title / description /
7921
8379
  // status), its variants (create / edit / delete), each variant's price
@@ -7943,6 +8401,7 @@ function renderAdminProduct(opts) {
7943
8401
  var pid = _htmlEscape(p.id);
7944
8402
 
7945
8403
  var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
8404
+ var created = opts.created ? "<div class=\"banner banner--ok\">Product created. Add a variant with a SKU, set its price, and add stock to make it sellable.</div>" : "";
7946
8405
  var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
7947
8406
 
7948
8407
  // ---- product fields edit ---------------------------------------------
@@ -8128,7 +8587,7 @@ function renderAdminProduct(opts) {
8128
8587
  "<span class=\"status-pill " + statusCls + "\">" + _htmlEscape(p.status) + "</span>" +
8129
8588
  " · <a href=\"/products/" + _htmlEscape(encodeURIComponent(p.slug)) + "\" target=\"_blank\" rel=\"noreferrer\">View on storefront &rarr;</a></p>";
8130
8589
 
8131
- var body = "<section><h2>" + _htmlEscape(p.title) + "</h2>" + saved + notice + head +
8590
+ var body = "<section><h2>" + _htmlEscape(p.title) + "</h2>" + created + saved + notice + head +
8132
8591
  "<div class=\"actions-row mt-0\">" + archiveAction + "</div>" +
8133
8592
  editForm + "</section>" + variantsSection + mediaSection;
8134
8593
  return _renderAdminShell(opts.shop_name, "Product " + p.slug, body, "products", opts.nav_available);
@@ -8161,5 +8620,7 @@ module.exports = {
8161
8620
  renderAdminShipping: renderAdminShipping,
8162
8621
  renderAdminShippingZone: renderAdminShippingZone,
8163
8622
  renderAdminDiscounts: renderAdminDiscounts,
8623
+ renderAdminQuantityDiscounts: renderAdminQuantityDiscounts,
8624
+ renderAdminQuantityDiscount: renderAdminQuantityDiscount,
8164
8625
  renderAdminConfirm: renderAdminConfirm,
8165
8626
  };
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.1",
2
+ "version": "0.3.3",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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": {