@blamejs/blamejs-shop 0.3.18 → 0.3.19

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.3.x
10
10
 
11
+ - v0.3.19 (2026-05-30) — **Edit announcement bars, automatic-discount terms, and subscription plans from the console.** Three admin entities could be created but only partly edited from the console — the rest of their fields were reachable only through the API, so changing them meant archive-and-recreate or a manual API call. Each now has a detail screen with the full edit form. An announcement bar can have its message, links, schedule, audience, and dismissibility changed in place. An automatic discount can have its actual terms changed — the amount, percentage, threshold, or buy-x-get-y values — not just its title, priority, and on/off state. A subscription plan can have its price, billing interval, trial length, active state, and variant edited. Create and archive behavior is unchanged. **Fixed:** *Announcement bars are editable in place* — A detail screen now lets you change an announcement's message, link, theme, audience, start/end schedule, and whether it can be dismissed — instead of archiving it and creating a new one (which lost the slug and any recorded dismissal state). · *Automatic-discount terms are editable from the console* — The discount detail screen now edits the rule's trigger and value — the amount off, percent off, cart threshold, or buy-x-get-y terms — which previously could only be changed through the API. The inline priority and on/off controls are unchanged. · *Subscription plans have an edit screen* — A subscription plan's price, billing interval count, trial length, active state, and variant can now be edited from a detail screen. The Stripe-bound fields (the price id, interval unit, and currency) remain read-only, as they were always immutable.
12
+
11
13
  - v0.3.18 (2026-05-30) — **Write and publish blog posts from the admin console.** The storefront already served a blog — the index at /blog, individual posts at /blog/:slug, an RSS feed, and sitemap entries — but there was no way to write a post, so it rendered empty. The admin console now has a Blog screen with the full lifecycle: create a draft, edit its title, body (Markdown), tags, hero image, and meta fields, publish it, unpublish it, and archive or restore it. A post is created as a draft and stays off the storefront until it is explicitly published, so work in progress is never visible to shoppers. **Added:** *Blog authoring in the admin console* — A new Blog screen lists posts by status and lets an operator create, edit, publish, unpublish, archive, and restore them. New posts start as drafts and do not appear on the storefront /blog, its RSS feed, or the sitemap until published; archiving removes a published post from the storefront and is reversible. This wires the admin over the blog backend that was already serving the customer-facing pages.
12
14
 
13
15
  - v0.3.17 (2026-05-30) — **Search results paginate with an accurate count, and collection pages get canonical URLs.** Two customer-facing storefront fixes. Search no longer stops at the first 24 products: the result count now reports the true number of matches instead of the page size, and numbered page links (with prev/next) make every matching product reachable while preserving the active query and filters. Separately, collection pages now emit a correct canonical and og:url so search engines and shared links resolve to one address, while the cart and account pages — which should not be indexed — are explicitly marked noindex. **Fixed:** *Search shows the real match count and paginates* — The search results page previously capped at 24 products, discarded the real total, and reported the page size as the match count — so anything past the first 24 was unreachable and the count was wrong. It now computes the true total, shows it in the "Showing N matches" summary, and adds no-JavaScript numbered page links (with prev/next) that carry the active query and facet filters, so every matching product can be reached. · *Collection pages have canonical URLs; cart and account are noindex* — Collection pages now emit an absolute canonical and og:url derived from the request host, so indexers and shared links resolve to a single address instead of an empty tag. The cart and account pages, which are not meant to be indexed, now carry a noindex robots tag in their markup, consistent on both the edge and container render paths.
package/lib/admin.js CHANGED
@@ -4005,6 +4005,55 @@ function mount(router, deps) {
4005
4005
  },
4006
4006
  ));
4007
4007
 
4008
+ // Detail screen: the single announcement + its edit form. Content-
4009
+ // negotiates like the list — bearer → the JSON row; browser cookie →
4010
+ // the rendered edit page. A bad / unknown slug is a 404 page (browser)
4011
+ // or 404 problem (bearer), never a 500.
4012
+ router.get("/admin/announcements/:slug", _pageOrApi(true,
4013
+ R(async function (req, res) {
4014
+ var row = await announcements.getAnnouncement(req.params.slug);
4015
+ if (!row) return _problem(res, 404, "announcement-not-found");
4016
+ _json(res, 200, row);
4017
+ }),
4018
+ async function (req, res) {
4019
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4020
+ var row = await announcements.getAnnouncement(req.params.slug);
4021
+ if (!row) return _sendHtml(res, 404, renderAdminAnnouncements({
4022
+ shop_name: deps.shop_name, nav_available: navAvailable, announcements: [], notice: "Announcement not found.",
4023
+ }));
4024
+ _sendHtml(res, 200, renderAdminAnnouncement({
4025
+ shop_name: deps.shop_name, nav_available: navAvailable, announcement: row,
4026
+ updated: url && url.searchParams.get("updated"),
4027
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the announcement." : null,
4028
+ }));
4029
+ },
4030
+ ));
4031
+
4032
+ // Edit content-negotiates: bearer POST /edit (JSON) + browser POST
4033
+ // /edit (HTML forms can't PATCH). Both forward the full editable
4034
+ // column set — message, link, audience, schedule, dismissible — into
4035
+ // updateAnnouncement, preserving the slug + accumulated dismissal
4036
+ // state (archive-and-recreate would discard both). PRG to ?updated=1;
4037
+ // a bad shape is a clean 400 (bearer) / err notice (browser).
4038
+ router.post("/admin/announcements/:slug/edit", _pageOrApi(false,
4039
+ W("announcement.update", async function (req, res) {
4040
+ var row;
4041
+ try { row = await announcements.updateAnnouncement(req.params.slug, _announcementPatchFromForm(req.body || {})); }
4042
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4043
+ if (!row) return _problem(res, 404, "announcement-not-found");
4044
+ _json(res, 200, row);
4045
+ return { id: row.slug };
4046
+ }),
4047
+ async function (req, res) {
4048
+ var slug = req.params.slug;
4049
+ var enc = encodeURIComponent(slug);
4050
+ try { await announcements.updateAnnouncement(slug, _announcementPatchFromForm(req.body || {})); }
4051
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/announcements/" + enc + "?err=1"); }
4052
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".announcement.update", outcome: "success", metadata: { slug: slug } });
4053
+ _redirect(res, "/admin/announcements/" + enc + "?updated=1");
4054
+ },
4055
+ ));
4056
+
4008
4057
  router.post("/admin/announcements/:slug/archive", _pageOrApi(false,
4009
4058
  W("announcement.archive", async function (req, res) {
4010
4059
  var row;
@@ -4810,6 +4859,29 @@ function mount(router, deps) {
4810
4859
 
4811
4860
  var subscriptions = deps.subscriptions || null;
4812
4861
  if (subscriptions) {
4862
+ // Coerce the edit form into a plans.update patch. Only the mutable
4863
+ // columns are forwarded; an absent / blank field is omitted so a
4864
+ // partial edit leaves the rest untouched. amount_minor /
4865
+ // interval_count / trial_days go through the strict integer reader so
4866
+ // "", a float, or "12abc" is refused as a clean 400 rather than
4867
+ // coerced. active rides a hidden presence marker so a value-only edit
4868
+ // doesn't flip it. variant_id="" links the plan to no variant
4869
+ // (standalone); the immutable Stripe-bound columns are never on the
4870
+ // form.
4871
+ function _subscriptionPlanPatchFromForm(body) {
4872
+ body = body || {};
4873
+ var patch = {};
4874
+ if (body.amount_minor != null && body.amount_minor !== "") patch.amount_minor = _strictMinorInt(body.amount_minor, "subscriptions", "amount_minor (minor units)");
4875
+ if (body.interval_count != null && body.interval_count !== "") patch.interval_count = _strictMinorInt(body.interval_count, "subscriptions", "interval_count");
4876
+ if (body.trial_days != null && body.trial_days !== "") patch.trial_days = _strictMinorInt(body.trial_days, "subscriptions", "trial_days");
4877
+ if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
4878
+ if (Object.prototype.hasOwnProperty.call(body, "variant_id")) {
4879
+ var vid = typeof body.variant_id === "string" ? body.variant_id.trim() : body.variant_id;
4880
+ patch.variant_id = vid ? vid : null;
4881
+ }
4882
+ return patch;
4883
+ }
4884
+
4813
4885
  // Create content-negotiates: bearer → JSON (unchanged for tooling);
4814
4886
  // signed-in browser form → create, then PRG back to the catalog (a
4815
4887
  // bad-shape submit re-renders the form with the validator's message
@@ -4874,19 +4946,74 @@ function mount(router, deps) {
4874
4946
  },
4875
4947
  ));
4876
4948
 
4877
- router.get("/admin/subscription-plans/:id", R(async function (req, res) {
4878
- var p = await subscriptions.plans.get(req.params.id);
4879
- if (!p) return _problem(res, 404, "subscription-plan-not-found");
4880
- _json(res, 200, p);
4881
- }));
4949
+ // Detail screen content-negotiates: bearer the JSON plan (the
4950
+ // tooling contract, unchanged); browser cookie → the rendered detail
4951
+ // + edit page. A malformed / unknown id is a 404 either way, never a
4952
+ // 500. (Previously this route was bearer-JSON-only, so the console
4953
+ // had no way to change a plan's price / interval-count / trial after
4954
+ // create — only archive-and-recreate against a fresh Stripe price.)
4955
+ router.get("/admin/subscription-plans/:id", _pageOrApi(true,
4956
+ R(async function (req, res) {
4957
+ var p;
4958
+ try { p = await subscriptions.plans.get(req.params.id); }
4959
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "subscription-plan-not-found", e.message); throw e; }
4960
+ if (!p) return _problem(res, 404, "subscription-plan-not-found");
4961
+ _json(res, 200, p);
4962
+ }),
4963
+ async function (req, res) {
4964
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4965
+ var p = null;
4966
+ try { p = await subscriptions.plans.get(req.params.id); }
4967
+ catch (e) { if (!(e instanceof TypeError)) throw e; p = null; }
4968
+ if (!p) return _sendHtml(res, 404, renderAdminSubscriptionPlans({
4969
+ shop_name: deps.shop_name, nav_available: navAvailable, plans: [], notice: "Subscription plan not found.",
4970
+ }));
4971
+ _sendHtml(res, 200, renderAdminSubscriptionPlan({
4972
+ shop_name: deps.shop_name, nav_available: navAvailable, plan: p,
4973
+ updated: url && url.searchParams.get("updated"),
4974
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the plan." : null,
4975
+ }));
4976
+ },
4977
+ ));
4882
4978
 
4883
4979
  router.patch("/admin/subscription-plans/:id", W("subscription_plan.update", async function (req, res) {
4884
- var p = await subscriptions.plans.update(req.params.id, req.body || {});
4980
+ var p;
4981
+ try { p = await subscriptions.plans.update(req.params.id, req.body || {}); }
4982
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4885
4983
  if (!p) return _problem(res, 404, "subscription-plan-not-found");
4886
4984
  _json(res, 200, p);
4887
4985
  return p;
4888
4986
  }));
4889
4987
 
4988
+ // Browser edit alias for the PATCH (HTML forms can't PATCH). Forwards
4989
+ // the mutable columns — amount / interval_count / trial_days / active
4990
+ // / variant_id — through plans.update; the immutable Stripe-bound
4991
+ // columns (stripe_price_id / interval / currency) are not on the form
4992
+ // (changing those is archive-and-recreate). A bad value is a clean
4993
+ // 400 (bearer) / err notice (browser), never a 500 or partial write.
4994
+ router.post("/admin/subscription-plans/:id/edit", _pageOrApi(false,
4995
+ W("subscription_plan.update", async function (req, res) {
4996
+ var p;
4997
+ try { p = await subscriptions.plans.update(req.params.id, _subscriptionPlanPatchFromForm(req.body || {})); }
4998
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4999
+ if (!p) return _problem(res, 404, "subscription-plan-not-found");
5000
+ _json(res, 200, p);
5001
+ return p;
5002
+ }),
5003
+ async function (req, res) {
5004
+ var id = req.params.id;
5005
+ var enc = encodeURIComponent(id);
5006
+ var updated = null;
5007
+ try { updated = await subscriptions.plans.update(id, _subscriptionPlanPatchFromForm(req.body || {})); }
5008
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/subscription-plans/" + enc + "?err=1"); }
5009
+ // A well-formed-but-unknown id (stale / tampered form) updates no
5010
+ // row — plans.update returns null. Flag err, never a false success.
5011
+ if (!updated) return _redirect(res, "/admin/subscription-plans/" + enc + "?err=1");
5012
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".subscription_plan.update", outcome: "success", metadata: { id: id } });
5013
+ _redirect(res, "/admin/subscription-plans/" + enc + "?updated=1");
5014
+ },
5015
+ ));
5016
+
4890
5017
  // Browser confirmation interstitial for archive — terminal from the
4891
5018
  // console (a retired plan is replaced by creating a new one against a
4892
5019
  // fresh Stripe price id, never reactivated in place), so confirm it.
@@ -5858,6 +5985,30 @@ function mount(router, deps) {
5858
5985
  return { id: rule.slug };
5859
5986
  }));
5860
5987
 
5988
+ // Detail screen: the single rule + a full edit form covering the
5989
+ // trigger + value terms (not just priority/active). Content-
5990
+ // negotiates like the list — bearer → the JSON rule; browser cookie
5991
+ // → the rendered edit page.
5992
+ router.get("/admin/discounts/:slug", _pageOrApi(true,
5993
+ R(async function (req, res) {
5994
+ var rule = await autoDiscount.getRule(req.params.slug);
5995
+ if (!rule) return _problem(res, 404, "auto-discount-not-found");
5996
+ _json(res, 200, rule);
5997
+ }),
5998
+ async function (req, res) {
5999
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6000
+ var rule = await autoDiscount.getRule(req.params.slug);
6001
+ if (!rule) return _sendHtml(res, 404, await _renderDiscounts({
6002
+ notice: "Discount rule not found.",
6003
+ }));
6004
+ _sendHtml(res, 200, renderAdminDiscount({
6005
+ shop_name: deps.shop_name, nav_available: navAvailable, rule: rule,
6006
+ updated: url && url.searchParams.get("updated"),
6007
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the rule." : null,
6008
+ }));
6009
+ },
6010
+ ));
6011
+
5861
6012
  router.post("/admin/discounts/:slug/edit", _pageOrApi(false,
5862
6013
  W("auto_discount.update", async function (req, res) {
5863
6014
  var rule;
@@ -5867,9 +6018,18 @@ function mount(router, deps) {
5867
6018
  return { id: rule.slug };
5868
6019
  }),
5869
6020
  async function (req, res) {
5870
- try { await autoDiscount.updateRule(req.params.slug, _discountPatch(req.body || {})); }
5871
- catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/discounts?err=1"); }
5872
- b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".auto_discount.update", outcome: "success", metadata: { slug: req.params.slug } });
6021
+ var slug = req.params.slug;
6022
+ // A terms edit (trigger/value) comes from the detail screen and
6023
+ // returns there on error so the operator sees their input in
6024
+ // context; the inline row edit (priority/active only) stays on
6025
+ // the list. Success PRGs to the list's ?updated banner either way.
6026
+ var fromDetail = (req.body && (req.body.trigger_kind || req.body.value_kind));
6027
+ var errHref = fromDetail
6028
+ ? "/admin/discounts/" + encodeURIComponent(slug) + "?err=1"
6029
+ : "/admin/discounts?err=1";
6030
+ try { await autoDiscount.updateRule(slug, _discountPatch(req.body || {})); }
6031
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, errHref); }
6032
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".auto_discount.update", outcome: "success", metadata: { slug: slug } });
5873
6033
  _redirect(res, "/admin/discounts?updated=1");
5874
6034
  },
5875
6035
  ));
@@ -6263,15 +6423,24 @@ function mount(router, deps) {
6263
6423
  return { kind: "free_shipping" };
6264
6424
  }
6265
6425
 
6266
- // Translate the rule edit form into an updateRule patch title /
6267
- // priority / active. The trigger + value re-set go through the bearer
6268
- // JSON PATCH for the full vocabulary; the console edit is the common
6269
- // "rename / re-prioritise / pause" gesture.
6426
+ // Translate the rule edit form into an updateRule patch. The inline
6427
+ // row form covers the common "rename / re-prioritise / pause" gesture
6428
+ // (title / priority / active); the detail screen additionally posts a
6429
+ // trigger_kind + value_kind so an operator can change the actual
6430
+ // discount terms (amount / percentage / threshold / BOGO) from the
6431
+ // console, not just reprioritise. The trigger / value kind fields are
6432
+ // reused from the create form's vocabulary via _discountTrigger /
6433
+ // _discountValue, which throw a TypeError on a bad / missing required
6434
+ // field — so a bad terms edit degrades to a clean 400, never a 500,
6435
+ // and never a silent partial write. The richer applies_to / segment /
6436
+ // exclusion vocabulary stays on the bearer JSON PATCH.
6270
6437
  function _discountPatch(body) {
6271
6438
  var patch = {};
6272
6439
  if (Object.prototype.hasOwnProperty.call(body, "title") && body.title !== "") patch.title = body.title;
6273
6440
  if (body.priority != null && body.priority !== "") patch.priority = _strictMinorInt(body.priority, "autoDiscount", "priority");
6274
6441
  if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
6442
+ if (body.trigger_kind != null && body.trigger_kind !== "") patch.trigger = _discountTrigger(body);
6443
+ if (body.value_kind != null && body.value_kind !== "") patch.value = _discountValue(body);
6275
6444
  return patch;
6276
6445
  }
6277
6446
 
@@ -8163,6 +8332,56 @@ function renderAdminInventory(opts) {
8163
8332
  return _renderAdminShell(opts.shop_name, "Inventory", bodyHtml, "inventory", opts.nav_available);
8164
8333
  }
8165
8334
 
8335
+ // Single subscription-plan detail + edit form. The Stripe-bound columns
8336
+ // (price id / interval / currency) are immutable post-create — shown
8337
+ // read-only — because they mirror a recurring Stripe Price; to change
8338
+ // those an operator archives the plan and creates a new one against a
8339
+ // fresh price id. The mutable columns (amount / interval count / trial /
8340
+ // active / variant link) are editable here.
8341
+ function renderAdminSubscriptionPlan(opts) {
8342
+ opts = opts || {};
8343
+ var p = opts.plan;
8344
+ if (!p) {
8345
+ var nf = "<section><h2>Subscription plan</h2><p class=\"empty\">Subscription plan not found.</p>" +
8346
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/subscription-plans\">Back to plans</a></div></section>";
8347
+ return _renderAdminShell(opts.shop_name, "Subscription plan", nf, "subscriptions", opts.nav_available);
8348
+ }
8349
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Plan updated.</div>" : "";
8350
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
8351
+ var isActive = p.active === 1 || p.active === true;
8352
+ var cur = String(p.currency || "").toUpperCase();
8353
+ var every = p.interval_count > 1 ? p.interval_count + " " + p.interval + "s" : p.interval;
8354
+
8355
+ var summary =
8356
+ "<div class=\"panel\"><dl class=\"detail-grid\">" +
8357
+ "<div><dt>Price</dt><dd><strong>" + _htmlEscape(pricing.format(p.amount_minor, cur) + " / " + every) + "</strong></dd></div>" +
8358
+ "<div><dt>Stripe price</dt><dd><code class=\"order-id\">" + _htmlEscape(p.stripe_price_id) + "</code></dd></div>" +
8359
+ "<div><dt>Variant</dt><dd>" + (p.variant_id ? "<code class=\"order-id\">" + _htmlEscape(String(p.variant_id)) + "</code>" : "<span class=\"meta\">standalone</span>") + "</dd></div>" +
8360
+ "<div><dt>Trial</dt><dd>" + (p.trial_days ? _htmlEscape(String(p.trial_days)) + " days" : "<span class=\"meta\">none</span>") + "</dd></div>" +
8361
+ "<div><dt>Status</dt><dd><span class=\"status-pill " + (isActive ? "paid" : "cancelled") + "\">" + (isActive ? "active" : "archived") + "</span></dd></div>" +
8362
+ "</dl></div>";
8363
+
8364
+ var editForm = !isActive
8365
+ ? "<p class=\"empty\">This plan is archived and can no longer be edited. Create a new plan against a fresh Stripe price id to offer it again.</p>"
8366
+ : "<div class=\"panel mw-34\">" +
8367
+ "<h3 class=\"subhead\">Edit plan</h3>" +
8368
+ "<p class=\"meta\">The Stripe price id, billing interval, and currency are fixed — they mirror the Stripe Price. To change those, archive this plan and create a new one.</p>" +
8369
+ "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/edit\">" +
8370
+ "<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
8371
+ _setupField("Amount (minor units)", "amount_minor", String(p.amount_minor), "number", "In the currency's smallest unit — e.g. 1999 = $19.99.", " min=\"1\"") +
8372
+ _setupField("Interval count", "interval_count", String(p.interval_count), "number", "Bill every N " + _htmlEscape(p.interval) + "s (1–12).", " min=\"1\" max=\"12\"") +
8373
+ _setupField("Trial days", "trial_days", String(p.trial_days), "number", "Free trial length before the first charge (0–730).", " min=\"0\" max=\"730\"") +
8374
+ _setupField("Variant id (optional)", "variant_id", p.variant_id ? String(p.variant_id) : "", "text", "Link to a storefront variant, or clear for a standalone tier.", " maxlength=\"64\"") +
8375
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"active\" checked> Active</label>" +
8376
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
8377
+ "</form>" +
8378
+ "</div>";
8379
+
8380
+ var bodyHtml = "<section><h2>Subscription plan</h2>" + updated + notice + summary + editForm +
8381
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/subscription-plans\">Back to plans</a></div></section>";
8382
+ return _renderAdminShell(opts.shop_name, "Subscription plan", bodyHtml, "subscriptions", opts.nav_available);
8383
+ }
8384
+
8166
8385
  function renderAdminSubscriptionPlans(opts) {
8167
8386
  opts = opts || {};
8168
8387
  var rows = opts.plans || [];
@@ -8188,9 +8407,10 @@ function renderAdminSubscriptionPlans(opts) {
8188
8407
  var price = pricing.format(p.amount_minor, String(p.currency || "").toUpperCase()) + " / " + every;
8189
8408
  var isActive = p.active === 1 || p.active === true;
8190
8409
  var archiveCell = isActive
8191
- ? "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive/confirm\" class=\"form-inline\">" +
8410
+ ? "<a class=\"btn btn--ghost\" href=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "\">Edit</a> " +
8411
+ "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive/confirm\" class=\"form-inline\">" +
8192
8412
  "<button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>"
8193
- : "<span class=\"meta\">—</span>";
8413
+ : "<a class=\"btn btn--ghost\" href=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "\">View</a>";
8194
8414
  return "<tr>" +
8195
8415
  "<td><strong>" + _htmlEscape(price) + "</strong>" +
8196
8416
  (p.trial_days ? " <span class=\"status-pill pending\">" + _htmlEscape(String(p.trial_days)) + "d trial</span>" : "") + "</td>" +
@@ -8733,6 +8953,83 @@ function _fmtValue(v) {
8733
8953
  return v.kind;
8734
8954
  }
8735
8955
 
8956
+ // Single auto-discount rule detail + a full edit form. Unlike the
8957
+ // inline row form (priority/active only), this form re-collects the
8958
+ // trigger + value terms so an operator can change the amount / percent
8959
+ // / threshold / BOGO quantities from the console. The trigger / value
8960
+ // field set mirrors the create form exactly; each kind's fields are
8961
+ // prefilled from the rule's current terms, and the operator fills the
8962
+ // ones the chosen kind needs (the backend validates the required
8963
+ // fields for the kind). The hidden active_present marker keeps a
8964
+ // terms-only edit from flipping active.
8965
+ function renderAdminDiscount(opts) {
8966
+ opts = opts || {};
8967
+ var r = opts.rule;
8968
+ if (!r) {
8969
+ var nf = "<section><h2>Discount rule</h2><p class=\"empty\">Discount rule not found.</p>" +
8970
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/discounts\">Back to discounts</a></div></section>";
8971
+ return _renderAdminShell(opts.shop_name, "Discount rule", nf, "discounts", opts.nav_available);
8972
+ }
8973
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Rule updated.</div>" : "";
8974
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
8975
+ var isArchived = r.archived_at != null;
8976
+ var t = r.trigger || {};
8977
+ var v = r.value || {};
8978
+
8979
+ var TRIGGERS = [
8980
+ { value: "cart_total_min", label: "Cart total ≥ (minor)" },
8981
+ { value: "item_count_min", label: "Item count ≥" },
8982
+ { value: "sku_purchase", label: "SKU purchased" },
8983
+ ];
8984
+ var VALUES = [
8985
+ { value: "percent_off", label: "Percent off" },
8986
+ { value: "amount_off_total", label: "Amount off total" },
8987
+ { value: "amount_off_each", label: "Amount off each" },
8988
+ { value: "free_shipping", label: "Free shipping" },
8989
+ { value: "bogo", label: "Buy X get Y" },
8990
+ ];
8991
+ // For amount_off_total / amount_off_each the lib stores one `minor`
8992
+ // field; prefill it for whichever amount kind is active.
8993
+ var amountMinor = (v.kind === "amount_off_total" || v.kind === "amount_off_each") && v.minor != null ? String(v.minor) : "";
8994
+
8995
+ var editForm = isArchived
8996
+ ? "<p class=\"empty\">This rule is archived and can no longer be edited.</p>"
8997
+ : "<div class=\"panel mw-42\">" +
8998
+ "<h3 class=\"subhead\">Edit rule</h3>" +
8999
+ "<p class=\"meta\">Change the trigger, the value, or the priority. Fill the fields for the trigger / value kind you pick — the others are ignored.</p>" +
9000
+ "<form method=\"post\" action=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "/edit\">" +
9001
+ "<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
9002
+ _setupField("Title", "title", r.title, "text", "Operator-facing name.", " maxlength=\"200\"") +
9003
+ _selectField("Trigger kind", "trigger_kind", TRIGGERS, t.kind || "cart_total_min", "What fires the rule.", "") +
9004
+ _setupField("· Cart total min (minor)", "trigger_min_minor", t.kind === "cart_total_min" && t.min_minor != null ? String(t.min_minor) : "", "number", "For \"Cart total ≥\".", " min=\"0\"") +
9005
+ _setupField("· Item count min", "trigger_min_count", t.kind === "item_count_min" && t.min_count != null ? String(t.min_count) : "", "number", "For \"Item count ≥\".", " min=\"1\"") +
9006
+ _setupField("· SKUs (comma-separated)", "trigger_skus", t.kind === "sku_purchase" && Array.isArray(t.skus) ? t.skus.join(",") : "", "text", "For \"SKU purchased\".", " maxlength=\"2000\"") +
9007
+ _setupField("· SKU min quantity", "trigger_min_quantity", t.kind === "sku_purchase" && t.min_quantity != null ? String(t.min_quantity) : "", "number", "For \"SKU purchased\" (default 1).", " min=\"1\"") +
9008
+ _selectField("Value kind", "value_kind", VALUES, v.kind || "percent_off", "What the rule gives.", "") +
9009
+ _setupField("· Percent (basis points)", "value_basis_points", v.kind === "percent_off" && v.basis_points != null ? String(v.basis_points) : "", "number", "For \"Percent off\". 1000 = 10.00%.", " min=\"1\" max=\"10000\"") +
9010
+ _setupField("· Amount (minor)", "value_minor", amountMinor, "number", "For \"Amount off total / each\".", " min=\"1\"") +
9011
+ _setupField("· BOGO buy qty", "value_buy_qty", v.kind === "bogo" && v.buy_qty != null ? String(v.buy_qty) : "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
9012
+ _setupField("· BOGO get qty", "value_get_qty", v.kind === "bogo" && v.get_qty != null ? String(v.get_qty) : "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
9013
+ _setupField("Priority", "priority", String(r.priority), "number", "Higher wins ties.", " min=\"0\"") +
9014
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"active\"" + (r.active ? " checked" : "") + "> Active</label>" +
9015
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
9016
+ "</form>" +
9017
+ "</div>";
9018
+
9019
+ var bodyHtml = "<section><h2>Discount rule</h2>" + updated + notice +
9020
+ "<div class=\"panel\"><dl class=\"detail-grid\">" +
9021
+ "<div><dt>Rule</dt><dd><strong>" + _htmlEscape(r.title) + "</strong><br><code class=\"order-id\">" + _htmlEscape(r.slug) + "</code></dd></div>" +
9022
+ "<div><dt>Trigger</dt><dd>" + _htmlEscape(_fmtTrigger(r.trigger)) + "</dd></div>" +
9023
+ "<div><dt>Value</dt><dd>" + _htmlEscape(_fmtValue(r.value)) + "</dd></div>" +
9024
+ "<div><dt>Priority</dt><dd>" + _htmlEscape(String(r.priority)) + "</dd></div>" +
9025
+ "<div><dt>Status</dt><dd><span class=\"status-pill " + (isArchived ? "cancelled" : (r.active ? "paid" : "pending")) + "\">" + (isArchived ? "archived" : (r.active ? "active" : "paused")) + "</span></dd></div>" +
9026
+ "</dl></div>" +
9027
+ editForm +
9028
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/discounts\">Back to discounts</a></div>" +
9029
+ "</section>";
9030
+ return _renderAdminShell(opts.shop_name, "Discount rule", bodyHtml, "discounts", opts.nav_available);
9031
+ }
9032
+
8736
9033
  function renderAdminDiscounts(opts) {
8737
9034
  opts = opts || {};
8738
9035
  var rules = opts.rules || [];
@@ -8755,6 +9052,7 @@ function renderAdminDiscounts(opts) {
8755
9052
  "<label class=\"kv\"><input type=\"checkbox\" name=\"active\"" + (r.active ? " checked" : "") + "> on</label>" +
8756
9053
  "<button class=\"btn btn--ghost\" type=\"submit\">Save</button>" +
8757
9054
  "</form> " +
9055
+ "<a class=\"btn btn--ghost\" href=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "\">Edit terms</a> " +
8758
9056
  "<form method=\"post\" action=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "/archive\" class=\"form-inline\">" +
8759
9057
  "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>";
8760
9058
  return "<tr>" +
@@ -9249,6 +9547,112 @@ function _announcementFromForm(body) {
9249
9547
  return out;
9250
9548
  }
9251
9549
 
9550
+ // Coerce the edit form into an updateAnnouncement patch. Every editable
9551
+ // column the primitive's whitelist accepts is forwarded so a console
9552
+ // edit reaches the same surface as the bearer PATCH: message, theme,
9553
+ // audience, dismissible, the link pair, and the two schedule bounds.
9554
+ // The schedule + link fields use the form's hidden presence markers so
9555
+ // a partial edit doesn't unintentionally clear an unrelated column: a
9556
+ // link_present marker maps a blank pair to null (clear the link); a
9557
+ // starts_present / expires_present marker maps a blank bound to null
9558
+ // (open the schedule). Absent markers leave the column untouched. The
9559
+ // primitive validates the result (audience<->segment, link both-or-
9560
+ // neither, expires>starts) and throws a TypeError on a bad shape, which
9561
+ // both surfaces degrade to a clean 400 / err notice.
9562
+ function _announcementPatchFromForm(body) {
9563
+ body = body || {};
9564
+ var patch = {};
9565
+ if (Object.prototype.hasOwnProperty.call(body, "message") && body.message !== "") patch.message = body.message;
9566
+ if (body.theme != null && body.theme !== "") patch.theme = body.theme;
9567
+ if (body.audience != null && body.audience !== "") patch.audience = body.audience;
9568
+ // The dismissible checkbox is present (on/1) when checked, absent when
9569
+ // unchecked — only treat it as a field when the form declared the
9570
+ // hidden marker so a partial JSON edit doesn't flip it.
9571
+ if (body.dismissible_present === "1") patch.dismissible = (body.dismissible === "on" || body.dismissible === "1");
9572
+ if (body.link_present === "1") {
9573
+ var lu = typeof body.link_url === "string" ? body.link_url.trim() : "";
9574
+ var ll = typeof body.link_label === "string" ? body.link_label.trim() : "";
9575
+ if (lu || ll) { patch.link_url = lu; patch.link_label = ll; }
9576
+ else { patch.link_url = null; patch.link_label = null; }
9577
+ }
9578
+ if (body.starts_present === "1") patch.starts_at = _epochFromForm(body.starts_at);
9579
+ if (body.expires_present === "1") patch.expires_at = _epochFromForm(body.expires_at);
9580
+ return patch;
9581
+ }
9582
+
9583
+ // epoch ms → the <datetime-local> value an <input> renders back. Returns
9584
+ // "" for a null/absent bound so the field stays empty (open-ended).
9585
+ function _datetimeLocalValue(epochMs) {
9586
+ if (epochMs == null) return "";
9587
+ var d = new Date(Number(epochMs));
9588
+ if (isNaN(d.getTime())) return "";
9589
+ function _pad(n) { return n < 10 ? "0" + n : String(n); }
9590
+ return d.getUTCFullYear() + "-" + _pad(d.getUTCMonth() + 1) + "-" + _pad(d.getUTCDate()) +
9591
+ "T" + _pad(d.getUTCHours()) + ":" + _pad(d.getUTCMinutes());
9592
+ }
9593
+
9594
+ // Single-announcement detail + edit screen. The form prefills every
9595
+ // editable column and posts to /edit; the hidden presence markers tell
9596
+ // the patch coercion which columns the form is authoritative for, so a
9597
+ // blank schedule / link clears rather than being ignored.
9598
+ function renderAdminAnnouncement(opts) {
9599
+ opts = opts || {};
9600
+ var a = opts.announcement;
9601
+ if (!a) {
9602
+ var nf = "<section><h2>Announcement</h2><p class=\"empty\">Announcement not found.</p>" +
9603
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/announcements\">Back to announcements</a></div></section>";
9604
+ return _renderAdminShell(opts.shop_name, "Announcement", nf, "announcements", opts.nav_available);
9605
+ }
9606
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Announcement updated.</div>" : "";
9607
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
9608
+ var isArchived = a.archived_at != null;
9609
+
9610
+ var themeOpts = ["urgency", "promo", "info", "success"].map(function (t) {
9611
+ return "<option value=\"" + t + "\"" + (t === a.theme ? " selected" : "") + ">" + t + "</option>";
9612
+ }).join("");
9613
+ // segment audience needs an isMember handle that isn't wired in the
9614
+ // console (see the create screen's note), so the picker offers the
9615
+ // three reachable audiences; an existing segment row keeps its value
9616
+ // as a disabled-looking selected option so an edit doesn't silently
9617
+ // re-target it.
9618
+ var audValues = ["all", "guest", "logged_in"];
9619
+ if (a.audience === "segment") audValues = ["segment"].concat(audValues);
9620
+ var audienceOpts = audValues.map(function (au) {
9621
+ return "<option value=\"" + au + "\"" + (au === a.audience ? " selected" : "") + ">" + au + "</option>";
9622
+ }).join("");
9623
+
9624
+ var editForm = isArchived
9625
+ ? "<p class=\"empty\">This announcement is archived and can no longer be edited.</p>"
9626
+ : "<div class=\"panel mw-40\">" +
9627
+ "<h3 class=\"subhead\">Edit announcement</h3>" +
9628
+ "<form method=\"post\" action=\"/admin/announcements/" + _htmlEscape(encodeURIComponent(a.slug)) + "/edit\">" +
9629
+ "<input type=\"hidden\" name=\"dismissible_present\" value=\"1\">" +
9630
+ "<input type=\"hidden\" name=\"link_present\" value=\"1\">" +
9631
+ "<input type=\"hidden\" name=\"starts_present\" value=\"1\">" +
9632
+ "<input type=\"hidden\" name=\"expires_present\" value=\"1\">" +
9633
+ "<label class=\"form-field\"><span>Message</span><textarea name=\"message\" maxlength=\"500\" required>" + _htmlEscape(a.message) + "</textarea></label>" +
9634
+ _setupField("Link URL (optional)", "link_url", a.link_url || "", "text", "https:// or a /-rooted path. Clear both to remove the link.", " maxlength=\"2048\"") +
9635
+ _setupField("Link label (optional)", "link_label", a.link_label || "", "text", "", " maxlength=\"120\"") +
9636
+ "<label class=\"form-field\"><span>Theme</span><select name=\"theme\">" + themeOpts + "</select></label>" +
9637
+ "<label class=\"form-field\"><span>Audience</span><select name=\"audience\">" + audienceOpts + "</select></label>" +
9638
+ "<label class=\"form-field\"><span>Starts at (optional)</span><input type=\"datetime-local\" name=\"starts_at\" value=\"" + _htmlEscape(_datetimeLocalValue(a.starts_at)) + "\"></label>" +
9639
+ "<label class=\"form-field\"><span>Expires at (optional)</span><input type=\"datetime-local\" name=\"expires_at\" value=\"" + _htmlEscape(_datetimeLocalValue(a.expires_at)) + "\"></label>" +
9640
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"dismissible\" value=\"on\"" + (a.dismissible ? " checked" : "") + "> Visitors can dismiss this</label>" +
9641
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
9642
+ "</form>" +
9643
+ "</div>";
9644
+
9645
+ var bodyHtml = "<section><h2>Announcement</h2>" + updated + notice +
9646
+ "<div class=\"panel\"><dl class=\"detail-grid\">" +
9647
+ "<div><dt>Slug</dt><dd><code class=\"order-id\">" + _htmlEscape(a.slug) + "</code></dd></div>" +
9648
+ "<div><dt>Status</dt><dd><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></dd></div>" +
9649
+ "</dl></div>" +
9650
+ editForm +
9651
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/announcements\">Back to announcements</a></div>" +
9652
+ "</section>";
9653
+ return _renderAdminShell(opts.shop_name, "Announcement", bodyHtml, "announcements", opts.nav_available);
9654
+ }
9655
+
9252
9656
  function renderAdminAnnouncements(opts) {
9253
9657
  opts = opts || {};
9254
9658
  var rows = opts.announcements || [];
@@ -9272,6 +9676,7 @@ function renderAdminAnnouncements(opts) {
9272
9676
  "<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
9273
9677
  "<td><div class=\"actions-row\">" +
9274
9678
  (isArchived ? "" :
9679
+ "<a class=\"btn btn--ghost\" href=\"/admin/announcements/" + _htmlEscape(encodeURIComponent(a.slug)) + "\">Edit</a> " +
9275
9680
  "<form method=\"post\" action=\"/admin/announcements/" + _htmlEscape(encodeURIComponent(a.slug)) + "/archive\" class=\"form-inline\">" +
9276
9681
  "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
9277
9682
  "</div></td>" +
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.18",
2
+ "version": "0.3.19",
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.18",
3
+ "version": "0.3.19",
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": {