@blamejs/blamejs-shop 0.3.30 → 0.3.31

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.31 (2026-05-31) — **A public help center, authored from the admin console.** The knowledge-base backend was built but had no pages, so there was no way to publish help content or for a shopper to read it. There is now a Help center at /help: a browsable index of published articles grouped by category, and an article page that renders the article, shows a breadcrumb trail in search results, and lets a reader mark whether it was helpful. Operators get a Help center screen in the admin console to write articles, save them as drafts, publish them, and archive them. Article text is written in a small Markdown subset and is fully escaped on the page, and only published, non-archived articles are publicly reachable. **Added:** *Help center for shoppers* — A new /help section lists published articles by category and renders each article at /help/:slug with a breadcrumb (carried into search results as BreadcrumbList structured data) and a "Was this helpful?" control. Article bodies are written in a Markdown subset and escaped on output, and a draft, archived, or unknown article returns a clean 404 — only published articles are ever publicly reachable. · *Help center authoring in the admin console* — A Help center screen lets operators write articles (slug, title, category, body), keep them as drafts, publish or unpublish them, and archive ones that should no longer appear. The list filters by all/published/drafts and shows each article's view count and helpfulness tally, so the team can see what readers are using and which articles need work.
12
+
11
13
  - v0.3.30 (2026-05-31) — **An unrecognized automatic-discount type is now rejected instead of silently becoming free shipping.** When defining or editing an automatic discount over the API, an unrecognized discount type was silently turned into a free-shipping rule — the most generous kind — and accepted without any error. A discount sent over the API with a type outside the supported set (percent off, amount off the total, amount off each item, buy-X-get-Y, or free shipping) is now rejected with a clear error, so a typo can no longer create a store-wide free-shipping rule by accident. The admin form was never affected, because its type selector only offers the supported values. **Fixed:** *Automatic-discount type is validated* — Defining or editing an automatic discount with a discount type outside the supported set now returns a clear error instead of quietly defaulting to free shipping. Previously an unrecognized type sent over the JSON API was coerced into a free-shipping rule and saved with no warning, which could apply free shipping store-wide from a single typo. Free shipping remains a valid, explicitly chosen type; only unrecognized values are rejected. The admin console form was unaffected, since its selector is limited to the supported types.
12
14
 
13
15
  - v0.3.28 (2026-05-31) — **Customers can now see their store-credit balance and history.** Operators could grant or deduct a customer's store credit, but the customer had no way to see it — the balance only ever surfaced as a silent discount at checkout. A new Store credit page in the account area shows the current balance, a callout for any credit about to expire, and the full ledger of credits, deductions, and expirations with amounts, reasons, and dates. The page is read-only and shows only the signed-in customer's own balance. **Added:** *Store-credit wallet in the account area* — A Store credit page lists a customer's current balance, any credit expiring soon, and the full history of credits, deductions, and expirations — each with its amount, the reason, and the date. It is reachable from the account dashboard and reflects the store credit an operator grants from the customer screen, so a shopper can finally see credit they have been given instead of only discovering it at checkout. The page is read-only and scoped to the signed-in customer.
package/lib/admin.js CHANGED
@@ -516,7 +516,7 @@ function mount(router, deps) {
516
516
  // `reports` is always present in the nav (read-only sales summary needs no
517
517
  // extra dep); its route mounts unconditionally and renders an unconfigured
518
518
  // notice when the salesReports primitive isn't wired.
519
- 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, blog: !!deps.blog, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets };
519
+ 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, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets };
520
520
 
521
521
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
522
522
 
@@ -4846,6 +4846,247 @@ function mount(router, deps) {
4846
4846
  _blogTransition("archive", function (s) { return blog.archive(s); });
4847
4847
  }
4848
4848
 
4849
+ // ---- help center (knowledge base) -----------------------------------
4850
+ // Author the operator's self-serve help articles. The storefront serves
4851
+ // the customer-facing /help index + /help/:slug reader, reading ONLY
4852
+ // published rows; this console writes them. An article is created as a
4853
+ // DRAFT (published defaults off on defineArticle) and stays invisible to
4854
+ // the storefront until it's published — publish / unpublish / archive move
4855
+ // it through the lifecycle. Archive is a soft-delete tombstone: an archived
4856
+ // article drops out of the editable list (the knowledgeBase primitive does
4857
+ // not enumerate archived rows); re-publishing a known slug revives it.
4858
+ // Content-negotiated like the other screens: bearer → the JSON contract;
4859
+ // signed-in browser → the HTML list + author forms.
4860
+ if (deps.knowledgeBase) {
4861
+ var kb = deps.knowledgeBase;
4862
+
4863
+ // The editable list is drafts + published (the knowledgeBase primitive's
4864
+ // listArticles excludes archived tombstones). The status filter narrows
4865
+ // to one lifecycle state.
4866
+ async function _kbRows(filter) {
4867
+ var listing = await kb.listArticles({ published_only: false, limit: kb.MAX_LIST_LIMIT });
4868
+ var rows = listing.rows || [];
4869
+ if (filter === "published") return rows.filter(function (a) { return a.published === true; });
4870
+ if (filter === "draft") return rows.filter(function (a) { return a.published !== true; });
4871
+ return rows;
4872
+ }
4873
+
4874
+ router.get("/admin/help", _pageOrApi(true,
4875
+ R(async function (req, res) {
4876
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4877
+ var status = (url && url.searchParams.get("status")) || null;
4878
+ _json(res, 200, { rows: await _kbRows(status) });
4879
+ }),
4880
+ async function (req, res) {
4881
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4882
+ var status = (url && url.searchParams.get("status")) || null;
4883
+ var rows = await _kbRows(status);
4884
+ _sendHtml(res, 200, renderAdminHelp({
4885
+ shop_name: deps.shop_name, nav_available: navAvailable, articles: rows,
4886
+ status_filter: status,
4887
+ created: url && url.searchParams.get("created"),
4888
+ updated: url && url.searchParams.get("updated"),
4889
+ published: url && url.searchParams.get("published"),
4890
+ archived: url && url.searchParams.get("archived"),
4891
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the article." : null,
4892
+ }));
4893
+ },
4894
+ ));
4895
+
4896
+ // The new-article form. A standalone GET so the "New article" button on
4897
+ // the list has a target; the create POST lives at /admin/help.
4898
+ router.get("/admin/help/new", _pageOrApi(true,
4899
+ R(async function (_req, res) {
4900
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/help with a JSON body to create an article");
4901
+ }),
4902
+ async function (_req, res) {
4903
+ _sendHtml(res, 200, renderAdminHelpDetail({
4904
+ shop_name: deps.shop_name, nav_available: navAvailable, article: null,
4905
+ }));
4906
+ },
4907
+ ));
4908
+
4909
+ // Create content-negotiates: bearer → JSON 201 (the article row); browser
4910
+ // form → defineArticle, then PRG to the detail screen. Every article is
4911
+ // born a DRAFT (defineArticle defaults published off) — never published on
4912
+ // create — so it can't reach the storefront before the operator publishes.
4913
+ router.post("/admin/help", _pageOrApi(false,
4914
+ W("help.create", async function (req, res) {
4915
+ var row = await kb.defineArticle(_kbFromForm(req.body || {}));
4916
+ _json(res, 201, row);
4917
+ return { id: row.slug };
4918
+ }),
4919
+ async function (req, res) {
4920
+ var made;
4921
+ try {
4922
+ made = await kb.defineArticle(_kbFromForm(req.body || {}));
4923
+ } catch (e) {
4924
+ var n = _safeNotice(e, "help.create");
4925
+ return _sendHtml(res, n.status, renderAdminHelpDetail({
4926
+ shop_name: deps.shop_name, nav_available: navAvailable, article: null,
4927
+ form_values: req.body || {},
4928
+ notice: n.message.replace(/^knowledgeBase[.:]\s*/, ""),
4929
+ }));
4930
+ }
4931
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".help.create", outcome: "success", metadata: { slug: made.slug } });
4932
+ _redirect(res, "/admin/help/" + encodeURIComponent(made.slug) + "?created=1");
4933
+ },
4934
+ ));
4935
+
4936
+ // Detail content-negotiates: bearer → JSON (the row); browser → the edit
4937
+ // form + the lifecycle action buttons + the view/vote read. A bad /
4938
+ // unknown slug is a 404 page, never a 500. (getArticle returns null for
4939
+ // an archived tombstone too — an archived slug reads as "not here".)
4940
+ router.get("/admin/help/:slug", _pageOrApi(true,
4941
+ R(async function (req, res) {
4942
+ var row;
4943
+ try { row = await kb.getArticle({ slug: req.params.slug }); }
4944
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "help-article-not-found", e.message); throw e; }
4945
+ if (!row) return _problem(res, 404, "help-article-not-found");
4946
+ _json(res, 200, row);
4947
+ }),
4948
+ async function (req, res) {
4949
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4950
+ var row;
4951
+ try { row = await kb.getArticle({ slug: req.params.slug }); }
4952
+ catch (e) { if (!(e instanceof TypeError)) throw e; row = null; }
4953
+ if (!row) return _sendHtml(res, 404, renderAdminHelp({
4954
+ shop_name: deps.shop_name, nav_available: navAvailable, articles: [], notice: "Article not found.",
4955
+ }));
4956
+ // The helpfulness aggregate is a cheap, useful read to surface.
4957
+ var aggregate = null;
4958
+ try { aggregate = await kb.voteAggregateForArticle(row.slug); }
4959
+ catch (_e) { aggregate = null; }
4960
+ _sendHtml(res, 200, renderAdminHelpDetail({
4961
+ shop_name: deps.shop_name, nav_available: navAvailable, article: row, aggregate: aggregate,
4962
+ updated: url && url.searchParams.get("updated"),
4963
+ published: url && url.searchParams.get("published"),
4964
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the article." : null,
4965
+ }));
4966
+ },
4967
+ ));
4968
+
4969
+ // Edit content-negotiates: bearer PATCH (the JSON contract) + browser
4970
+ // POST /edit (HTML forms can't PATCH). Both patch the editable columns
4971
+ // (title / body / category / tags). Status is NOT editable here — it
4972
+ // moves via the lifecycle routes below.
4973
+ router.patch("/admin/help/:slug", W("help.update", async function (req, res) {
4974
+ var row;
4975
+ try { row = await kb.updateArticle(req.params.slug, req.body || {}); }
4976
+ catch (e) {
4977
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4978
+ if (e && e.code === "KB_ARTICLE_NOT_FOUND") return _problem(res, 404, "help-article-not-found");
4979
+ if (e && e.code === "KB_ARTICLE_ARCHIVED") return _problem(res, 409, "conflict", e.message);
4980
+ throw e;
4981
+ }
4982
+ _json(res, 200, row);
4983
+ return { id: row.slug };
4984
+ }));
4985
+
4986
+ router.post("/admin/help/:slug/edit", _pageOrApi(false,
4987
+ W("help.update", async function (req, res) {
4988
+ var row;
4989
+ try { row = await kb.updateArticle(req.params.slug, req.body || {}); }
4990
+ catch (e) {
4991
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4992
+ if (e && e.code === "KB_ARTICLE_NOT_FOUND") return _problem(res, 404, "help-article-not-found");
4993
+ if (e && e.code === "KB_ARTICLE_ARCHIVED") return _problem(res, 409, "conflict", e.message);
4994
+ throw e;
4995
+ }
4996
+ _json(res, 200, row);
4997
+ return { id: row.slug };
4998
+ }),
4999
+ async function (req, res) {
5000
+ var slug = req.params.slug;
5001
+ var enc = encodeURIComponent(slug);
5002
+ try {
5003
+ await kb.updateArticle(slug, _kbPatchFromForm(req.body || {}));
5004
+ } catch (e) {
5005
+ if (e && (e.code === "KB_ARTICLE_NOT_FOUND")) return _redirect(res, "/admin/help?err=1");
5006
+ var n = _safeNotice(e, "help.update");
5007
+ var current = null;
5008
+ try { current = await kb.getArticle({ slug: slug }); } catch (_e) { current = null; }
5009
+ if (!current) return _redirect(res, "/admin/help?err=1");
5010
+ return _sendHtml(res, n.status, renderAdminHelpDetail({
5011
+ shop_name: deps.shop_name, nav_available: navAvailable, article: current,
5012
+ notice: n.message.replace(/^knowledgeBase[.:]\s*/, ""),
5013
+ }));
5014
+ }
5015
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".help.update", outcome: "success", metadata: { slug: slug } });
5016
+ _redirect(res, "/admin/help/" + enc + "?updated=1");
5017
+ },
5018
+ ));
5019
+
5020
+ // Lifecycle transitions. publish takes a draft live (now visible on the
5021
+ // storefront /help); unpublish pulls it back to draft (gone from the
5022
+ // storefront). publish also REVIVES an archived slug (the primitive
5023
+ // clears the archive tombstone on publish). A missing slug is a ?err=1
5024
+ // notice, never a 500.
5025
+ function _kbTransition(action, fn, landsOnList) {
5026
+ router.post("/admin/help/:slug/" + action, _pageOrApi(false,
5027
+ W("help." + action, async function (req, res) {
5028
+ var row;
5029
+ try { row = await fn(req.params.slug); }
5030
+ catch (e) {
5031
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
5032
+ if (e && e.code === "KB_ARTICLE_NOT_FOUND") return _problem(res, 404, "help-article-not-found");
5033
+ throw e;
5034
+ }
5035
+ _json(res, 200, row);
5036
+ return { id: row.slug };
5037
+ }),
5038
+ async function (req, res) {
5039
+ var slug = req.params.slug;
5040
+ var enc = encodeURIComponent(slug);
5041
+ try {
5042
+ await fn(slug);
5043
+ } catch (e) {
5044
+ if (e instanceof TypeError || (e && e.code === "KB_ARTICLE_NOT_FOUND")) {
5045
+ return _redirect(res, "/admin/help/" + enc + "?err=1");
5046
+ }
5047
+ throw e;
5048
+ }
5049
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".help." + action, outcome: "success", metadata: { slug: slug } });
5050
+ // archive lands back on the list (the article left the editable set
5051
+ // — it's a tombstone now); the others stay on the detail with a
5052
+ // status banner.
5053
+ if (landsOnList) return _redirect(res, "/admin/help?archived=1");
5054
+ _redirect(res, "/admin/help/" + enc + (action === "publish" ? "?published=1" : "?updated=1"));
5055
+ },
5056
+ ));
5057
+ }
5058
+ _kbTransition("publish", function (s) { return kb.publishArticle(s); }, false);
5059
+ _kbTransition("unpublish", function (s) { return kb.unpublishArticle(s); }, false);
5060
+
5061
+ // Archive is destructive-ish (the article leaves the live storefront and
5062
+ // the editable list — the primitive tombstones it), so the browser path
5063
+ // confirms first (the CSP forbids a client confirm() dialog). Reached by
5064
+ // a GET link from the detail screen; bearer clients POST /archive directly.
5065
+ router.get("/admin/help/:slug/archive/confirm-page", _pageOrApi(true,
5066
+ R(async function (_req, res) {
5067
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/help/:slug/archive directly for the JSON API");
5068
+ }),
5069
+ async function (req, res) {
5070
+ var slug = req.params.slug;
5071
+ var enc = encodeURIComponent(slug);
5072
+ var row = null;
5073
+ try { row = await kb.getArticle({ slug: slug }); }
5074
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
5075
+ if (!row) return _redirect(res, "/admin/help?err=1");
5076
+ _sendHtml(res, 200, renderAdminConfirm({
5077
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "help",
5078
+ heading: "Archive this article?",
5079
+ consequence: "Archiving removes the article from the live help center and the editable list. Re-publishing the same slug later revives it.",
5080
+ detail: "Article: " + (row.title || slug) + ".",
5081
+ action: "/admin/help/" + _htmlEscape(enc) + "/archive",
5082
+ confirm_label: "Archive article",
5083
+ cancel_href: "/admin/help/" + enc,
5084
+ }));
5085
+ },
5086
+ ));
5087
+ _kbTransition("archive", function (s) { return kb.archiveArticle(s); }, true);
5088
+ }
5089
+
4849
5090
  // ---- storefront pages -----------------------------------------------
4850
5091
  // Author the operator's CMS content pages (About, Shipping, Returns,
4851
5092
  // Privacy, Terms, and the long tail every shop needs). The edge Worker
@@ -8123,6 +8364,7 @@ var ADMIN_NAV_ITEMS = [
8123
8364
  { key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
8124
8365
  { key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
8125
8366
  { key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
8367
+ { key: "help", href: "/admin/help", label: "Help center", requires: "knowledgeBase" },
8126
8368
  { key: "pages", href: "/admin/pages", label: "Pages", requires: "storefrontPages" },
8127
8369
  { key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
8128
8370
  { key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
@@ -11417,6 +11659,197 @@ function _blogStatusPill(status) {
11417
11659
  return "<span class=\"status-pill " + cls + "\">" + _htmlEscape(status) + "</span>";
11418
11660
  }
11419
11661
 
11662
+ // ---- help center (knowledge base) authoring ----------------------------
11663
+
11664
+ // Coerce the create form into knowledgeBase.defineArticle's shape: trimmed
11665
+ // slug + category, the required title/body, the optional tags + locale (a
11666
+ // blank optional field is omitted so the primitive's default applies). The
11667
+ // primitive validates every field — this only shapes the form strings.
11668
+ function _kbFromForm(body) {
11669
+ body = body || {};
11670
+ var out = {
11671
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
11672
+ title: body.title,
11673
+ body: body.body,
11674
+ category: typeof body.category === "string" ? body.category.trim() : body.category,
11675
+ locale: (typeof body.locale === "string" && body.locale.trim()) ? body.locale.trim() : "en",
11676
+ };
11677
+ var tags = _csvTags(body.tags);
11678
+ if (tags.length) out.tags = tags;
11679
+ return out;
11680
+ }
11681
+
11682
+ // Coerce the edit form into a knowledgeBase.updateArticle patch. Only the
11683
+ // columns the form carries are included; tags always ride (an empty list
11684
+ // clears them). Status / locale are NOT here — status moves via the
11685
+ // lifecycle routes, and the edit form patches the default-locale row.
11686
+ function _kbPatchFromForm(body) {
11687
+ body = body || {};
11688
+ var patch = { tags: _csvTags(body.tags) };
11689
+ if (typeof body.title === "string") patch.title = body.title;
11690
+ if (typeof body.body === "string") patch.body = body.body;
11691
+ if (typeof body.category === "string" && body.category.trim()) patch.category = body.category.trim();
11692
+ return patch;
11693
+ }
11694
+
11695
+ // One status pill per lifecycle state. The knowledgeBase row exposes a
11696
+ // boolean `published` (not the blog's string FSM); an archived row never
11697
+ // reaches this renderer (the primitive tombstones it out of every read).
11698
+ function _kbStatusPill(article) {
11699
+ var label = article && article.published === true ? "published" : "draft";
11700
+ var cls = label === "published" ? "paid" : "pending";
11701
+ return "<span class=\"status-pill " + cls + "\">" + _htmlEscape(label) + "</span>";
11702
+ }
11703
+
11704
+ function renderAdminHelp(opts) {
11705
+ opts = opts || {};
11706
+ var rows = opts.articles || [];
11707
+ var created = opts.created ? "<div class=\"banner banner--ok\">Article created as a draft.</div>" : "";
11708
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Article saved.</div>" : "";
11709
+ var published = opts.published ? "<div class=\"banner banner--ok\">Article published — it's live on the help center.</div>" : "";
11710
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Article archived.</div>" : "";
11711
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
11712
+
11713
+ var sf = opts.status_filter;
11714
+ var chips = "<div class=\"order-filters\">" +
11715
+ "<a class=\"chip" + (sf == null ? " chip--on" : "") + "\" href=\"/admin/help\">All</a>" +
11716
+ "<a class=\"chip" + (sf === "published" ? " chip--on" : "") + "\" href=\"/admin/help?status=published\">Published</a>" +
11717
+ "<a class=\"chip" + (sf === "draft" ? " chip--on" : "") + "\" href=\"/admin/help?status=draft\">Drafts</a>" +
11718
+ "</div>";
11719
+
11720
+ var bodyRows = rows.map(function (a) {
11721
+ var enc = encodeURIComponent(a.slug);
11722
+ var views = a.view_count != null ? String(a.view_count) : "0";
11723
+ // Per-row lifecycle actions match the article's current state: a draft
11724
+ // can publish; a published article can unpublish or archive. Edit is
11725
+ // always offered.
11726
+ var actions = "<a class=\"btn btn--ghost\" href=\"/admin/help/" + _htmlEscape(enc) + "\">Edit</a>";
11727
+ if (a.published === true) {
11728
+ actions += "<form method=\"post\" action=\"/admin/help/" + _htmlEscape(enc) + "/unpublish\" class=\"form-inline\">" +
11729
+ "<button class=\"btn btn--ghost\" type=\"submit\">Unpublish</button></form>" +
11730
+ "<a class=\"btn btn--danger\" href=\"/admin/help/" + _htmlEscape(enc) + "/archive/confirm-page\">Archive</a>";
11731
+ } else {
11732
+ actions += "<form method=\"post\" action=\"/admin/help/" + _htmlEscape(enc) + "/publish\" class=\"form-inline\">" +
11733
+ "<button class=\"btn\" type=\"submit\">Publish</button></form>";
11734
+ }
11735
+ return "<tr>" +
11736
+ "<td><a href=\"/admin/help/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(a.title) + "</strong></a></td>" +
11737
+ "<td><code class=\"order-id\">" + _htmlEscape(a.slug) + "</code></td>" +
11738
+ "<td>" + _htmlEscape(a.category || "") + "</td>" +
11739
+ "<td>" + _kbStatusPill(a) + "</td>" +
11740
+ "<td>" + _htmlEscape(views) + "</td>" +
11741
+ "<td><div class=\"actions-row\">" + actions + "</div></td>" +
11742
+ "</tr>";
11743
+ }).join("");
11744
+
11745
+ var table = rows.length
11746
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Slug</th><th scope=\"col\">Category</th><th scope=\"col\">Status</th><th scope=\"col\">Views</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
11747
+ : "<p class=\"empty\">No articles" + (sf ? " " + _htmlEscape(sf) : " yet") + ". Write your first one.</p>";
11748
+
11749
+ var newBtn = "<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/help/new\">New article</a></div>";
11750
+
11751
+ var bodyHtml = "<section><h2>Help center</h2>" +
11752
+ created + updated + published + archived + notice +
11753
+ "<p class=\"meta\">Self-serve help articles shown on the storefront help center (/help). An article is created as a draft and stays hidden until you publish it. Archiving removes it from the list; re-publishing the same slug revives it.</p>" +
11754
+ newBtn + chips + table + "</section>";
11755
+ return _renderAdminShell(opts.shop_name, "Help center", bodyHtml, "help", opts.nav_available);
11756
+ }
11757
+
11758
+ // New-article form (article: null) or edit form (article set) for a single
11759
+ // help article, plus the lifecycle action row + the helpfulness aggregate
11760
+ // when editing. `form_values` re-fills a failed create so the operator
11761
+ // doesn't retype.
11762
+ function renderAdminHelpDetail(opts) {
11763
+ opts = opts || {};
11764
+ var a = opts.article || null;
11765
+ var isNew = !a;
11766
+ var fv = opts.form_values || {};
11767
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Article saved.</div>" : "";
11768
+ var published = opts.published ? "<div class=\"banner banner--ok\">Article published — it's live on the help center.</div>" : "";
11769
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
11770
+
11771
+ function _val(col) {
11772
+ if (a && a[col] != null) return a[col];
11773
+ if (fv[col] != null) return fv[col];
11774
+ return "";
11775
+ }
11776
+ var tagsVal = a && Array.isArray(a.tags) ? a.tags.join(", ")
11777
+ : (typeof fv.tags === "string" ? fv.tags : "");
11778
+
11779
+ var action = isNew ? "/admin/help" : "/admin/help/" + encodeURIComponent(a.slug) + "/edit";
11780
+ // Slug is the PK — editable only on create. On edit it shows read-only so
11781
+ // the operator sees the storefront URL.
11782
+ var slugField = isNew
11783
+ ? _setupField("Slug", "slug", _val("slug"), "text", "Lowercase letters, digits, and hyphens — appears in /help/<slug>.", " maxlength=\"120\" required")
11784
+ : "<label class=\"form-field\"><span>Slug</span><input type=\"text\" value=\"" + _htmlEscape(a.slug) + "\" readonly>" +
11785
+ "<small>The storefront URL: <code>/help/" + _htmlEscape(a.slug) + "</code></small></label>";
11786
+ // Category is editable on both create + edit (the primitive treats it as
11787
+ // slug-wide). On create it's required; on edit the patch rewrites it.
11788
+ var categoryField = _setupField(
11789
+ "Category", "category", _val("category"), "text",
11790
+ "Groups the article on the help index — lowercase, e.g. orders, shipping, returns.",
11791
+ " maxlength=\"80\"" + (isNew ? " required" : "")
11792
+ );
11793
+
11794
+ var form =
11795
+ "<div class=\"panel mw-40\">" +
11796
+ "<h3 class=\"subhead\">" + (isNew ? "New article" : "Article details") + "</h3>" +
11797
+ "<form method=\"post\" action=\"" + _htmlEscape(action) + "\">" +
11798
+ slugField +
11799
+ _setupField("Title", "title", _val("title"), "text", "", " maxlength=\"200\" required") +
11800
+ categoryField +
11801
+ "<label class=\"form-field\"><span>Body (Markdown)</span>" +
11802
+ "<textarea name=\"body\" rows=\"16\" maxlength=\"32000\" required>" + _htmlEscape(_val("body")) + "</textarea>" +
11803
+ "<small>Headings, lists, links, bold/italic. Raw HTML is escaped — links are https-only.</small></label>" +
11804
+ _setupField("Tags (optional)", "tags", tagsVal, "text", "Comma-separated, e.g. refunds, delivery.", " maxlength=\"1000\"") +
11805
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">" + (isNew ? "Create draft" : "Save") + "</button>" +
11806
+ "<a class=\"btn btn--ghost\" href=\"/admin/help\">Back</a></div>" +
11807
+ "</form>" +
11808
+ "</div>";
11809
+
11810
+ var lifecycle = "";
11811
+ if (!isNew) {
11812
+ var enc = encodeURIComponent(a.slug);
11813
+ var btns = "";
11814
+ if (a.published === true) {
11815
+ btns = "<form method=\"post\" action=\"/admin/help/" + _htmlEscape(enc) + "/unpublish\" class=\"form-inline\">" +
11816
+ "<button class=\"btn btn--ghost\" type=\"submit\">Unpublish</button></form>" +
11817
+ "<a class=\"btn btn--danger\" href=\"/admin/help/" + _htmlEscape(enc) + "/archive/confirm-page\">Archive</a>" +
11818
+ "<span class=\"meta\">Unpublish pulls it back to a draft; archive removes it from the help center.</span>";
11819
+ } else {
11820
+ btns = "<form method=\"post\" action=\"/admin/help/" + _htmlEscape(enc) + "/publish\" class=\"form-inline\">" +
11821
+ "<button class=\"btn\" type=\"submit\">Publish</button></form>" +
11822
+ "<span class=\"meta\">Publishing makes the article live on the storefront help center.</span>";
11823
+ }
11824
+ // The helpfulness aggregate + view count — a cheap, useful read.
11825
+ var agg = opts.aggregate;
11826
+ var stats = "";
11827
+ if (agg) {
11828
+ var ratio = agg.helpfulness_ratio == null ? "—" : Math.round(agg.helpfulness_ratio * 100) + "%";
11829
+ stats = "<p class=\"meta\">Views: " + _htmlEscape(String(a.view_count == null ? 0 : a.view_count)) +
11830
+ " · Helpful: " + _htmlEscape(String(agg.helpful_count)) +
11831
+ " / Not helpful: " + _htmlEscape(String(agg.not_helpful_count)) +
11832
+ " (" + _htmlEscape(ratio) + " helpful)</p>";
11833
+ }
11834
+ lifecycle = "<div class=\"panel mt-1 mw-40\"><h3 class=\"subhead\">Status</h3>" +
11835
+ "<p class=\"meta\">Current state: " + _kbStatusPill(a) + "</p>" + stats +
11836
+ "<div class=\"actions-row\">" + btns + "</div></div>";
11837
+ }
11838
+
11839
+ var head = isNew
11840
+ ? "<p class=\"meta\"><a href=\"/admin/help\">&larr; Help center</a></p>"
11841
+ : "<p class=\"meta\"><a href=\"/admin/help\">&larr; Help center</a> · " +
11842
+ _kbStatusPill(a) +
11843
+ (a.published === true
11844
+ ? " · <a href=\"/help/" + _htmlEscape(encodeURIComponent(a.slug)) + "\" target=\"_blank\" rel=\"noreferrer\">View on storefront &rarr;</a>"
11845
+ : "") +
11846
+ "</p>";
11847
+
11848
+ var title = isNew ? "New article" : (a.title || a.slug);
11849
+ var body = "<section><h2>" + _htmlEscape(title) + "</h2>" + updated + published + notice + head + form + "</section>" + lifecycle;
11850
+ return _renderAdminShell(opts.shop_name, isNew ? "New article" : "Article " + a.slug, body, "help", opts.nav_available);
11851
+ }
11852
+
11420
11853
  function renderAdminBlog(opts) {
11421
11854
  opts = opts || {};
11422
11855
  var rows = opts.articles || [];
@@ -12925,6 +13358,8 @@ module.exports = {
12925
13358
  renderAdminCollection: renderAdminCollection,
12926
13359
  renderAdminBlog: renderAdminBlog,
12927
13360
  renderAdminBlogDetail: renderAdminBlogDetail,
13361
+ renderAdminHelp: renderAdminHelp,
13362
+ renderAdminHelpDetail: renderAdminHelpDetail,
12928
13363
  renderAdminPages: renderAdminPages,
12929
13364
  renderAdminPageDetail: renderAdminPageDetail,
12930
13365
  renderAdminGiftCards: renderAdminGiftCards,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.30",
2
+ "version": "0.3.31",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/storefront.js CHANGED
@@ -6953,6 +6953,171 @@ function renderHoursPage(opts) {
6953
6953
  });
6954
6954
  }
6955
6955
 
6956
+ // ---- help center (knowledge base) --------------------------------------
6957
+ //
6958
+ // The public /help reader: an index grouped by category, and a per-article
6959
+ // page with the safely-rendered body, a breadcrumb, BreadcrumbList JSON-LD,
6960
+ // and a "Was this helpful?" control. Container-rendered (not edge): the
6961
+ // article body is the knowledgeBase primitive's own `body_html`, which it
6962
+ // computes through the shared Markdown subset (every text run escaped via
6963
+ // b.template.escapeHtml, every link URL gated by b.safeUrl) — so the page
6964
+ // never emits unescaped operator HTML, and the renderer here only ever
6965
+ // interpolates already-rendered, already-escaped fragments. The vote POST
6966
+ // needs a per-session CSRF token, which the container already issues; the
6967
+ // edge serves no per-session forms.
6968
+
6969
+ // The help index. `opts.groups` is an array of { category, articles: [...] }
6970
+ // (each article: { slug, title }); `opts.popular` is an optional array of
6971
+ // { slug, title }. An empty index renders a friendly notice. The body never
6972
+ // carries operator HTML — titles are escaped at the sink.
6973
+ function renderHelpIndex(opts) {
6974
+ opts = opts || {};
6975
+ var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
6976
+ var groups = opts.groups || [];
6977
+ var shopName = opts.shop_name || "blamejs.shop";
6978
+
6979
+ var body;
6980
+ if (!groups.length) {
6981
+ body =
6982
+ "<section class=\"help-page\"><div class=\"help-page__inner help-page__inner--msg\">" +
6983
+ "<p class=\"eyebrow\">Help center</p>" +
6984
+ "<h1 class=\"help-page__title\">Help center</h1>" +
6985
+ "<p class=\"help-page__lede\">No help articles have been published yet. Check back soon.</p>" +
6986
+ "<a class=\"btn-ghost\" href=\"/\">Back to the shop</a>" +
6987
+ "</div></section>";
6988
+ } else {
6989
+ var popular = opts.popular || [];
6990
+ var popularHtml = popular.length
6991
+ ? "<aside class=\"help-popular\"><h2 class=\"help-popular__title\">Popular articles</h2><ul class=\"help-popular__list\">" +
6992
+ popular.map(function (a) {
6993
+ return "<li><a href=\"/help/" + esc(encodeURIComponent(a.slug)) + "\">" + esc(a.title) + "</a></li>";
6994
+ }).join("") +
6995
+ "</ul></aside>"
6996
+ : "";
6997
+ var sections = groups.map(function (g) {
6998
+ var items = (g.articles || []).map(function (a) {
6999
+ return "<li class=\"help-list__item\"><a href=\"/help/" + esc(encodeURIComponent(a.slug)) + "\">" + esc(a.title) + "</a></li>";
7000
+ }).join("");
7001
+ return "<section class=\"help-category\">" +
7002
+ "<h2 class=\"help-category__title\">" + esc(g.category) + "</h2>" +
7003
+ "<ul class=\"help-list\">" + items + "</ul>" +
7004
+ "</section>";
7005
+ }).join("");
7006
+ body =
7007
+ "<section class=\"help-page\"><div class=\"help-page__inner\">" +
7008
+ "<p class=\"eyebrow\">Help center</p>" +
7009
+ "<h1 class=\"help-page__title\">How can we help?</h1>" +
7010
+ "<p class=\"help-page__lede\">Browse answers to common questions.</p>" +
7011
+ popularHtml +
7012
+ "<div class=\"help-categories\">" + sections + "</div>" +
7013
+ "</div></section>";
7014
+ }
7015
+ return _wrap({
7016
+ title: "Help center",
7017
+ shop_name: shopName,
7018
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
7019
+ theme_css: opts.theme_css,
7020
+ og_description: "Help center for " + shopName + " — answers to common questions.",
7021
+ canonical_url: opts.canonical_url,
7022
+ og_url: opts.og_url,
7023
+ body: body,
7024
+ });
7025
+ }
7026
+
7027
+ // A single help article. `opts.article` is the hydrated knowledgeBase row —
7028
+ // `body_html` is ALREADY the safely-rendered HTML (escaped + link-gated by
7029
+ // the primitive), so it's spliced verbatim into the page (via spliceRaw, so
7030
+ // a `$`-bearing body can't trip String.replace's dollar substitution). The
7031
+ // breadcrumb is Help -> [category] -> title with a matching BreadcrumbList
7032
+ // JSON-LD (mirrors the PDP / collection shape); the "Was this helpful?"
7033
+ // control POSTs to /help/:slug/vote and is CSRF-tokened by `_wrap`.
7034
+ function renderHelpArticle(opts) {
7035
+ opts = opts || {};
7036
+ var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
7037
+ var article = opts.article;
7038
+ if (!article || typeof article !== "object") {
7039
+ throw new TypeError("storefront.renderHelpArticle: opts.article required");
7040
+ }
7041
+ var shopName = opts.shop_name || "blamejs.shop";
7042
+ var slugEnc = esc(encodeURIComponent(article.slug));
7043
+
7044
+ var crumbHtml =
7045
+ "<li><a href=\"/help\">Help</a></li>" +
7046
+ (article.category ? "<li><a href=\"/help?category=" + esc(encodeURIComponent(article.category)) + "\">" + esc(article.category) + "</a></li>" : "") +
7047
+ "<li aria-current=\"page\">" + esc(article.title) + "</li>";
7048
+
7049
+ // The vote control. The form posts to the container path /help/:slug/vote
7050
+ // (never an edge-exempt prefix), so `_injectCsrfFields` stamps it with the
7051
+ // per-request double-submit token. `voted` re-renders the confirmation
7052
+ // after a vote; absent it, the two buttons render.
7053
+ var voteHtml;
7054
+ if (opts.voted) {
7055
+ voteHtml = "<p class=\"help-vote__thanks\">Thanks for your feedback.</p>";
7056
+ } else {
7057
+ voteHtml =
7058
+ "<form class=\"help-vote\" method=\"post\" action=\"/help/" + slugEnc + "/vote\">" +
7059
+ "<p class=\"help-vote__q\">Was this helpful?</p>" +
7060
+ "<div class=\"help-vote__actions\">" +
7061
+ "<button class=\"btn-ghost\" type=\"submit\" name=\"vote\" value=\"helpful\">Yes</button>" +
7062
+ "<button class=\"btn-ghost\" type=\"submit\" name=\"vote\" value=\"not_helpful\">No</button>" +
7063
+ "</div>" +
7064
+ "</form>";
7065
+ }
7066
+
7067
+ var articleHtml =
7068
+ "<article class=\"help-article\">" +
7069
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" + crumbHtml + "</ol></nav>" +
7070
+ "<header class=\"help-article__head\">" +
7071
+ "<p class=\"eyebrow\">Help</p>" +
7072
+ "<h1 class=\"help-article__title\">" + esc(article.title) + "</h1>" +
7073
+ "</header>" +
7074
+ "<div class=\"help-article__body\">RAW_HELP_BODY_PLACEHOLDER</div>" +
7075
+ "<footer class=\"help-article__foot\">" + voteHtml +
7076
+ "<p class=\"help-article__back\"><a href=\"/help\">&larr; Back to help center</a></p>" +
7077
+ "</footer>" +
7078
+ "</article>";
7079
+ // body_html is the primitive's already-escaped, already-link-gated render —
7080
+ // splice it literally so a `$`-bearing body can't trip dollar substitution.
7081
+ articleHtml = _spliceRaw(articleHtml, "RAW_HELP_BODY_PLACEHOLDER", String(article.body_html || ""));
7082
+
7083
+ // BreadcrumbList JSON-LD mirroring the on-page breadcrumb. The `item` URLs
7084
+ // are absolute so the structured data is fully-qualified. Mirrors the PDP /
7085
+ // collection breadcrumb shape.
7086
+ var absoluteBase = _absoluteBase(opts.canonical_url, shopName);
7087
+ var crumbItems = [
7088
+ { "@type": "ListItem", "position": 1, "name": "Help", "item": absoluteBase + "/help" },
7089
+ ];
7090
+ var pos = 2;
7091
+ if (article.category) {
7092
+ crumbItems.push({
7093
+ "@type": "ListItem", "position": pos, "name": article.category,
7094
+ "item": absoluteBase + "/help?category=" + encodeURIComponent(article.category),
7095
+ });
7096
+ pos += 1;
7097
+ }
7098
+ crumbItems.push({
7099
+ "@type": "ListItem", "position": pos, "name": article.title,
7100
+ "item": absoluteBase + "/help/" + encodeURIComponent(article.slug),
7101
+ });
7102
+ var breadcrumbJsonLd = _jsonLdScript({
7103
+ "@context": "https://schema.org",
7104
+ "@type": "BreadcrumbList",
7105
+ "itemListElement": crumbItems,
7106
+ });
7107
+
7108
+ return _wrap({
7109
+ title: article.title,
7110
+ shop_name: shopName,
7111
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
7112
+ theme_css: opts.theme_css,
7113
+ og_description: "Help: " + article.title,
7114
+ og_type: "article",
7115
+ canonical_url: opts.canonical_url,
7116
+ og_url: opts.og_url,
7117
+ body: articleHtml + breadcrumbJsonLd,
7118
+ });
7119
+ }
7120
+
6956
7121
  function mount(router, deps) {
6957
7122
  if (!router || typeof router.get !== "function") throw new TypeError("storefront.mount: router with .get() required");
6958
7123
  if (!deps || !deps.catalog || !deps.cart) throw new TypeError("storefront.mount: deps.catalog + deps.cart required");
@@ -7467,6 +7632,174 @@ function mount(router, deps) {
7467
7632
  });
7468
7633
  }
7469
7634
 
7635
+ // ---- help center (knowledge base) -----------------------------------
7636
+ // The public self-serve help reader. GET /help is the category-grouped
7637
+ // index of PUBLISHED articles; GET /help/:slug is one article (its
7638
+ // body_html is the primitive's escaped + link-gated render); POST
7639
+ // /help/:slug/vote records a "was this helpful?" vote. Container-rendered
7640
+ // (the vote form carries a per-request CSRF token `_wrap` injects).
7641
+ // Resilient: a missing table / read failure renders the empty state or a
7642
+ // clean 404/notice, never a 500.
7643
+ if (deps.knowledgeBase) {
7644
+ var kb = deps.knowledgeBase;
7645
+
7646
+ function _helpThemeCss() {
7647
+ return (theme && theme.assetUrl) ? theme.assetUrl("css/main.css") : DEFAULT_THEME_CSS_URL;
7648
+ }
7649
+
7650
+ // The published-only article gate. getArticle returns a row in ANY
7651
+ // state (the primitive's getArticle is not status-filtered), so the
7652
+ // public route MUST refuse anything that isn't published + non-archived
7653
+ // — a draft or archived article is invisible to the storefront, exactly
7654
+ // like the blog / CMS-page reader. Returns the hydrated row when it's
7655
+ // publicly viewable, else null (the route 404s). A malformed slug throws
7656
+ // a TypeError in the primitive, which the caller maps to a 404.
7657
+ async function _kbPublishedArticle(slug, locale) {
7658
+ var row = await kb.getArticle({ slug: slug, locale: locale });
7659
+ if (!row) return null;
7660
+ if (row.published !== true) return null;
7661
+ if (row.archived_at != null) return null;
7662
+ return row;
7663
+ }
7664
+
7665
+ router.get("/help", async function (req, res) {
7666
+ var urls = _requestUrls(req);
7667
+ var rendered;
7668
+ try {
7669
+ var cartCount = await _cartCountForReq(req);
7670
+ // Category filter (the breadcrumb links carry ?category=…). An
7671
+ // invalid value just falls back to the full index (the primitive
7672
+ // throws on a bad category, which we swallow to a no-filter list).
7673
+ var category = null;
7674
+ var q = req.query || {};
7675
+ if (typeof q.category === "string" && q.category.length) category = q.category;
7676
+ var listOpts = { published_only: true, limit: kb.MAX_LIST_LIMIT };
7677
+ if (category) listOpts.category = category;
7678
+ var listing;
7679
+ try { listing = await kb.listArticles(listOpts); }
7680
+ catch (_e) { listing = await kb.listArticles({ published_only: true, limit: kb.MAX_LIST_LIMIT }); }
7681
+ var rows = listing.rows || [];
7682
+ // Group by category, preserving the listArticles order (updated_at
7683
+ // DESC) within each group; categories appear in first-seen order.
7684
+ var order = [];
7685
+ var byCat = {};
7686
+ for (var i = 0; i < rows.length; i += 1) {
7687
+ var a = rows[i];
7688
+ var c = a.category || "general";
7689
+ if (!byCat[c]) { byCat[c] = []; order.push(c); }
7690
+ byCat[c].push({ slug: a.slug, title: a.title });
7691
+ }
7692
+ var groups = order.map(function (cc) { return { category: cc, articles: byCat[cc] }; });
7693
+ // Popular block — best-effort over a 30-day window; any failure just
7694
+ // drops the rail (it's a cheap nicety, not a contract).
7695
+ var popular = [];
7696
+ try {
7697
+ var titleBySlug = {};
7698
+ for (var pi = 0; pi < rows.length; pi += 1) titleBySlug[rows[pi].slug] = rows[pi].title;
7699
+ var to = Date.now();
7700
+ var from = to - b.constants.TIME.days(30);
7701
+ var pop = await kb.popularArticles({ from: from, to: to, limit: 5 });
7702
+ for (var k = 0; k < pop.length; k += 1) {
7703
+ if (titleBySlug[pop[k].slug]) popular.push({ slug: pop[k].slug, title: titleBySlug[pop[k].slug] });
7704
+ }
7705
+ } catch (_e2) { popular = []; }
7706
+ rendered = renderHelpIndex({
7707
+ groups: groups, popular: popular, shop_name: shopName, cart_count: cartCount,
7708
+ theme_css: _helpThemeCss(), canonical_url: urls.canonical_url, og_url: urls.og_url,
7709
+ });
7710
+ } catch (_e) {
7711
+ // Table absent / read failure → the empty state, never a 500.
7712
+ rendered = renderHelpIndex({
7713
+ groups: [], shop_name: shopName, cart_count: 0, theme_css: _helpThemeCss(),
7714
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7715
+ });
7716
+ }
7717
+ _send(res, 200, rendered);
7718
+ });
7719
+
7720
+ router.get("/help/:slug", async function (req, res) {
7721
+ var slug = (req.params && req.params.slug) || "";
7722
+ var urls = _requestUrls(req);
7723
+ var cartCount = 0;
7724
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { cartCount = 0; }
7725
+ var article = null;
7726
+ try {
7727
+ article = await _kbPublishedArticle(slug);
7728
+ } catch (e) {
7729
+ if (!(e instanceof TypeError)) throw e; // a real error, not a bad slug
7730
+ article = null; // malformed slug → 404
7731
+ }
7732
+ if (!article) {
7733
+ return _send(res, 404, renderNotFound({
7734
+ what: "help article", shop_name: shopName, cart_count: cartCount, theme_css: _helpThemeCss(),
7735
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7736
+ }));
7737
+ }
7738
+ // Record the view (best-effort, drop-silent — analytics, never blocks
7739
+ // the render). A session id keys the per-session view namespace; absent
7740
+ // one the view still counts (session_id is optional on recordView).
7741
+ try {
7742
+ var sid = _readSidCookie(req);
7743
+ await kb.recordView(sid ? { slug: article.slug, session_id: sid } : { slug: article.slug });
7744
+ } catch (_e) { /* drop-silent — view tracking never breaks the page */ }
7745
+ _send(res, 200, renderHelpArticle({
7746
+ article: article, shop_name: shopName, cart_count: cartCount, theme_css: _helpThemeCss(),
7747
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7748
+ }));
7749
+ });
7750
+
7751
+ // Record a helpfulness vote. CSRF-safe via the per-request double-submit
7752
+ // token `_wrap` injects into the form (the action is a container path,
7753
+ // never an EDGE_POST_PATHS prefix, so it IS tokened + checked). The vote
7754
+ // dedups per (slug, session) at the primitive's UNIQUE; a browser with no
7755
+ // session cookie yet gets one minted here so the dedup has a key. A bad
7756
+ // slug / unpublished article / bad vote value all degrade to a clean
7757
+ // response, never a 500.
7758
+ router.post("/help/:slug/vote", async function (req, res) {
7759
+ var slug = (req.params && req.params.slug) || "";
7760
+ var urls = _requestUrls(req);
7761
+ var cartCount = 0;
7762
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { cartCount = 0; }
7763
+ var body = req.body || {};
7764
+ var vote = (typeof body.vote === "string") ? body.vote : "";
7765
+
7766
+ var article = null;
7767
+ try { article = await _kbPublishedArticle(slug); }
7768
+ catch (e) { if (!(e instanceof TypeError)) throw e; article = null; }
7769
+ if (!article) {
7770
+ return _send(res, 404, renderNotFound({
7771
+ what: "help article", shop_name: shopName, cart_count: cartCount, theme_css: _helpThemeCss(),
7772
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7773
+ }));
7774
+ }
7775
+
7776
+ // A session id keys the vote dedup. Mint + set one when the browser
7777
+ // has none yet (a uuid v7 matches the session-id cookie shape), so a
7778
+ // first-time voter still records exactly one distinct-session vote.
7779
+ var sid = _readSidCookie(req);
7780
+ if (!sid) { sid = b.uuid.v7(); _setSidCookie(req, res, sid); }
7781
+
7782
+ // Record the vote. An invalid vote value (neither helpful nor
7783
+ // not_helpful) re-renders the article with the buttons (the primitive
7784
+ // throws a TypeError); a duplicate vote collapses to a no-op at the
7785
+ // UNIQUE. Either way the voter sees the thank-you confirmation — the
7786
+ // page never leaks an error or a stack.
7787
+ try {
7788
+ await kb.recordVote({ slug: article.slug, session_id: sid, vote: vote });
7789
+ } catch (e) {
7790
+ if (!(e instanceof TypeError)) throw e;
7791
+ return _send(res, 200, renderHelpArticle({
7792
+ article: article, shop_name: shopName, cart_count: cartCount, theme_css: _helpThemeCss(),
7793
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7794
+ }));
7795
+ }
7796
+ _send(res, 200, renderHelpArticle({
7797
+ article: article, voted: true, shop_name: shopName, cart_count: cartCount, theme_css: _helpThemeCss(),
7798
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7799
+ }));
7800
+ });
7801
+ }
7802
+
7470
7803
  // Persist a locale choice. A GET form (works with JS off) from the
7471
7804
  // footer switcher submits `lang` (the chosen tag) + `to` (the path to
7472
7805
  // return to). We validate the tag against the active locale set, set
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.30",
3
+ "version": "0.3.31",
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": {