@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 +2 -0
- package/lib/admin.js +436 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +333 -0
- package/package.json +1 -1
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\">← Help center</a></p>"
|
|
11841
|
+
: "<p class=\"meta\"><a href=\"/admin/help\">← 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 →</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,
|
package/lib/asset-manifest.json
CHANGED
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\">← 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