@blamejs/blamejs-shop 0.3.17 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.18 (2026-05-30) — **Write and publish blog posts from the admin console.** The storefront already served a blog — the index at /blog, individual posts at /blog/:slug, an RSS feed, and sitemap entries — but there was no way to write a post, so it rendered empty. The admin console now has a Blog screen with the full lifecycle: create a draft, edit its title, body (Markdown), tags, hero image, and meta fields, publish it, unpublish it, and archive or restore it. A post is created as a draft and stays off the storefront until it is explicitly published, so work in progress is never visible to shoppers. **Added:** *Blog authoring in the admin console* — A new Blog screen lists posts by status and lets an operator create, edit, publish, unpublish, archive, and restore them. New posts start as drafts and do not appear on the storefront /blog, its RSS feed, or the sitemap until published; archiving removes a published post from the storefront and is reversible. This wires the admin over the blog backend that was already serving the customer-facing pages.
12
+
11
13
  - v0.3.17 (2026-05-30) — **Search results paginate with an accurate count, and collection pages get canonical URLs.** Two customer-facing storefront fixes. Search no longer stops at the first 24 products: the result count now reports the true number of matches instead of the page size, and numbered page links (with prev/next) make every matching product reachable while preserving the active query and filters. Separately, collection pages now emit a correct canonical and og:url so search engines and shared links resolve to one address, while the cart and account pages — which should not be indexed — are explicitly marked noindex. **Fixed:** *Search shows the real match count and paginates* — The search results page previously capped at 24 products, discarded the real total, and reported the page size as the match count — so anything past the first 24 was unreachable and the count was wrong. It now computes the true total, shows it in the "Showing N matches" summary, and adds no-JavaScript numbered page links (with prev/next) that carry the active query and facet filters, so every matching product can be reached. · *Collection pages have canonical URLs; cart and account are noindex* — Collection pages now emit an absolute canonical and og:url derived from the request host, so indexers and shared links resolve to a single address instead of an empty tag. The cart and account pages, which are not meant to be indexed, now carry a noindex robots tag in their markup, consistent on both the edge and container render paths.
12
14
 
13
15
  - v0.3.16 (2026-05-30) — **Uploaded SVGs are sanitized, and product images can be reordered with a chosen primary.** Two things for product media in the admin console. First, when an SVG image is uploaded it now passes through a strict sanitizer: an SVG that carries script or other active content is repaired, or refused outright when it can't be made safe, so a stored image can't become a script-execution vector. Second, a product's images can now be reordered and any image promoted to the primary (hero) slot directly from the console — previously the order was fixed at attach time and the first-attached image was always the hero. The primary image is the one shown first on the product page. **Added:** *Reorder product images and set the primary from the console* — Each image on a product now has a "Make primary" control that moves it to the hero slot shown first on the product page, and a reorder control sets the full order. Both are no-JavaScript form actions. The product page renders images in this order, so changing it here changes what shoppers see first. **Changed:** *Vendored runtime updated to v0.14.8* — The vendored blamejs runtime is updated to v0.14.8, an internal source-comment and lint-hygiene release with no API, wire-format, or behavior changes. No operator action is required. **Security:** *Uploaded SVG images are sanitized or refused* — An uploaded SVG is now checked by a strict sanitizer before it is stored: active content (scripts, event handlers, external references) is stripped, and an SVG that cannot be made safe is rejected with a clear error and never written. This closes the path where a crafted SVG, once stored and served, could execute script. Raster image uploads (PNG, JPEG, WebP, GIF) are unaffected.
package/lib/admin.js CHANGED
@@ -510,7 +510,7 @@ function mount(router, deps) {
510
510
  // `reports` is always present in the nav (read-only sales summary needs no
511
511
  // extra dep); its route mounts unconditionally and renders an unconfigured
512
512
  // notice when the salesReports primitive isn't wired.
513
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
513
+ 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, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
514
514
 
515
515
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
516
516
 
@@ -4026,6 +4026,245 @@ function mount(router, deps) {
4026
4026
  ));
4027
4027
  }
4028
4028
 
4029
+ // ---- blog -----------------------------------------------------------
4030
+ // Author the operator's editorial blog. The edge Worker serves the
4031
+ // customer-facing /blog index + /blog/:slug posts + the RSS feed,
4032
+ // reading ONLY published rows; this console writes them. A post is
4033
+ // created as a draft and stays invisible to the storefront until it's
4034
+ // published — publish / unpublish / archive / restore move it through
4035
+ // the lifecycle (draft → published → archived, with restore back to
4036
+ // draft). Content-negotiated like the other screens: bearer → the JSON
4037
+ // contract; signed-in browser → the HTML list + author forms.
4038
+ if (deps.blog) {
4039
+ var blog = deps.blog;
4040
+
4041
+ // The list pulls each lifecycle state separately (the primitive has
4042
+ // listDrafts / listArchived + listPublished) and merges them for the
4043
+ // console table. The published set is the first page of the cursor-
4044
+ // paginated feed — an editorial corpus that the console doesn't page
4045
+ // (operators have tens of posts); the storefront's /blog is the
4046
+ // paginated reader.
4047
+ async function _blogRows(filter) {
4048
+ var drafts = await blog.listDrafts();
4049
+ var archived = await blog.listArchived();
4050
+ var published = (await blog.listPublished({ limit: blog.MAX_LIST_LIMIT })).rows;
4051
+ if (filter === "draft") return drafts;
4052
+ if (filter === "published") return published;
4053
+ if (filter === "archived") return archived;
4054
+ // All: published first (newest-live), then drafts, then archived.
4055
+ return published.concat(drafts).concat(archived);
4056
+ }
4057
+
4058
+ router.get("/admin/blog", _pageOrApi(true,
4059
+ R(async function (req, res) {
4060
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4061
+ var status = (url && url.searchParams.get("status")) || null;
4062
+ _json(res, 200, { rows: await _blogRows(status) });
4063
+ }),
4064
+ async function (req, res) {
4065
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4066
+ var status = (url && url.searchParams.get("status")) || null;
4067
+ var rows = await _blogRows(status);
4068
+ _sendHtml(res, 200, renderAdminBlog({
4069
+ shop_name: deps.shop_name, nav_available: navAvailable, articles: rows,
4070
+ status_filter: status,
4071
+ created: url && url.searchParams.get("created"),
4072
+ updated: url && url.searchParams.get("updated"),
4073
+ published: url && url.searchParams.get("published"),
4074
+ archived: url && url.searchParams.get("archived"),
4075
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the post." : null,
4076
+ }));
4077
+ },
4078
+ ));
4079
+
4080
+ // The new-post form. A standalone GET so the "New post" button on the
4081
+ // list has a target; the create POST lives at /admin/blog.
4082
+ router.get("/admin/blog/new", _pageOrApi(true,
4083
+ R(async function (_req, res) {
4084
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/blog with a JSON body to create a post");
4085
+ }),
4086
+ async function (_req, res) {
4087
+ _sendHtml(res, 200, renderAdminBlogDetail({
4088
+ shop_name: deps.shop_name, nav_available: navAvailable, article: null,
4089
+ }));
4090
+ },
4091
+ ));
4092
+
4093
+ // Create content-negotiates: bearer → JSON 201 (a draft row); browser
4094
+ // form → createDraft, then PRG to the post's detail screen to keep
4095
+ // authoring. Every post is born a DRAFT — never published on create —
4096
+ // so it can't reach the storefront before the operator publishes it.
4097
+ router.post("/admin/blog", _pageOrApi(false,
4098
+ W("blog.create", async function (req, res) {
4099
+ var row = await blog.createDraft(req.body || {});
4100
+ _json(res, 201, row);
4101
+ return { id: row.slug };
4102
+ }),
4103
+ async function (req, res) {
4104
+ var made;
4105
+ try {
4106
+ made = await blog.createDraft(_blogFromForm(req.body || {}));
4107
+ } catch (e) {
4108
+ var n = _safeNotice(e, "blog.create");
4109
+ return _sendHtml(res, n.status, renderAdminBlogDetail({
4110
+ shop_name: deps.shop_name, nav_available: navAvailable, article: null,
4111
+ form_values: req.body || {},
4112
+ notice: n.message.replace(/^blogArticles[.:]\s*/, ""),
4113
+ }));
4114
+ }
4115
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".blog.create", outcome: "success", metadata: { slug: made.slug } });
4116
+ _redirect(res, "/admin/blog/" + encodeURIComponent(made.slug) + "?created=1");
4117
+ },
4118
+ ));
4119
+
4120
+ // Detail content-negotiates: bearer → JSON (the row, any status);
4121
+ // browser → the edit form + the lifecycle action buttons. A bad /
4122
+ // unknown slug is a 404 page, never a 500.
4123
+ router.get("/admin/blog/:slug", _pageOrApi(true,
4124
+ R(async function (req, res) {
4125
+ var row;
4126
+ try { row = await blog.get(req.params.slug); }
4127
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "blog-post-not-found", e.message); throw e; }
4128
+ if (!row) return _problem(res, 404, "blog-post-not-found");
4129
+ _json(res, 200, row);
4130
+ }),
4131
+ async function (req, res) {
4132
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4133
+ var row;
4134
+ try { row = await blog.get(req.params.slug); }
4135
+ catch (e) { if (!(e instanceof TypeError)) throw e; row = null; }
4136
+ if (!row) return _sendHtml(res, 404, renderAdminBlog({
4137
+ shop_name: deps.shop_name, nav_available: navAvailable, articles: [], notice: "Post not found.",
4138
+ }));
4139
+ _sendHtml(res, 200, renderAdminBlogDetail({
4140
+ shop_name: deps.shop_name, nav_available: navAvailable, article: row,
4141
+ updated: url && url.searchParams.get("updated"),
4142
+ published: url && url.searchParams.get("published"),
4143
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the post." : null,
4144
+ }));
4145
+ },
4146
+ ));
4147
+
4148
+ // Edit content-negotiates: bearer PATCH (the JSON contract) + browser
4149
+ // POST /edit (HTML forms can't PATCH). Both patch the editable columns
4150
+ // (title / body / author / tags / meta / hero image). Status is NOT
4151
+ // editable here — it moves via the lifecycle routes below.
4152
+ router.patch("/admin/blog/:slug", W("blog.update", async function (req, res) {
4153
+ var row;
4154
+ try { row = await blog.update(req.params.slug, req.body || {}); }
4155
+ catch (e) {
4156
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4157
+ if (e && e.code === "BLOG_ARTICLE_NOT_FOUND") return _problem(res, 404, "blog-post-not-found");
4158
+ throw e;
4159
+ }
4160
+ _json(res, 200, row);
4161
+ return { id: row.slug };
4162
+ }));
4163
+
4164
+ router.post("/admin/blog/:slug/edit", _pageOrApi(false,
4165
+ W("blog.update", async function (req, res) {
4166
+ var row;
4167
+ try { row = await blog.update(req.params.slug, req.body || {}); }
4168
+ catch (e) {
4169
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4170
+ if (e && e.code === "BLOG_ARTICLE_NOT_FOUND") return _problem(res, 404, "blog-post-not-found");
4171
+ throw e;
4172
+ }
4173
+ _json(res, 200, row);
4174
+ return { id: row.slug };
4175
+ }),
4176
+ async function (req, res) {
4177
+ var slug = req.params.slug;
4178
+ var enc = encodeURIComponent(slug);
4179
+ try {
4180
+ await blog.update(slug, _blogPatchFromForm(req.body || {}));
4181
+ } catch (e) {
4182
+ if (e && e.code === "BLOG_ARTICLE_NOT_FOUND") return _redirect(res, "/admin/blog?err=1");
4183
+ var n = _safeNotice(e, "blog.update");
4184
+ var current = null;
4185
+ try { current = await blog.get(slug); } catch (_e) { current = null; }
4186
+ if (!current) return _redirect(res, "/admin/blog?err=1");
4187
+ return _sendHtml(res, n.status, renderAdminBlogDetail({
4188
+ shop_name: deps.shop_name, nav_available: navAvailable, article: current,
4189
+ notice: n.message.replace(/^blogArticles[.:]\s*/, ""),
4190
+ }));
4191
+ }
4192
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".blog.update", outcome: "success", metadata: { slug: slug } });
4193
+ _redirect(res, "/admin/blog/" + enc + "?updated=1");
4194
+ },
4195
+ ));
4196
+
4197
+ // Lifecycle transitions — each PRGs back to the detail. publish takes
4198
+ // a draft live (now visible on the storefront); unpublish pulls it
4199
+ // back to draft (gone from the storefront); restore returns an
4200
+ // archived post to draft. An illegal transition (wrong from-state) or
4201
+ // a missing slug is a ?err=1 notice, never a 500.
4202
+ function _blogTransition(action, fn) {
4203
+ router.post("/admin/blog/:slug/" + action, _pageOrApi(false,
4204
+ W("blog." + action, async function (req, res) {
4205
+ var row;
4206
+ try { row = await fn(req.params.slug); }
4207
+ catch (e) {
4208
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4209
+ if (e && e.code === "BLOG_ARTICLE_NOT_FOUND") return _problem(res, 404, "blog-post-not-found");
4210
+ if (e && e.code === "BLOG_ARTICLE_BAD_STATE") return _problem(res, 409, "conflict", e.message);
4211
+ throw e;
4212
+ }
4213
+ _json(res, 200, row);
4214
+ return { id: row.slug };
4215
+ }),
4216
+ async function (req, res) {
4217
+ var slug = req.params.slug;
4218
+ var enc = encodeURIComponent(slug);
4219
+ try {
4220
+ await fn(slug);
4221
+ } catch (e) {
4222
+ if (e instanceof TypeError || (e && (e.code === "BLOG_ARTICLE_NOT_FOUND" || e.code === "BLOG_ARTICLE_BAD_STATE"))) {
4223
+ return _redirect(res, "/admin/blog/" + enc + "?err=1");
4224
+ }
4225
+ throw e;
4226
+ }
4227
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".blog." + action, outcome: "success", metadata: { slug: slug } });
4228
+ // archive lands back on the list (the post left the editable
4229
+ // set); the others stay on the detail with a status banner.
4230
+ if (action === "archive") return _redirect(res, "/admin/blog?archived=1");
4231
+ _redirect(res, "/admin/blog/" + enc + (action === "publish" ? "?published=1" : "?updated=1"));
4232
+ },
4233
+ ));
4234
+ }
4235
+ _blogTransition("publish", function (s) { return blog.publish(s); });
4236
+ _blogTransition("unpublish", function (s) { return blog.unpublish(s); });
4237
+ _blogTransition("restore", function (s) { return blog.restore(s); });
4238
+
4239
+ // Archive is destructive-ish (the post leaves the live storefront and
4240
+ // the editable set), so the browser path confirms first — the CSP
4241
+ // forbids a client confirm() dialog. Reached by a GET link from the
4242
+ // detail screen; bearer clients POST /archive directly.
4243
+ router.get("/admin/blog/:slug/archive/confirm-page", _pageOrApi(true,
4244
+ R(async function (_req, res) {
4245
+ return _problem(res, 405, "use-canonical-endpoint", "POST /admin/blog/:slug/archive directly for the JSON API");
4246
+ }),
4247
+ async function (req, res) {
4248
+ var slug = req.params.slug;
4249
+ var enc = encodeURIComponent(slug);
4250
+ var row = null;
4251
+ try { row = await blog.get(slug); }
4252
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
4253
+ if (!row) return _redirect(res, "/admin/blog?err=1");
4254
+ _sendHtml(res, 200, renderAdminConfirm({
4255
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "blog",
4256
+ heading: "Archive this post?",
4257
+ consequence: "Archiving removes the post from the live storefront blog. You can restore it to a draft later.",
4258
+ detail: "Post: " + (row.title || slug) + ".",
4259
+ action: "/admin/blog/" + _htmlEscape(enc) + "/archive",
4260
+ confirm_label: "Archive post",
4261
+ cancel_href: "/admin/blog/" + enc,
4262
+ }));
4263
+ },
4264
+ ));
4265
+ _blogTransition("archive", function (s) { return blog.archive(s); });
4266
+ }
4267
+
4029
4268
  // ---- customer surveys -----------------------------------------------
4030
4269
  // Define NPS/CSAT/CES/custom surveys, issue token invitations (the
4031
4270
  // plaintext link is shown once on issue), and read the rollup. The
@@ -6393,6 +6632,7 @@ var ADMIN_NAV_ITEMS = [
6393
6632
  { key: "shipping-labels", href: "/admin/shipping-labels", label: "Shipping labels", requires: "shippingLabels" },
6394
6633
  { key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
6395
6634
  { key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
6635
+ { key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
6396
6636
  { key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
6397
6637
  { key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
6398
6638
  { key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
@@ -9071,6 +9311,209 @@ function renderAdminAnnouncements(opts) {
9071
9311
  return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
9072
9312
  }
9073
9313
 
9314
+ // ---- blog form coercion + render --------------------------------------
9315
+
9316
+ // A comma- or newline-separated tag field → the normalized string array
9317
+ // the blog primitive validates. Empty entries are dropped; the primitive
9318
+ // enforces the per-tag shape + the dedupe + the count cap, so this only
9319
+ // splits and trims.
9320
+ function _csvTags(v) {
9321
+ if (typeof v !== "string" || !v.trim()) return [];
9322
+ return v.split(/[,\n]/).map(function (s) { return s.trim(); }).filter(Boolean);
9323
+ }
9324
+
9325
+ // Coerce the create form into blogArticles.createDraft's shape: trimmed
9326
+ // slug, the required title/body/author, the optional tags array, and the
9327
+ // optional hero-image + meta fields (a blank optional field is omitted so
9328
+ // the primitive's null default applies). The primitive validates every
9329
+ // field — this only shapes the form strings.
9330
+ function _blogFromForm(body) {
9331
+ body = body || {};
9332
+ var out = {
9333
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
9334
+ title: body.title,
9335
+ body: body.body,
9336
+ author_id: typeof body.author_id === "string" ? body.author_id.trim() : body.author_id,
9337
+ };
9338
+ var tags = _csvTags(body.tags);
9339
+ if (tags.length) out.tags = tags;
9340
+ var hero = typeof body.hero_image_url === "string" ? body.hero_image_url.trim() : "";
9341
+ if (hero) out.hero_image_url = hero;
9342
+ var md = typeof body.meta_description === "string" ? body.meta_description.trim() : "";
9343
+ if (md) out.meta_description = md;
9344
+ var mk = typeof body.meta_keywords === "string" ? body.meta_keywords.trim() : "";
9345
+ if (mk) out.meta_keywords = mk;
9346
+ return out;
9347
+ }
9348
+
9349
+ // Coerce the edit form into a blogArticles.update patch. Only the columns
9350
+ // the form actually carries are included; an empty optional field is sent
9351
+ // as "" so the operator can clear a previously-set meta line / hero image
9352
+ // (the primitive accepts an empty string for the nullable meta columns).
9353
+ // Tags always ride (an empty list clears them). Status is NOT here — it
9354
+ // moves via the lifecycle routes.
9355
+ function _blogPatchFromForm(body) {
9356
+ body = body || {};
9357
+ var patch = { tags: _csvTags(body.tags) };
9358
+ if (typeof body.title === "string") patch.title = body.title;
9359
+ if (typeof body.body === "string") patch.body = body.body;
9360
+ if (typeof body.author_id === "string") patch.author_id = body.author_id.trim();
9361
+ if (typeof body.hero_image_url === "string") patch.hero_image_url = body.hero_image_url.trim() || null;
9362
+ if (typeof body.meta_description === "string") patch.meta_description = body.meta_description.trim();
9363
+ if (typeof body.meta_keywords === "string") patch.meta_keywords = body.meta_keywords.trim();
9364
+ return patch;
9365
+ }
9366
+
9367
+ // One status pill class per lifecycle state, reusing the order-status pill
9368
+ // palette: published = paid (green), draft = pending (amber), archived =
9369
+ // cancelled (grey).
9370
+ function _blogStatusPill(status) {
9371
+ var cls = status === "published" ? "paid" : status === "archived" ? "cancelled" : "pending";
9372
+ return "<span class=\"status-pill " + cls + "\">" + _htmlEscape(status) + "</span>";
9373
+ }
9374
+
9375
+ function renderAdminBlog(opts) {
9376
+ opts = opts || {};
9377
+ var rows = opts.articles || [];
9378
+ var created = opts.created ? "<div class=\"banner banner--ok\">Post created as a draft.</div>" : "";
9379
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Post saved.</div>" : "";
9380
+ var published = opts.published ? "<div class=\"banner banner--ok\">Post published — it's live on the storefront blog.</div>" : "";
9381
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Post archived.</div>" : "";
9382
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
9383
+
9384
+ var sf = opts.status_filter;
9385
+ var chips = "<div class=\"order-filters\">" +
9386
+ "<a class=\"chip" + (sf == null ? " chip--on" : "") + "\" href=\"/admin/blog\">All</a>" +
9387
+ "<a class=\"chip" + (sf === "published" ? " chip--on" : "") + "\" href=\"/admin/blog?status=published\">Published</a>" +
9388
+ "<a class=\"chip" + (sf === "draft" ? " chip--on" : "") + "\" href=\"/admin/blog?status=draft\">Drafts</a>" +
9389
+ "<a class=\"chip" + (sf === "archived" ? " chip--on" : "") + "\" href=\"/admin/blog?status=archived\">Archived</a>" +
9390
+ "</div>";
9391
+
9392
+ var bodyRows = rows.map(function (a) {
9393
+ var enc = encodeURIComponent(a.slug);
9394
+ var date = a.published_at != null ? _fmtDate(a.published_at) : "—";
9395
+ // Per-row lifecycle actions match the post's current state: a draft
9396
+ // can publish; a published post can unpublish or archive; an archived
9397
+ // post can restore. Edit + the storefront link are always offered.
9398
+ var actions = "<a class=\"btn btn--ghost\" href=\"/admin/blog/" + _htmlEscape(enc) + "\">Edit</a>";
9399
+ if (a.status === "draft") {
9400
+ actions += "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/publish\" class=\"form-inline\">" +
9401
+ "<button class=\"btn\" type=\"submit\">Publish</button></form>";
9402
+ } else if (a.status === "published") {
9403
+ actions += "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/unpublish\" class=\"form-inline\">" +
9404
+ "<button class=\"btn btn--ghost\" type=\"submit\">Unpublish</button></form>" +
9405
+ "<a class=\"btn btn--danger\" href=\"/admin/blog/" + _htmlEscape(enc) + "/archive/confirm-page\">Archive</a>";
9406
+ } else if (a.status === "archived") {
9407
+ actions += "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/restore\" class=\"form-inline\">" +
9408
+ "<button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>";
9409
+ }
9410
+ return "<tr>" +
9411
+ "<td><a href=\"/admin/blog/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(a.title) + "</strong></a></td>" +
9412
+ "<td><code class=\"order-id\">" + _htmlEscape(a.slug) + "</code></td>" +
9413
+ "<td>" + _blogStatusPill(a.status) + "</td>" +
9414
+ "<td>" + _htmlEscape(date) + "</td>" +
9415
+ "<td><div class=\"actions-row\">" + actions + "</div></td>" +
9416
+ "</tr>";
9417
+ }).join("");
9418
+
9419
+ var table = rows.length
9420
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Slug</th><th scope=\"col\">Status</th><th scope=\"col\">Published</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
9421
+ : "<p class=\"empty\">No posts" + (sf ? " " + _htmlEscape(sf) : " yet") + ". Write your first one.</p>";
9422
+
9423
+ var newBtn = "<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/blog/new\">New post</a></div>";
9424
+
9425
+ var bodyHtml = "<section><h2>Blog</h2>" +
9426
+ created + updated + published + archived + notice +
9427
+ "<p class=\"meta\">Editorial posts shown on the storefront blog (/blog). A post is created as a draft and stays hidden until you publish it.</p>" +
9428
+ newBtn + chips + table + "</section>";
9429
+ return _renderAdminShell(opts.shop_name, "Blog", bodyHtml, "blog", opts.nav_available);
9430
+ }
9431
+
9432
+ // New-post form (article: null) or edit form (article set) for a single
9433
+ // blog post, plus the lifecycle action row when editing an existing post.
9434
+ // `form_values` re-fills a failed create so the operator doesn't retype.
9435
+ function renderAdminBlogDetail(opts) {
9436
+ opts = opts || {};
9437
+ var a = opts.article || null;
9438
+ var isNew = !a;
9439
+ var fv = opts.form_values || {};
9440
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Post saved.</div>" : "";
9441
+ var published = opts.published ? "<div class=\"banner banner--ok\">Post published — it's live on the storefront blog.</div>" : "";
9442
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
9443
+
9444
+ // Field values: the existing row when editing, else the failed-create
9445
+ // form values, else blank.
9446
+ function _val(col) {
9447
+ if (a && a[col] != null) return a[col];
9448
+ if (fv[col] != null) return fv[col];
9449
+ return "";
9450
+ }
9451
+ var tagsVal = a && Array.isArray(a.tags) ? a.tags.join(", ")
9452
+ : (typeof fv.tags === "string" ? fv.tags : "");
9453
+
9454
+ var action = isNew ? "/admin/blog" : "/admin/blog/" + encodeURIComponent(a.slug) + "/edit";
9455
+ // Slug is the PK — editable only on create (the primitive keys update on
9456
+ // it). On edit it shows read-only so the operator sees the storefront URL.
9457
+ var slugField = isNew
9458
+ ? _setupField("Slug", "slug", _val("slug"), "text", "Lowercase letters, digits, and hyphens — appears in /blog/<slug>.", " maxlength=\"120\" required")
9459
+ : "<label class=\"form-field\"><span>Slug</span><input type=\"text\" value=\"" + _htmlEscape(a.slug) + "\" readonly>" +
9460
+ "<small>The storefront URL: <code>/blog/" + _htmlEscape(a.slug) + "</code></small></label>";
9461
+
9462
+ var form =
9463
+ "<div class=\"panel mw-40\">" +
9464
+ "<h3 class=\"subhead\">" + (isNew ? "New post" : "Post details") + "</h3>" +
9465
+ "<form method=\"post\" action=\"" + _htmlEscape(action) + "\">" +
9466
+ slugField +
9467
+ _setupField("Title", "title", _val("title"), "text", "", " maxlength=\"200\" required") +
9468
+ _setupField("Author", "author_id", _val("author_id"), "text", "An author id (your editorial team's handle).", " maxlength=\"80\" required") +
9469
+ "<label class=\"form-field\"><span>Body (Markdown)</span>" +
9470
+ "<textarea name=\"body\" rows=\"16\" maxlength=\"200000\" required>" + _htmlEscape(_val("body")) + "</textarea>" +
9471
+ "<small>Headings, lists, links, bold/italic. Raw HTML is escaped — links are https-only.</small></label>" +
9472
+ _setupField("Tags (optional)", "tags", tagsVal, "text", "Comma-separated, e.g. guides, buying-tips.", " maxlength=\"1000\"") +
9473
+ _setupField("Hero image URL (optional)", "hero_image_url", _val("hero_image_url"), "text", "https:// or a /-rooted path.", " maxlength=\"2048\"") +
9474
+ _setupField("Meta description (optional)", "meta_description", _val("meta_description"), "text", "Shown in search results + the post's <head>.", " maxlength=\"320\"") +
9475
+ _setupField("Meta keywords (optional)", "meta_keywords", _val("meta_keywords"), "text", "", " maxlength=\"320\"") +
9476
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">" + (isNew ? "Create draft" : "Save") + "</button>" +
9477
+ "<a class=\"btn btn--ghost\" href=\"/admin/blog\">Back</a></div>" +
9478
+ "</form>" +
9479
+ "</div>";
9480
+
9481
+ var lifecycle = "";
9482
+ if (!isNew) {
9483
+ var enc = encodeURIComponent(a.slug);
9484
+ var btns = "";
9485
+ if (a.status === "draft") {
9486
+ btns = "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/publish\" class=\"form-inline\">" +
9487
+ "<button class=\"btn\" type=\"submit\">Publish</button></form>" +
9488
+ "<span class=\"meta\">Publishing makes the post live on the storefront blog.</span>";
9489
+ } else if (a.status === "published") {
9490
+ btns = "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/unpublish\" class=\"form-inline\">" +
9491
+ "<button class=\"btn btn--ghost\" type=\"submit\">Unpublish</button></form>" +
9492
+ "<a class=\"btn btn--danger\" href=\"/admin/blog/" + _htmlEscape(enc) + "/archive/confirm-page\">Archive</a>" +
9493
+ "<span class=\"meta\">Unpublish pulls it back to a draft; archive removes it from the storefront.</span>";
9494
+ } else if (a.status === "archived") {
9495
+ btns = "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/restore\" class=\"form-inline\">" +
9496
+ "<button class=\"btn btn--ghost\" type=\"submit\">Restore to draft</button></form>";
9497
+ }
9498
+ lifecycle = "<div class=\"panel mt-1 mw-40\"><h3 class=\"subhead\">Status</h3>" +
9499
+ "<p class=\"meta\">Current state: " + _blogStatusPill(a.status) + "</p>" +
9500
+ "<div class=\"actions-row\">" + btns + "</div></div>";
9501
+ }
9502
+
9503
+ var head = isNew
9504
+ ? "<p class=\"meta\"><a href=\"/admin/blog\">&larr; Blog</a></p>"
9505
+ : "<p class=\"meta\"><a href=\"/admin/blog\">&larr; Blog</a> · " +
9506
+ _blogStatusPill(a.status) +
9507
+ (a.status === "published"
9508
+ ? " · <a href=\"/blog/" + _htmlEscape(encodeURIComponent(a.slug)) + "\" target=\"_blank\" rel=\"noreferrer\">View on storefront &rarr;</a>"
9509
+ : "") +
9510
+ "</p>";
9511
+
9512
+ var title = isNew ? "New post" : (a.title || a.slug);
9513
+ var body = "<section><h2>" + _htmlEscape(title) + "</h2>" + updated + published + notice + head + form + "</section>" + lifecycle;
9514
+ return _renderAdminShell(opts.shop_name, isNew ? "New post" : "Post " + a.slug, body, "blog", opts.nav_available);
9515
+ }
9516
+
9074
9517
  // Standard question set per survey kind, so the console can create the
9075
9518
  // common surveys without a dynamic question builder (fully custom question
9076
9519
  // lists go through the JSON defineSurvey API). Each question carries a
@@ -9889,6 +10332,8 @@ module.exports = {
9889
10332
  renderAdminQuestion: renderAdminQuestion,
9890
10333
  renderAdminCollections: renderAdminCollections,
9891
10334
  renderAdminCollection: renderAdminCollection,
10335
+ renderAdminBlog: renderAdminBlog,
10336
+ renderAdminBlogDetail: renderAdminBlogDetail,
9892
10337
  renderAdminGiftCards: renderAdminGiftCards,
9893
10338
  renderAdminGiftCard: renderAdminGiftCard,
9894
10339
  renderAdminTaxRates: renderAdminTaxRates,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.17",
2
+ "version": "0.3.18",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
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": {