@blamejs/blamejs-shop 0.3.16 → 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,10 @@ 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
+
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.
14
+
11
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.
12
16
 
13
17
  - v0.3.15 (2026-05-30) — **Product image galleries are interactive — click a thumbnail to view that image full size.** A product page's image gallery now lets a shopper switch between the product's images: clicking a thumbnail swaps the main image, with no JavaScript required. Previously the thumbnail strip was decorative — the additional images were unreachable, a single-image product showed empty placeholder slots, and only the first four images were shown. Now the main image stacks the product's images and the thumbnails are real, keyboard-accessible controls that select which one is shown; a product with one image shows just that image (no empty slots), and the four-image cap is raised to twelve so far more images are reachable. The picker is pure HTML and CSS, so it works without scripting and degrades cleanly. **Fixed:** *The product image gallery actually switches images now* — Thumbnails on the product page are now interactive: selecting one shows that image as the main image, using a no-JavaScript radio/label control. The empty placeholder slots a single-image product used to show are gone, the four-image cap is raised to twelve so far more images are reachable, and the strip is keyboard-navigable with accessible labels. The edge and container renderers emit identical markup.
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.16",
2
+ "version": "0.3.18",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -36,10 +36,12 @@
36
36
  * the facet registry portable across whichever search
37
37
  * backend the operator chooses.
38
38
  *
39
- * `previewQuery({ query, filters })` returns the product count +
40
- * a sample of matching products for a candidate filter
41
- * combination — used by the storefront to render "Show 23 results"
42
- * before the shopper commits the click.
39
+ * `previewQuery({ query, filters, sample?, offset? })` returns the
40
+ * product count + a window of matching products for a candidate
41
+ * filter combination — used by the storefront to render "Show 23
42
+ * results" before the shopper commits the click, and to page the
43
+ * results grid (`offset`/`sample` window the matches; `total` stays
44
+ * the full passing count so the result-count copy is page-independent).
43
45
  *
44
46
  * `recordFacetUse({ key, value, session_id })` appends an
45
47
  * analytics row. The session id is namespaceHashed under
@@ -66,7 +68,7 @@
66
68
  * - updateFacet(key, patch)
67
69
  * - archiveFacet(key)
68
70
  * - getFacets({ query?, applied_filters?, scope? })
69
- * - previewQuery({ query?, filters })
71
+ * - previewQuery({ query?, filters, sample?, offset? })
70
72
  * - recordFacetUse({ key, value, session_id })
71
73
  *
72
74
  * Catalog contract — the operator passes a `catalog` to the
@@ -768,6 +770,13 @@ function create(opts) {
768
770
  var qStr = _optQuery(input.query, "searchFacets.previewQuery");
769
771
  var filters = _appliedFilters(input.filters, "searchFacets.previewQuery");
770
772
  var sample = _sampleSize(input.sample, "searchFacets.previewQuery");
773
+ // `offset` pages the returned window over the full passing set so a
774
+ // caller can request page N of the matches (storefront search
775
+ // pagination) without re-running the in-memory filter walk. The
776
+ // `total` is always the REAL passing count — the page window never
777
+ // changes it — so the result-count copy stays honest regardless of
778
+ // which page is requested. Defaults to 0 (the first window).
779
+ var offset = _offset(input.offset, "searchFacets.previewQuery");
771
780
 
772
781
  var facets = await _loadFacets();
773
782
  var facetsByKey = {};
@@ -790,7 +799,7 @@ function create(opts) {
790
799
  }
791
800
  return {
792
801
  total: passing.length,
793
- sample: passing.slice(0, sample),
802
+ sample: passing.slice(offset, offset + sample),
794
803
  };
795
804
  },
796
805
 
package/lib/storefront.js CHANGED
@@ -243,6 +243,7 @@ var LAYOUT =
243
243
  " <title>{{title}} — {{shop_name}}</title>\n" +
244
244
  " <meta name=\"description\" content=\"{{og_description}}\">\n" +
245
245
  " <link rel=\"canonical\" href=\"{{canonical_url}}\">\n" +
246
+ "RAW_ROBOTS_META" +
246
247
  " <link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">\n" +
247
248
  " <link rel=\"icon\" type=\"image/png\" href=\"/assets/brand/favicon.png\">\n" +
248
249
  " <link rel=\"apple-touch-icon\" href=\"/assets/brand/favicon.png\">\n" +
@@ -755,8 +756,21 @@ function _wrap(opts) {
755
756
  vars[ck] = chrome[ck];
756
757
  }
757
758
 
759
+ // Per-page robots directive. Indexable pages (the default) emit no
760
+ // robots meta — the absence means "index, follow". Pages the storefront
761
+ // keeps out of search (cart / account / any session-scoped surface)
762
+ // pass `robots: "noindex"`, which stamps a noindex,nofollow meta in the
763
+ // <head> — a belt-and-suspenders pairing with the robots.txt Disallow +
764
+ // the edge `x-robots-tag` header, so a directly-fetched page is also
765
+ // self-describing. A noindex page needs no canonical (a crawler won't
766
+ // index it to dedupe), so these pages don't thread a canonical_url.
767
+ var robotsMeta = (opts.robots === "noindex")
768
+ ? " <meta name=\"robots\" content=\"noindex,nofollow\">\n"
769
+ : "";
770
+
758
771
  var assembled = _render(LAYOUT, vars)
759
772
  .replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
773
+ .replace("RAW_ROBOTS_META", robotsMeta)
760
774
  .replace("RAW_ANNOUNCEMENT_BAR", announcementBarHtml)
761
775
  .replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
762
776
  .replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
@@ -1331,6 +1345,87 @@ function _renderSearchChips(facets, filters, q) {
1331
1345
  return "<div class=\"search-active-filters\" aria-label=\"Active filters\">\n" + chips + clearAll + "</div>\n";
1332
1346
  }
1333
1347
 
1348
+ // The fixed search page size — the number of result cards one page of
1349
+ // `/search` shows. Pages beyond the first are reached via `?page=N`
1350
+ // links the pagination nav emits (the real total drives how many pages
1351
+ // exist). Mirrors the edge renderer's SEARCH_PAGE_SIZE.
1352
+ var SEARCH_PAGE_SIZE = 24;
1353
+
1354
+ // `/search?...` URL for a specific results page — the query + active
1355
+ // filters carried forward (so paging preserves the facet state) with a
1356
+ // `page` param appended for any page past the first. Page 1 omits the
1357
+ // param so the canonical first page has one URL. Mirrors the edge
1358
+ // renderer's `_searchPageUrl` byte-for-byte.
1359
+ function _searchPageUrl(q, filters, page) {
1360
+ var base = _searchUrl(q, filters);
1361
+ if (page <= 1) return base;
1362
+ return base + (base.indexOf("?") === -1 ? "?" : "&") + "page=" + page;
1363
+ }
1364
+
1365
+ var SEARCH_PAGE_LINK =
1366
+ "<li class=\"search-pagination__page\"><a class=\"search-pagination__link\" href=\"{{href}}\"{{aria_current}}>{{n}}</a></li>\n";
1367
+
1368
+ // Numbered prev/next pagination for the result grid. No-JS + SEO-
1369
+ // friendly: every page is a real `?page=N` link carrying the active
1370
+ // query + facets, `rel="prev"/"next"` mark the sequence, and the current
1371
+ // page is `aria-current="page"`. Renders nothing for a single page (the
1372
+ // default when a caller threads no total — keeps the unpaginated render
1373
+ // byte-identical). `total` is the REAL match count (not the page slice
1374
+ // length); `page` is the 1-based current page. Mirrors the edge
1375
+ // renderer's `_renderSearchPagination` byte-for-byte.
1376
+ function _renderSearchPagination(q, filters, total, page, pageSize) {
1377
+ var size = pageSize > 0 ? pageSize : SEARCH_PAGE_SIZE;
1378
+ var totalPages = Math.max(1, Math.ceil(total / size));
1379
+ if (totalPages <= 1) return "";
1380
+ var cur = page < 1 ? 1 : (page > totalPages ? totalPages : page);
1381
+ var prev = cur > 1
1382
+ ? _render("<a class=\"search-pagination__link search-pagination__prev\" href=\"{{href}}\" rel=\"prev\">Previous</a>\n",
1383
+ { href: _searchPageUrl(q, filters, cur - 1) })
1384
+ : "<span class=\"search-pagination__link search-pagination__prev is-disabled\" aria-disabled=\"true\">Previous</span>\n";
1385
+ var next = cur < totalPages
1386
+ ? _render("<a class=\"search-pagination__link search-pagination__next\" href=\"{{href}}\" rel=\"next\">Next</a>\n",
1387
+ { href: _searchPageUrl(q, filters, cur + 1) })
1388
+ : "<span class=\"search-pagination__link search-pagination__next is-disabled\" aria-disabled=\"true\">Next</span>\n";
1389
+ var pagesHtml = "";
1390
+ for (var n = 1; n <= totalPages; n += 1) {
1391
+ pagesHtml += _render(SEARCH_PAGE_LINK, {
1392
+ href: _searchPageUrl(q, filters, n),
1393
+ aria_current: n === cur ? "RAW_ARIA" : "",
1394
+ n: String(n),
1395
+ }).replace("RAW_ARIA", n === cur ? " aria-current=\"page\"" : "");
1396
+ }
1397
+ return "<nav class=\"search-pagination\" aria-label=\"Search results pages\">\n" +
1398
+ prev +
1399
+ "<ol class=\"search-pagination__pages\">\n" + pagesHtml + "</ol>\n" +
1400
+ next +
1401
+ "</nav>\n";
1402
+ }
1403
+
1404
+ // Read the 1-based `?page=N` results page off a parsed URL. A missing,
1405
+ // non-integer, or sub-1 value reads as page 1 (the canonical first page);
1406
+ // the upper bound is clamped against the real page count by `_clampPage`
1407
+ // once the total is known. Defensive request-shape reader — returns the
1408
+ // safe default for any garbage rather than throwing.
1409
+ function _parsePageParam(url) {
1410
+ if (!url || !url.searchParams) return 1;
1411
+ var raw = url.searchParams.get("page");
1412
+ if (raw == null) return 1;
1413
+ if (!/^[0-9]{1,9}$/.test(raw)) return 1;
1414
+ var n = parseInt(raw, 10);
1415
+ return n >= 1 ? n : 1;
1416
+ }
1417
+
1418
+ // Clamp a 1-based page to `[1, lastPage]` for a given total + page size.
1419
+ // A `?page` past the end serves the last page (a stable, link-followable
1420
+ // result) rather than an empty grid; a zero-result query stays on page 1.
1421
+ function _clampPage(page, total, pageSize) {
1422
+ var size = pageSize > 0 ? pageSize : SEARCH_PAGE_SIZE;
1423
+ var lastPage = Math.max(1, Math.ceil(total / size));
1424
+ if (page < 1) return 1;
1425
+ if (page > lastPage) return lastPage;
1426
+ return page;
1427
+ }
1428
+
1334
1429
  // Hard caps mirroring `lib/search-facets.js`'s applied-filter
1335
1430
  // validators so a hostile / stale URL can't blow the in-memory facet
1336
1431
  // walk. Garbage values and unknown facet keys are dropped (not
@@ -1377,6 +1472,14 @@ function renderSearch(opts) {
1377
1472
  var facets = Array.isArray(opts.facets) ? opts.facets : [];
1378
1473
  var filters = (opts.filters && typeof opts.filters === "object") ? opts.filters : {};
1379
1474
  var hasFilters = Object.keys(filters).length > 0;
1475
+ // The result count copy + the page math run off the REAL match total —
1476
+ // the count of every product the query matched, not the length of the
1477
+ // page slice the renderer was handed. A caller that doesn't thread a
1478
+ // total (a unit test rendering one page directly) falls back to the page
1479
+ // length so the unpaginated render is unchanged.
1480
+ var pageSize = (typeof opts.page_size === "number" && opts.page_size > 0) ? opts.page_size : SEARCH_PAGE_SIZE;
1481
+ var totalCount = (typeof opts.total === "number" && opts.total >= 0) ? opts.total : products.length;
1482
+ var page = (typeof opts.page === "number" && opts.page >= 1) ? Math.floor(opts.page) : 1;
1380
1483
  var qTrim = opts.q.trim();
1381
1484
  var title, summary, emptyHeading, emptyCopy;
1382
1485
  if (qTrim.length === 0) {
@@ -1384,14 +1487,14 @@ function renderSearch(opts) {
1384
1487
  summary = "Use the search box in the header to look for a product by title, SKU, or description.";
1385
1488
  emptyHeading = "What are you looking for?";
1386
1489
  emptyCopy = "Type a query in the header search to find products by title, SKU, or description.";
1387
- } else if (products.length === 0) {
1490
+ } else if (totalCount === 0) {
1388
1491
  title = "No matches";
1389
1492
  summary = "Nothing in the catalog matched “" + qTrim + "”.";
1390
1493
  emptyHeading = "We don't carry that yet";
1391
1494
  emptyCopy = "Try a broader term, or browse every product on the home page.";
1392
1495
  } else {
1393
1496
  title = "“" + qTrim + "”";
1394
- summary = "Showing " + products.length + " match" + (products.length === 1 ? "" : "es") + " for your query.";
1497
+ summary = "Showing " + totalCount + " match" + (totalCount === 1 ? "" : "es") + " for your query.";
1395
1498
  }
1396
1499
 
1397
1500
  var correctionHtml = "";
@@ -1424,6 +1527,14 @@ function renderSearch(opts) {
1424
1527
  }).join("\n");
1425
1528
  resultsInner = "<section class=\"search-grid\"><div class=\"grid\">" + cards + "</div></section>";
1426
1529
  }
1530
+ // Pagination nav under the grid — `?page=N` links carrying the active
1531
+ // query + facets so paging never drops the filter state. Renders nothing
1532
+ // for a single page (the result count fits one page) or the empty / no-
1533
+ // query states. Driven by the real total, not the page slice length.
1534
+ var paginationHtml = (qTrim.length > 0 && totalCount > 0)
1535
+ ? _renderSearchPagination(opts.q, filters, totalCount, page, pageSize)
1536
+ : "";
1537
+ resultsInner += paginationHtml;
1427
1538
  var body;
1428
1539
  if (facetsHtml.length > 0) {
1429
1540
  body = header + correctionHtml + chipsHtml +
@@ -2689,6 +2800,7 @@ function renderCollectionList(opts) {
2689
2800
  return _wrap({
2690
2801
  title: "Collections", shop_name: opts.shop_name || "blamejs.shop",
2691
2802
  cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
2803
+ canonical_url: opts.canonical_url, og_url: opts.og_url,
2692
2804
  });
2693
2805
  }
2694
2806
 
@@ -2719,6 +2831,7 @@ function renderCollection(opts) {
2719
2831
  return _wrap({
2720
2832
  title: col.title, shop_name: opts.shop_name || "blamejs.shop",
2721
2833
  cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
2834
+ canonical_url: opts.canonical_url, og_url: opts.og_url,
2722
2835
  });
2723
2836
  }
2724
2837
 
@@ -4969,6 +5082,11 @@ function renderCart(opts) {
4969
5082
  shop_name: shopName,
4970
5083
  cart_count: lines.length,
4971
5084
  theme_css: opts.theme_css,
5085
+ // The cart is session-scoped and robots.txt-disallowed — keep it out
5086
+ // of the index with a noindex meta (matches the edge x-robots-tag on
5087
+ // the guest cart render), so a directly-crawled cart URL is self-
5088
+ // describing rather than relying on robots.txt alone.
5089
+ robots: "noindex",
4972
5090
  body: body,
4973
5091
  }, _currencyWrapOpts(opts)));
4974
5092
  }
@@ -5808,6 +5926,10 @@ function renderAccount(opts) {
5808
5926
  shop_name: opts.shop_name || "blamejs.shop",
5809
5927
  cart_count: opts.cart_count,
5810
5928
  theme_css: opts.theme_css,
5929
+ // The account dashboard is per-customer + robots.txt-disallowed — keep
5930
+ // it out of the index with a noindex meta rather than relying on the
5931
+ // crawl policy alone.
5932
+ robots: "noindex",
5811
5933
  body: body,
5812
5934
  });
5813
5935
  }
@@ -7396,10 +7518,17 @@ function mount(router, deps) {
7396
7518
  // otherwise round-trip through the LIKE escape function.
7397
7519
  if (q.length > 200) q = q.slice(0, 200);
7398
7520
 
7521
+ // 1-based results page from `?page=N`. A missing / non-numeric /
7522
+ // out-of-low-range value reads as page 1; the upper bound is clamped
7523
+ // to the real page count once the total is known (below) so a `?page`
7524
+ // past the end serves the last page rather than an empty grid.
7525
+ var page = _parsePageParam(url);
7526
+
7399
7527
  var products = [];
7400
7528
  var facetGroups = [];
7401
7529
  var filters = {};
7402
7530
  var correctedQ = "";
7531
+ var totalCount = 0;
7403
7532
 
7404
7533
  if (q.trim().length > 0) {
7405
7534
  // Synonym + typo rewrite expands the typed query into the
@@ -7434,11 +7563,28 @@ function mount(router, deps) {
7434
7563
  var facetDefs = await sfInstance.listFacets({});
7435
7564
  filters = _parseSearchFilters(url, facetDefs);
7436
7565
  facetGroups = await sfInstance.getFacets({ query: q, applied_filters: filters });
7437
- var preview = await sfInstance.previewQuery({ query: q, filters: filters, sample: 24 });
7566
+ // First pass reads the REAL total so the result-count copy is
7567
+ // honest and the page can be clamped to the last page. The second
7568
+ // pass windows that page (offset/sample) over the full passing set
7569
+ // — pages past the first are reachable, not discarded at 24.
7570
+ var totalPreview = await sfInstance.previewQuery({ query: q, filters: filters, sample: 0 });
7571
+ totalCount = totalPreview.total;
7572
+ page = _clampPage(page, totalCount, SEARCH_PAGE_SIZE);
7573
+ var preview = await sfInstance.previewQuery({
7574
+ query: q,
7575
+ filters: filters,
7576
+ sample: SEARCH_PAGE_SIZE,
7577
+ offset: (page - 1) * SEARCH_PAGE_SIZE,
7578
+ });
7438
7579
  products = preview.sample;
7439
7580
  } else {
7440
- // No facet dep — flat search over the expanded terms.
7441
- products = (await _facetableUniverse(terms)).slice(0, 24);
7581
+ // No facet dep — flat search over the expanded terms. The full
7582
+ // matched universe gives the honest total; the page is the
7583
+ // windowed slice (the same page size the faceted path uses).
7584
+ var flatUniverse = await _facetableUniverse(terms);
7585
+ totalCount = flatUniverse.length;
7586
+ page = _clampPage(page, totalCount, SEARCH_PAGE_SIZE);
7587
+ products = flatUniverse.slice((page - 1) * SEARCH_PAGE_SIZE, page * SEARCH_PAGE_SIZE);
7442
7588
  }
7443
7589
  }
7444
7590
 
@@ -7450,6 +7596,9 @@ function mount(router, deps) {
7450
7596
  facets: facetGroups,
7451
7597
  filters: filters,
7452
7598
  corrected_query: correctedQ,
7599
+ total: totalCount,
7600
+ page: page,
7601
+ page_size: SEARCH_PAGE_SIZE,
7453
7602
  shop_name: shopName,
7454
7603
  cart_count: cartCount,
7455
7604
  }, _requestUrls(req), ccy)));
@@ -7809,9 +7958,9 @@ function mount(router, deps) {
7809
7958
  router.get("/collections", async function (req, res) {
7810
7959
  var cols = await deps.collections.list({ active_only: true });
7811
7960
  var cartCount = await _cartCountForReq(req);
7812
- _send(res, 200, renderCollectionList({
7961
+ _send(res, 200, renderCollectionList(Object.assign({
7813
7962
  collections: cols, shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
7814
- }));
7963
+ }, _requestUrls(req))));
7815
7964
  });
7816
7965
 
7817
7966
  router.get("/collections/:slug", async function (req, res) {
@@ -7835,9 +7984,9 @@ function mount(router, deps) {
7835
7984
  if (card) products.push(card);
7836
7985
  }
7837
7986
  var cartCount = await _cartCountForReq(req);
7838
- _send(res, 200, renderCollection({
7987
+ _send(res, 200, renderCollection(Object.assign({
7839
7988
  collection: col, products: products, shop_name: shopName, cart_count: cartCount,
7840
- }));
7989
+ }, _requestUrls(req))));
7841
7990
  });
7842
7991
  }
7843
7992
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.16",
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": {