@blamejs/blamejs-shop 0.3.30 → 0.3.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.32 (2026-05-31) — **Collection pages now page through every product instead of stopping at 24.** A collection page showed only its first 24 products and provided no way to reach the rest, so any product past the 24th in a collection was silently unreachable. Collection pages now have previous/next pagination, matching the search results, so a collection of any size can be browsed in full. A shared link to a specific page keeps its position, while the page's canonical URL stays the plain collection address. **Fixed:** *Collection pages paginate* — A collection with more than 24 products was truncated at the first 24 with no way to see the others. Collection pages now carry previous/next pagination — the same control used on search results — so every product in a collection is reachable. The canonical URL remains the bare collection address on every page (so search engines see one collection page, not duplicates), while a shared link to a deeper page preserves its position.
12
+
13
+ - 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.
14
+
11
15
  - 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
16
 
13
17
  - 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.32",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -753,21 +753,29 @@ function create(opts) {
753
753
  throw new TypeError("collections.productsIn: cursor — " + (e && e.message || "malformed"));
754
754
  }
755
755
  }
756
+ // Fetch one row beyond the page so the next cursor is emitted ONLY
757
+ // when a member past this page actually exists. Keying the cursor off
758
+ // a full page alone (rows.length === limit) advertises a phantom next
759
+ // page when the collection size is an exact multiple of the limit —
760
+ // following it lands on an empty page. The smart path peeks the same
761
+ // way (startIdx + slice.length < matched.length).
756
762
  var sql, params;
757
763
  if (cursorVals) {
758
764
  sql = "SELECT * FROM collection_members WHERE collection_slug = ?1 AND " +
759
765
  "(position > ?2 OR (position = ?2 AND id > ?3)) " +
760
766
  "ORDER BY position ASC, id ASC LIMIT ?4";
761
- params = [input.slug, cursorVals[0], cursorVals[1], limit];
767
+ params = [input.slug, cursorVals[0], cursorVals[1], limit + 1];
762
768
  } else {
763
769
  sql = "SELECT * FROM collection_members WHERE collection_slug = ?1 " +
764
770
  "ORDER BY position ASC, id ASC LIMIT ?2";
765
- params = [input.slug, limit];
771
+ params = [input.slug, limit + 1];
766
772
  }
767
773
  var r = await query(sql, params);
768
- var lastM = r.rows[r.rows.length - 1];
774
+ var hasMore = r.rows.length > limit;
775
+ var pageRows = hasMore ? r.rows.slice(0, limit) : r.rows;
776
+ var lastM = pageRows[pageRows.length - 1];
769
777
  var nextM = null;
770
- if (lastM && r.rows.length === limit) {
778
+ if (lastM && hasMore) {
771
779
  nextM = b.pagination.encodeCursor({
772
780
  orderKey: MEMBER_ORDER_KEY,
773
781
  vals: [lastM.position, lastM.id],
@@ -777,7 +785,7 @@ function create(opts) {
777
785
  return {
778
786
  type: "manual",
779
787
  sort_strategy: row.sort_strategy,
780
- rows: r.rows.map(function (mr) {
788
+ rows: pageRows.map(function (mr) {
781
789
  return {
782
790
  id: mr.id,
783
791
  collection_slug: mr.collection_slug,
package/lib/storefront.js CHANGED
@@ -1466,6 +1466,84 @@ function _renderSearchPagination(q, filters, total, page, pageSize) {
1466
1466
  "</nav>\n";
1467
1467
  }
1468
1468
 
1469
+ // The fixed collection page size — the number of product cards one page of
1470
+ // `/collections/:slug` shows. Pages past the first are reached via the
1471
+ // `?cursor=` trail the pagination nav emits. A collection is keyset/offset
1472
+ // paginated by its lib (`collections.productsIn` returns an opaque,
1473
+ // forward-only `next_cursor`); it exposes no total, so the page UI is a
1474
+ // prev/next pair (not the numbered `/search` UI), reusing the same
1475
+ // `search-pagination` shell + `rel="prev"/"next"` so no new CSS ships.
1476
+ var COLLECTION_PAGE_SIZE = 24;
1477
+
1478
+ // Cursor characters are RFC 4648 base64url plus a single `.` tag separator —
1479
+ // no `,`, `+`, `/`, or `=` — so a comma joins a list of page-start cursors
1480
+ // into one URL-safe `?cursor=` value. This is the page trail: page 1 carries
1481
+ // no `cursor`; each Next appends the page's `next_cursor`; each Previous
1482
+ // drops the last entry. The current page starts at the trail's last cursor.
1483
+ var COLLECTION_CURSOR_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
1484
+
1485
+ // Parse the `?cursor=` trail off a parsed URL into an array of opaque
1486
+ // page-start cursors. A missing param is page 1 (empty trail). Each
1487
+ // comma-separated entry must match the base64url`.`base64url cursor shape;
1488
+ // any entry that doesn't is dropped, and a trail longer than a sane bound is
1489
+ // truncated — a defensive request-shape reader that returns a clean trail
1490
+ // (never throws) so a tampered / stale `?cursor=` degrades to a reachable
1491
+ // page rather than a 500. The route additionally retries page 1 if the
1492
+ // lib rejects the resolved start cursor's HMAC.
1493
+ var COLLECTION_MAX_TRAIL = 512;
1494
+ function _parseCollectionCursorTrail(url) {
1495
+ if (!url || !url.searchParams) return [];
1496
+ var raw = url.searchParams.get("cursor");
1497
+ if (raw == null || raw === "") return [];
1498
+ if (raw.length > COLLECTION_MAX_TRAIL * 200) return [];
1499
+ var parts = raw.split(",");
1500
+ var out = [];
1501
+ for (var i = 0; i < parts.length && out.length < COLLECTION_MAX_TRAIL; i += 1) {
1502
+ if (COLLECTION_CURSOR_RE.test(parts[i])) out.push(parts[i]);
1503
+ }
1504
+ return out;
1505
+ }
1506
+
1507
+ // `/collections/:slug` URL for a given cursor trail. An empty trail is the
1508
+ // bare collection page (page 1) so the first page has one canonical URL;
1509
+ // otherwise the trail joins into the `?cursor=` param. Mirrors the search
1510
+ // renderer's `_searchPageUrl` (page 1 omits the param).
1511
+ function _collectionPageUrl(slug, trail) {
1512
+ var base = "/collections/" + encodeURIComponent(slug);
1513
+ if (!trail || !trail.length) return base;
1514
+ return base + "?cursor=" + trail.join(",");
1515
+ }
1516
+
1517
+ // Prev/next pagination for a collection product grid. Reuses the
1518
+ // `search-pagination` shell + `rel="prev"/"next"` + disabled-state spans
1519
+ // so no new CSS ships and the markup matches the search nav. Renders
1520
+ // nothing when there is neither a previous page (empty trail) nor a next
1521
+ // page (`nextCursor == null`) — i.e. a single-page collection stays
1522
+ // byte-identical to the unpaginated render. `trail` is the current page's
1523
+ // cursor trail (the last entry is this page's start); `nextCursor` is the
1524
+ // lib's opaque forward cursor for the following page (null on the last
1525
+ // page).
1526
+ function _renderCollectionPagination(slug, trail, nextCursor) {
1527
+ var esc = b.template.escapeHtml;
1528
+ var hasPrev = trail && trail.length > 0;
1529
+ var hasNext = nextCursor != null && nextCursor !== "";
1530
+ if (!hasPrev && !hasNext) return "";
1531
+ var prevTrail = hasPrev ? trail.slice(0, trail.length - 1) : [];
1532
+ var nextTrail = (trail || []).concat([nextCursor]);
1533
+ var prev = hasPrev
1534
+ ? _render("<a class=\"search-pagination__link search-pagination__prev\" href=\"{{href}}\" rel=\"prev\">Previous</a>\n",
1535
+ { href: esc(_collectionPageUrl(slug, prevTrail)) })
1536
+ : "<span class=\"search-pagination__link search-pagination__prev is-disabled\" aria-disabled=\"true\">Previous</span>\n";
1537
+ var next = hasNext
1538
+ ? _render("<a class=\"search-pagination__link search-pagination__next\" href=\"{{href}}\" rel=\"next\">Next</a>\n",
1539
+ { href: esc(_collectionPageUrl(slug, nextTrail)) })
1540
+ : "<span class=\"search-pagination__link search-pagination__next is-disabled\" aria-disabled=\"true\">Next</span>\n";
1541
+ return "<nav class=\"search-pagination collection-pagination\" aria-label=\"Collection pages\">\n" +
1542
+ prev +
1543
+ next +
1544
+ "</nav>\n";
1545
+ }
1546
+
1469
1547
  // Read the 1-based `?page=N` results page off a parsed URL. A missing,
1470
1548
  // non-integer, or sub-1 value reads as page 1 (the canonical first page);
1471
1549
  // the upper bound is clamped against the real page count by `_clampPage`
@@ -2884,6 +2962,16 @@ function renderCollection(opts) {
2884
2962
  var grid = cards
2885
2963
  ? "<div class=\"catalog-grid collection-grid\">" + cards + "</div>"
2886
2964
  : "<p class=\"collection-empty\">No products in this collection yet.</p>";
2965
+ // Prev/next nav under the grid — the `?cursor=` trail carries the lib's
2966
+ // opaque forward cursor so a collection larger than one page is fully
2967
+ // reachable (the silent 24-cap truncation is the bug this closes). Renders
2968
+ // nothing for a single-page collection (no trail + no next cursor), so the
2969
+ // small-collection render is unchanged.
2970
+ var pagination = _renderCollectionPagination(
2971
+ col.slug,
2972
+ opts.cursor_trail || [],
2973
+ opts.next_cursor == null ? null : opts.next_cursor
2974
+ );
2887
2975
  var body =
2888
2976
  "<section class=\"collection-page\">" +
2889
2977
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -2896,6 +2984,7 @@ function renderCollection(opts) {
2896
2984
  (col.description ? "<p class=\"collection-page__desc\">" + esc(col.description) + "</p>" : "") +
2897
2985
  "</header>" +
2898
2986
  grid +
2987
+ pagination +
2899
2988
  "</section>";
2900
2989
  // BreadcrumbList JSON-LD mirroring the on-page `<nav class="breadcrumb">`
2901
2990
  // trail (Shop → Collections → this collection). Google's result panel
@@ -6953,6 +7042,171 @@ function renderHoursPage(opts) {
6953
7042
  });
6954
7043
  }
6955
7044
 
7045
+ // ---- help center (knowledge base) --------------------------------------
7046
+ //
7047
+ // The public /help reader: an index grouped by category, and a per-article
7048
+ // page with the safely-rendered body, a breadcrumb, BreadcrumbList JSON-LD,
7049
+ // and a "Was this helpful?" control. Container-rendered (not edge): the
7050
+ // article body is the knowledgeBase primitive's own `body_html`, which it
7051
+ // computes through the shared Markdown subset (every text run escaped via
7052
+ // b.template.escapeHtml, every link URL gated by b.safeUrl) — so the page
7053
+ // never emits unescaped operator HTML, and the renderer here only ever
7054
+ // interpolates already-rendered, already-escaped fragments. The vote POST
7055
+ // needs a per-session CSRF token, which the container already issues; the
7056
+ // edge serves no per-session forms.
7057
+
7058
+ // The help index. `opts.groups` is an array of { category, articles: [...] }
7059
+ // (each article: { slug, title }); `opts.popular` is an optional array of
7060
+ // { slug, title }. An empty index renders a friendly notice. The body never
7061
+ // carries operator HTML — titles are escaped at the sink.
7062
+ function renderHelpIndex(opts) {
7063
+ opts = opts || {};
7064
+ var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
7065
+ var groups = opts.groups || [];
7066
+ var shopName = opts.shop_name || "blamejs.shop";
7067
+
7068
+ var body;
7069
+ if (!groups.length) {
7070
+ body =
7071
+ "<section class=\"help-page\"><div class=\"help-page__inner help-page__inner--msg\">" +
7072
+ "<p class=\"eyebrow\">Help center</p>" +
7073
+ "<h1 class=\"help-page__title\">Help center</h1>" +
7074
+ "<p class=\"help-page__lede\">No help articles have been published yet. Check back soon.</p>" +
7075
+ "<a class=\"btn-ghost\" href=\"/\">Back to the shop</a>" +
7076
+ "</div></section>";
7077
+ } else {
7078
+ var popular = opts.popular || [];
7079
+ var popularHtml = popular.length
7080
+ ? "<aside class=\"help-popular\"><h2 class=\"help-popular__title\">Popular articles</h2><ul class=\"help-popular__list\">" +
7081
+ popular.map(function (a) {
7082
+ return "<li><a href=\"/help/" + esc(encodeURIComponent(a.slug)) + "\">" + esc(a.title) + "</a></li>";
7083
+ }).join("") +
7084
+ "</ul></aside>"
7085
+ : "";
7086
+ var sections = groups.map(function (g) {
7087
+ var items = (g.articles || []).map(function (a) {
7088
+ return "<li class=\"help-list__item\"><a href=\"/help/" + esc(encodeURIComponent(a.slug)) + "\">" + esc(a.title) + "</a></li>";
7089
+ }).join("");
7090
+ return "<section class=\"help-category\">" +
7091
+ "<h2 class=\"help-category__title\">" + esc(g.category) + "</h2>" +
7092
+ "<ul class=\"help-list\">" + items + "</ul>" +
7093
+ "</section>";
7094
+ }).join("");
7095
+ body =
7096
+ "<section class=\"help-page\"><div class=\"help-page__inner\">" +
7097
+ "<p class=\"eyebrow\">Help center</p>" +
7098
+ "<h1 class=\"help-page__title\">How can we help?</h1>" +
7099
+ "<p class=\"help-page__lede\">Browse answers to common questions.</p>" +
7100
+ popularHtml +
7101
+ "<div class=\"help-categories\">" + sections + "</div>" +
7102
+ "</div></section>";
7103
+ }
7104
+ return _wrap({
7105
+ title: "Help center",
7106
+ shop_name: shopName,
7107
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
7108
+ theme_css: opts.theme_css,
7109
+ og_description: "Help center for " + shopName + " — answers to common questions.",
7110
+ canonical_url: opts.canonical_url,
7111
+ og_url: opts.og_url,
7112
+ body: body,
7113
+ });
7114
+ }
7115
+
7116
+ // A single help article. `opts.article` is the hydrated knowledgeBase row —
7117
+ // `body_html` is ALREADY the safely-rendered HTML (escaped + link-gated by
7118
+ // the primitive), so it's spliced verbatim into the page (via spliceRaw, so
7119
+ // a `$`-bearing body can't trip String.replace's dollar substitution). The
7120
+ // breadcrumb is Help -> [category] -> title with a matching BreadcrumbList
7121
+ // JSON-LD (mirrors the PDP / collection shape); the "Was this helpful?"
7122
+ // control POSTs to /help/:slug/vote and is CSRF-tokened by `_wrap`.
7123
+ function renderHelpArticle(opts) {
7124
+ opts = opts || {};
7125
+ var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
7126
+ var article = opts.article;
7127
+ if (!article || typeof article !== "object") {
7128
+ throw new TypeError("storefront.renderHelpArticle: opts.article required");
7129
+ }
7130
+ var shopName = opts.shop_name || "blamejs.shop";
7131
+ var slugEnc = esc(encodeURIComponent(article.slug));
7132
+
7133
+ var crumbHtml =
7134
+ "<li><a href=\"/help\">Help</a></li>" +
7135
+ (article.category ? "<li><a href=\"/help?category=" + esc(encodeURIComponent(article.category)) + "\">" + esc(article.category) + "</a></li>" : "") +
7136
+ "<li aria-current=\"page\">" + esc(article.title) + "</li>";
7137
+
7138
+ // The vote control. The form posts to the container path /help/:slug/vote
7139
+ // (never an edge-exempt prefix), so `_injectCsrfFields` stamps it with the
7140
+ // per-request double-submit token. `voted` re-renders the confirmation
7141
+ // after a vote; absent it, the two buttons render.
7142
+ var voteHtml;
7143
+ if (opts.voted) {
7144
+ voteHtml = "<p class=\"help-vote__thanks\">Thanks for your feedback.</p>";
7145
+ } else {
7146
+ voteHtml =
7147
+ "<form class=\"help-vote\" method=\"post\" action=\"/help/" + slugEnc + "/vote\">" +
7148
+ "<p class=\"help-vote__q\">Was this helpful?</p>" +
7149
+ "<div class=\"help-vote__actions\">" +
7150
+ "<button class=\"btn-ghost\" type=\"submit\" name=\"vote\" value=\"helpful\">Yes</button>" +
7151
+ "<button class=\"btn-ghost\" type=\"submit\" name=\"vote\" value=\"not_helpful\">No</button>" +
7152
+ "</div>" +
7153
+ "</form>";
7154
+ }
7155
+
7156
+ var articleHtml =
7157
+ "<article class=\"help-article\">" +
7158
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" + crumbHtml + "</ol></nav>" +
7159
+ "<header class=\"help-article__head\">" +
7160
+ "<p class=\"eyebrow\">Help</p>" +
7161
+ "<h1 class=\"help-article__title\">" + esc(article.title) + "</h1>" +
7162
+ "</header>" +
7163
+ "<div class=\"help-article__body\">RAW_HELP_BODY_PLACEHOLDER</div>" +
7164
+ "<footer class=\"help-article__foot\">" + voteHtml +
7165
+ "<p class=\"help-article__back\"><a href=\"/help\">&larr; Back to help center</a></p>" +
7166
+ "</footer>" +
7167
+ "</article>";
7168
+ // body_html is the primitive's already-escaped, already-link-gated render —
7169
+ // splice it literally so a `$`-bearing body can't trip dollar substitution.
7170
+ articleHtml = _spliceRaw(articleHtml, "RAW_HELP_BODY_PLACEHOLDER", String(article.body_html || ""));
7171
+
7172
+ // BreadcrumbList JSON-LD mirroring the on-page breadcrumb. The `item` URLs
7173
+ // are absolute so the structured data is fully-qualified. Mirrors the PDP /
7174
+ // collection breadcrumb shape.
7175
+ var absoluteBase = _absoluteBase(opts.canonical_url, shopName);
7176
+ var crumbItems = [
7177
+ { "@type": "ListItem", "position": 1, "name": "Help", "item": absoluteBase + "/help" },
7178
+ ];
7179
+ var pos = 2;
7180
+ if (article.category) {
7181
+ crumbItems.push({
7182
+ "@type": "ListItem", "position": pos, "name": article.category,
7183
+ "item": absoluteBase + "/help?category=" + encodeURIComponent(article.category),
7184
+ });
7185
+ pos += 1;
7186
+ }
7187
+ crumbItems.push({
7188
+ "@type": "ListItem", "position": pos, "name": article.title,
7189
+ "item": absoluteBase + "/help/" + encodeURIComponent(article.slug),
7190
+ });
7191
+ var breadcrumbJsonLd = _jsonLdScript({
7192
+ "@context": "https://schema.org",
7193
+ "@type": "BreadcrumbList",
7194
+ "itemListElement": crumbItems,
7195
+ });
7196
+
7197
+ return _wrap({
7198
+ title: article.title,
7199
+ shop_name: shopName,
7200
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
7201
+ theme_css: opts.theme_css,
7202
+ og_description: "Help: " + article.title,
7203
+ og_type: "article",
7204
+ canonical_url: opts.canonical_url,
7205
+ og_url: opts.og_url,
7206
+ body: articleHtml + breadcrumbJsonLd,
7207
+ });
7208
+ }
7209
+
6956
7210
  function mount(router, deps) {
6957
7211
  if (!router || typeof router.get !== "function") throw new TypeError("storefront.mount: router with .get() required");
6958
7212
  if (!deps || !deps.catalog || !deps.cart) throw new TypeError("storefront.mount: deps.catalog + deps.cart required");
@@ -7467,6 +7721,174 @@ function mount(router, deps) {
7467
7721
  });
7468
7722
  }
7469
7723
 
7724
+ // ---- help center (knowledge base) -----------------------------------
7725
+ // The public self-serve help reader. GET /help is the category-grouped
7726
+ // index of PUBLISHED articles; GET /help/:slug is one article (its
7727
+ // body_html is the primitive's escaped + link-gated render); POST
7728
+ // /help/:slug/vote records a "was this helpful?" vote. Container-rendered
7729
+ // (the vote form carries a per-request CSRF token `_wrap` injects).
7730
+ // Resilient: a missing table / read failure renders the empty state or a
7731
+ // clean 404/notice, never a 500.
7732
+ if (deps.knowledgeBase) {
7733
+ var kb = deps.knowledgeBase;
7734
+
7735
+ function _helpThemeCss() {
7736
+ return (theme && theme.assetUrl) ? theme.assetUrl("css/main.css") : DEFAULT_THEME_CSS_URL;
7737
+ }
7738
+
7739
+ // The published-only article gate. getArticle returns a row in ANY
7740
+ // state (the primitive's getArticle is not status-filtered), so the
7741
+ // public route MUST refuse anything that isn't published + non-archived
7742
+ // — a draft or archived article is invisible to the storefront, exactly
7743
+ // like the blog / CMS-page reader. Returns the hydrated row when it's
7744
+ // publicly viewable, else null (the route 404s). A malformed slug throws
7745
+ // a TypeError in the primitive, which the caller maps to a 404.
7746
+ async function _kbPublishedArticle(slug, locale) {
7747
+ var row = await kb.getArticle({ slug: slug, locale: locale });
7748
+ if (!row) return null;
7749
+ if (row.published !== true) return null;
7750
+ if (row.archived_at != null) return null;
7751
+ return row;
7752
+ }
7753
+
7754
+ router.get("/help", async function (req, res) {
7755
+ var urls = _requestUrls(req);
7756
+ var rendered;
7757
+ try {
7758
+ var cartCount = await _cartCountForReq(req);
7759
+ // Category filter (the breadcrumb links carry ?category=…). An
7760
+ // invalid value just falls back to the full index (the primitive
7761
+ // throws on a bad category, which we swallow to a no-filter list).
7762
+ var category = null;
7763
+ var q = req.query || {};
7764
+ if (typeof q.category === "string" && q.category.length) category = q.category;
7765
+ var listOpts = { published_only: true, limit: kb.MAX_LIST_LIMIT };
7766
+ if (category) listOpts.category = category;
7767
+ var listing;
7768
+ try { listing = await kb.listArticles(listOpts); }
7769
+ catch (_e) { listing = await kb.listArticles({ published_only: true, limit: kb.MAX_LIST_LIMIT }); }
7770
+ var rows = listing.rows || [];
7771
+ // Group by category, preserving the listArticles order (updated_at
7772
+ // DESC) within each group; categories appear in first-seen order.
7773
+ var order = [];
7774
+ var byCat = {};
7775
+ for (var i = 0; i < rows.length; i += 1) {
7776
+ var a = rows[i];
7777
+ var c = a.category || "general";
7778
+ if (!byCat[c]) { byCat[c] = []; order.push(c); }
7779
+ byCat[c].push({ slug: a.slug, title: a.title });
7780
+ }
7781
+ var groups = order.map(function (cc) { return { category: cc, articles: byCat[cc] }; });
7782
+ // Popular block — best-effort over a 30-day window; any failure just
7783
+ // drops the rail (it's a cheap nicety, not a contract).
7784
+ var popular = [];
7785
+ try {
7786
+ var titleBySlug = {};
7787
+ for (var pi = 0; pi < rows.length; pi += 1) titleBySlug[rows[pi].slug] = rows[pi].title;
7788
+ var to = Date.now();
7789
+ var from = to - b.constants.TIME.days(30);
7790
+ var pop = await kb.popularArticles({ from: from, to: to, limit: 5 });
7791
+ for (var k = 0; k < pop.length; k += 1) {
7792
+ if (titleBySlug[pop[k].slug]) popular.push({ slug: pop[k].slug, title: titleBySlug[pop[k].slug] });
7793
+ }
7794
+ } catch (_e2) { popular = []; }
7795
+ rendered = renderHelpIndex({
7796
+ groups: groups, popular: popular, shop_name: shopName, cart_count: cartCount,
7797
+ theme_css: _helpThemeCss(), canonical_url: urls.canonical_url, og_url: urls.og_url,
7798
+ });
7799
+ } catch (_e) {
7800
+ // Table absent / read failure → the empty state, never a 500.
7801
+ rendered = renderHelpIndex({
7802
+ groups: [], shop_name: shopName, cart_count: 0, theme_css: _helpThemeCss(),
7803
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7804
+ });
7805
+ }
7806
+ _send(res, 200, rendered);
7807
+ });
7808
+
7809
+ router.get("/help/:slug", async function (req, res) {
7810
+ var slug = (req.params && req.params.slug) || "";
7811
+ var urls = _requestUrls(req);
7812
+ var cartCount = 0;
7813
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { cartCount = 0; }
7814
+ var article = null;
7815
+ try {
7816
+ article = await _kbPublishedArticle(slug);
7817
+ } catch (e) {
7818
+ if (!(e instanceof TypeError)) throw e; // a real error, not a bad slug
7819
+ article = null; // malformed slug → 404
7820
+ }
7821
+ if (!article) {
7822
+ return _send(res, 404, renderNotFound({
7823
+ what: "help article", shop_name: shopName, cart_count: cartCount, theme_css: _helpThemeCss(),
7824
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7825
+ }));
7826
+ }
7827
+ // Record the view (best-effort, drop-silent — analytics, never blocks
7828
+ // the render). A session id keys the per-session view namespace; absent
7829
+ // one the view still counts (session_id is optional on recordView).
7830
+ try {
7831
+ var sid = _readSidCookie(req);
7832
+ await kb.recordView(sid ? { slug: article.slug, session_id: sid } : { slug: article.slug });
7833
+ } catch (_e) { /* drop-silent — view tracking never breaks the page */ }
7834
+ _send(res, 200, renderHelpArticle({
7835
+ article: article, shop_name: shopName, cart_count: cartCount, theme_css: _helpThemeCss(),
7836
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7837
+ }));
7838
+ });
7839
+
7840
+ // Record a helpfulness vote. CSRF-safe via the per-request double-submit
7841
+ // token `_wrap` injects into the form (the action is a container path,
7842
+ // never an EDGE_POST_PATHS prefix, so it IS tokened + checked). The vote
7843
+ // dedups per (slug, session) at the primitive's UNIQUE; a browser with no
7844
+ // session cookie yet gets one minted here so the dedup has a key. A bad
7845
+ // slug / unpublished article / bad vote value all degrade to a clean
7846
+ // response, never a 500.
7847
+ router.post("/help/:slug/vote", async function (req, res) {
7848
+ var slug = (req.params && req.params.slug) || "";
7849
+ var urls = _requestUrls(req);
7850
+ var cartCount = 0;
7851
+ try { cartCount = await _cartCountForReq(req); } catch (_e) { cartCount = 0; }
7852
+ var body = req.body || {};
7853
+ var vote = (typeof body.vote === "string") ? body.vote : "";
7854
+
7855
+ var article = null;
7856
+ try { article = await _kbPublishedArticle(slug); }
7857
+ catch (e) { if (!(e instanceof TypeError)) throw e; article = null; }
7858
+ if (!article) {
7859
+ return _send(res, 404, renderNotFound({
7860
+ what: "help article", shop_name: shopName, cart_count: cartCount, theme_css: _helpThemeCss(),
7861
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7862
+ }));
7863
+ }
7864
+
7865
+ // A session id keys the vote dedup. Mint + set one when the browser
7866
+ // has none yet (a uuid v7 matches the session-id cookie shape), so a
7867
+ // first-time voter still records exactly one distinct-session vote.
7868
+ var sid = _readSidCookie(req);
7869
+ if (!sid) { sid = b.uuid.v7(); _setSidCookie(req, res, sid); }
7870
+
7871
+ // Record the vote. An invalid vote value (neither helpful nor
7872
+ // not_helpful) re-renders the article with the buttons (the primitive
7873
+ // throws a TypeError); a duplicate vote collapses to a no-op at the
7874
+ // UNIQUE. Either way the voter sees the thank-you confirmation — the
7875
+ // page never leaks an error or a stack.
7876
+ try {
7877
+ await kb.recordVote({ slug: article.slug, session_id: sid, vote: vote });
7878
+ } catch (e) {
7879
+ if (!(e instanceof TypeError)) throw e;
7880
+ return _send(res, 200, renderHelpArticle({
7881
+ article: article, shop_name: shopName, cart_count: cartCount, theme_css: _helpThemeCss(),
7882
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7883
+ }));
7884
+ }
7885
+ _send(res, 200, renderHelpArticle({
7886
+ article: article, voted: true, shop_name: shopName, cart_count: cartCount, theme_css: _helpThemeCss(),
7887
+ canonical_url: urls.canonical_url, og_url: urls.og_url,
7888
+ }));
7889
+ });
7890
+ }
7891
+
7470
7892
  // Persist a locale choice. A GET form (works with JS off) from the
7471
7893
  // footer switcher submits `lang` (the chosen tag) + `to` (the path to
7472
7894
  // return to). We validate the tag against the active locale set, set
@@ -8569,18 +8991,49 @@ function mount(router, deps) {
8569
8991
 
8570
8992
  router.get("/collections/:slug", async function (req, res) {
8571
8993
  var slug = req.params && req.params.slug;
8572
- // get() and productsIn() both throw on a malformed slug (the
8573
- // primitive validates shape). A bad path segment is a 404, not a
8574
- // 500 — the route is a defensive request-shape reader.
8575
- var col, result;
8994
+ // get() throws a TypeError on a malformed slug (the primitive
8995
+ // validates shape). A bad path segment / unknown / archived
8996
+ // collection is a 404, not a 500 — the route is a defensive
8997
+ // request-shape reader.
8998
+ var col;
8576
8999
  try {
8577
9000
  col = slug ? await deps.collections.get(slug) : null;
8578
9001
  if (!col || col.archived_at != null) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
8579
- result = await deps.collections.productsIn({ slug: slug, limit: 24 });
8580
9002
  } catch (e) {
8581
9003
  if (e instanceof TypeError) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
8582
9004
  throw e;
8583
9005
  }
9006
+
9007
+ // `?cursor=` trail — the comma-joined list of page-start cursors. The
9008
+ // current page starts at the trail's last cursor (page 1 = empty
9009
+ // trail). Parsed defensively (garbage entries dropped); the lib still
9010
+ // HMAC-verifies the resolved start cursor, so a tampered-but-well-
9011
+ // shaped cursor is caught below and falls back to page 1 rather than
9012
+ // 500/404 — matching how `/search` treats a bad `?page=`.
9013
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
9014
+ var trail = _parseCollectionCursorTrail(url);
9015
+ var startCursor = trail.length ? trail[trail.length - 1] : null;
9016
+
9017
+ var result;
9018
+ try {
9019
+ result = await deps.collections.productsIn({ slug: slug, limit: COLLECTION_PAGE_SIZE, cursor: startCursor });
9020
+ } catch (e2) {
9021
+ // A bad/stale/tampered cursor surfaces as a TypeError whose message
9022
+ // names the cursor. Behave like page 1 (a reachable, link-followable
9023
+ // result) instead of a 404 — the collection still exists. Any other
9024
+ // TypeError (an impossible slug change between get() and productsIn)
9025
+ // is a 404; non-TypeErrors propagate.
9026
+ if (e2 instanceof TypeError && /cursor/i.test(e2.message || "")) {
9027
+ trail = [];
9028
+ startCursor = null;
9029
+ result = await deps.collections.productsIn({ slug: slug, limit: COLLECTION_PAGE_SIZE, cursor: null });
9030
+ } else if (e2 instanceof TypeError) {
9031
+ return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
9032
+ } else {
9033
+ throw e2;
9034
+ }
9035
+ }
9036
+
8584
9037
  var products = [];
8585
9038
  for (var i = 0; i < result.rows.length; i += 1) {
8586
9039
  var pid = result.rows[i].product_id || result.rows[i].id;
@@ -8590,6 +9043,7 @@ function mount(router, deps) {
8590
9043
  var cartCount = await _cartCountForReq(req);
8591
9044
  _send(res, 200, renderCollection(Object.assign({
8592
9045
  collection: col, products: products, shop_name: shopName, cart_count: cartCount,
9046
+ cursor_trail: trail, next_cursor: result.next_cursor,
8593
9047
  }, _requestUrls(req))));
8594
9048
  });
8595
9049
  }
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.32",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {