@blamejs/blamejs-shop 0.3.31 → 0.3.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.32 (2026-05-31) — **Collection pages now page through every product instead of stopping at 24.** A collection page showed only its first 24 products and provided no way to reach the rest, so any product past the 24th in a collection was silently unreachable. Collection pages now have previous/next pagination, matching the search results, so a collection of any size can be browsed in full. A shared link to a specific page keeps its position, while the page's canonical URL stays the plain collection address. **Fixed:** *Collection pages paginate* — A collection with more than 24 products was truncated at the first 24 with no way to see the others. Collection pages now carry previous/next pagination — the same control used on search results — so every product in a collection is reachable. The canonical URL remains the bare collection address on every page (so search engines see one collection page, not duplicates), while a shared link to a deeper page preserves its position.
12
+
11
13
  - v0.3.31 (2026-05-31) — **A public help center, authored from the admin console.** The knowledge-base backend was built but had no pages, so there was no way to publish help content or for a shopper to read it. There is now a Help center at /help: a browsable index of published articles grouped by category, and an article page that renders the article, shows a breadcrumb trail in search results, and lets a reader mark whether it was helpful. Operators get a Help center screen in the admin console to write articles, save them as drafts, publish them, and archive them. Article text is written in a small Markdown subset and is fully escaped on the page, and only published, non-archived articles are publicly reachable. **Added:** *Help center for shoppers* — A new /help section lists published articles by category and renders each article at /help/:slug with a breadcrumb (carried into search results as BreadcrumbList structured data) and a "Was this helpful?" control. Article bodies are written in a Markdown subset and escaped on output, and a draft, archived, or unknown article returns a clean 404 — only published articles are ever publicly reachable. · *Help center authoring in the admin console* — A Help center screen lets operators write articles (slug, title, category, body), keep them as drafts, publish or unpublish them, and archive ones that should no longer appear. The list filters by all/published/drafts and shows each article's view count and helpfulness tally, so the team can see what readers are using and which articles need work.
12
14
 
13
15
  - v0.3.30 (2026-05-31) — **An unrecognized automatic-discount type is now rejected instead of silently becoming free shipping.** When defining or editing an automatic discount over the API, an unrecognized discount type was silently turned into a free-shipping rule — the most generous kind — and accepted without any error. A discount sent over the API with a type outside the supported set (percent off, amount off the total, amount off each item, buy-X-get-Y, or free shipping) is now rejected with a clear error, so a typo can no longer create a store-wide free-shipping rule by accident. The admin form was never affected, because its type selector only offers the supported values. **Fixed:** *Automatic-discount type is validated* — Defining or editing an automatic discount with a discount type outside the supported set now returns a clear error instead of quietly defaulting to free shipping. Previously an unrecognized type sent over the JSON API was coerced into a free-shipping rule and saved with no warning, which could apply free shipping store-wide from a single typo. Free shipping remains a valid, explicitly chosen type; only unrecognized values are rejected. The admin console form was unaffected, since its selector is limited to the supported types.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.31",
2
+ "version": "0.3.32",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -753,21 +753,29 @@ function create(opts) {
753
753
  throw new TypeError("collections.productsIn: cursor — " + (e && e.message || "malformed"));
754
754
  }
755
755
  }
756
+ // Fetch one row beyond the page so the next cursor is emitted ONLY
757
+ // when a member past this page actually exists. Keying the cursor off
758
+ // a full page alone (rows.length === limit) advertises a phantom next
759
+ // page when the collection size is an exact multiple of the limit —
760
+ // following it lands on an empty page. The smart path peeks the same
761
+ // way (startIdx + slice.length < matched.length).
756
762
  var sql, params;
757
763
  if (cursorVals) {
758
764
  sql = "SELECT * FROM collection_members WHERE collection_slug = ?1 AND " +
759
765
  "(position > ?2 OR (position = ?2 AND id > ?3)) " +
760
766
  "ORDER BY position ASC, id ASC LIMIT ?4";
761
- params = [input.slug, cursorVals[0], cursorVals[1], limit];
767
+ params = [input.slug, cursorVals[0], cursorVals[1], limit + 1];
762
768
  } else {
763
769
  sql = "SELECT * FROM collection_members WHERE collection_slug = ?1 " +
764
770
  "ORDER BY position ASC, id ASC LIMIT ?2";
765
- params = [input.slug, limit];
771
+ params = [input.slug, limit + 1];
766
772
  }
767
773
  var r = await query(sql, params);
768
- var lastM = r.rows[r.rows.length - 1];
774
+ var hasMore = r.rows.length > limit;
775
+ var pageRows = hasMore ? r.rows.slice(0, limit) : r.rows;
776
+ var lastM = pageRows[pageRows.length - 1];
769
777
  var nextM = null;
770
- if (lastM && r.rows.length === limit) {
778
+ if (lastM && hasMore) {
771
779
  nextM = b.pagination.encodeCursor({
772
780
  orderKey: MEMBER_ORDER_KEY,
773
781
  vals: [lastM.position, lastM.id],
@@ -777,7 +785,7 @@ function create(opts) {
777
785
  return {
778
786
  type: "manual",
779
787
  sort_strategy: row.sort_strategy,
780
- rows: r.rows.map(function (mr) {
788
+ rows: pageRows.map(function (mr) {
781
789
  return {
782
790
  id: mr.id,
783
791
  collection_slug: mr.collection_slug,
package/lib/storefront.js CHANGED
@@ -1466,6 +1466,84 @@ function _renderSearchPagination(q, filters, total, page, pageSize) {
1466
1466
  "</nav>\n";
1467
1467
  }
1468
1468
 
1469
+ // The fixed collection page size — the number of product cards one page of
1470
+ // `/collections/:slug` shows. Pages past the first are reached via the
1471
+ // `?cursor=` trail the pagination nav emits. A collection is keyset/offset
1472
+ // paginated by its lib (`collections.productsIn` returns an opaque,
1473
+ // forward-only `next_cursor`); it exposes no total, so the page UI is a
1474
+ // prev/next pair (not the numbered `/search` UI), reusing the same
1475
+ // `search-pagination` shell + `rel="prev"/"next"` so no new CSS ships.
1476
+ var COLLECTION_PAGE_SIZE = 24;
1477
+
1478
+ // Cursor characters are RFC 4648 base64url plus a single `.` tag separator —
1479
+ // no `,`, `+`, `/`, or `=` — so a comma joins a list of page-start cursors
1480
+ // into one URL-safe `?cursor=` value. This is the page trail: page 1 carries
1481
+ // no `cursor`; each Next appends the page's `next_cursor`; each Previous
1482
+ // drops the last entry. The current page starts at the trail's last cursor.
1483
+ var COLLECTION_CURSOR_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
1484
+
1485
+ // Parse the `?cursor=` trail off a parsed URL into an array of opaque
1486
+ // page-start cursors. A missing param is page 1 (empty trail). Each
1487
+ // comma-separated entry must match the base64url`.`base64url cursor shape;
1488
+ // any entry that doesn't is dropped, and a trail longer than a sane bound is
1489
+ // truncated — a defensive request-shape reader that returns a clean trail
1490
+ // (never throws) so a tampered / stale `?cursor=` degrades to a reachable
1491
+ // page rather than a 500. The route additionally retries page 1 if the
1492
+ // lib rejects the resolved start cursor's HMAC.
1493
+ var COLLECTION_MAX_TRAIL = 512;
1494
+ function _parseCollectionCursorTrail(url) {
1495
+ if (!url || !url.searchParams) return [];
1496
+ var raw = url.searchParams.get("cursor");
1497
+ if (raw == null || raw === "") return [];
1498
+ if (raw.length > COLLECTION_MAX_TRAIL * 200) return [];
1499
+ var parts = raw.split(",");
1500
+ var out = [];
1501
+ for (var i = 0; i < parts.length && out.length < COLLECTION_MAX_TRAIL; i += 1) {
1502
+ if (COLLECTION_CURSOR_RE.test(parts[i])) out.push(parts[i]);
1503
+ }
1504
+ return out;
1505
+ }
1506
+
1507
+ // `/collections/:slug` URL for a given cursor trail. An empty trail is the
1508
+ // bare collection page (page 1) so the first page has one canonical URL;
1509
+ // otherwise the trail joins into the `?cursor=` param. Mirrors the search
1510
+ // renderer's `_searchPageUrl` (page 1 omits the param).
1511
+ function _collectionPageUrl(slug, trail) {
1512
+ var base = "/collections/" + encodeURIComponent(slug);
1513
+ if (!trail || !trail.length) return base;
1514
+ return base + "?cursor=" + trail.join(",");
1515
+ }
1516
+
1517
+ // Prev/next pagination for a collection product grid. Reuses the
1518
+ // `search-pagination` shell + `rel="prev"/"next"` + disabled-state spans
1519
+ // so no new CSS ships and the markup matches the search nav. Renders
1520
+ // nothing when there is neither a previous page (empty trail) nor a next
1521
+ // page (`nextCursor == null`) — i.e. a single-page collection stays
1522
+ // byte-identical to the unpaginated render. `trail` is the current page's
1523
+ // cursor trail (the last entry is this page's start); `nextCursor` is the
1524
+ // lib's opaque forward cursor for the following page (null on the last
1525
+ // page).
1526
+ function _renderCollectionPagination(slug, trail, nextCursor) {
1527
+ var esc = b.template.escapeHtml;
1528
+ var hasPrev = trail && trail.length > 0;
1529
+ var hasNext = nextCursor != null && nextCursor !== "";
1530
+ if (!hasPrev && !hasNext) return "";
1531
+ var prevTrail = hasPrev ? trail.slice(0, trail.length - 1) : [];
1532
+ var nextTrail = (trail || []).concat([nextCursor]);
1533
+ var prev = hasPrev
1534
+ ? _render("<a class=\"search-pagination__link search-pagination__prev\" href=\"{{href}}\" rel=\"prev\">Previous</a>\n",
1535
+ { href: esc(_collectionPageUrl(slug, prevTrail)) })
1536
+ : "<span class=\"search-pagination__link search-pagination__prev is-disabled\" aria-disabled=\"true\">Previous</span>\n";
1537
+ var next = hasNext
1538
+ ? _render("<a class=\"search-pagination__link search-pagination__next\" href=\"{{href}}\" rel=\"next\">Next</a>\n",
1539
+ { href: esc(_collectionPageUrl(slug, nextTrail)) })
1540
+ : "<span class=\"search-pagination__link search-pagination__next is-disabled\" aria-disabled=\"true\">Next</span>\n";
1541
+ return "<nav class=\"search-pagination collection-pagination\" aria-label=\"Collection pages\">\n" +
1542
+ prev +
1543
+ next +
1544
+ "</nav>\n";
1545
+ }
1546
+
1469
1547
  // Read the 1-based `?page=N` results page off a parsed URL. A missing,
1470
1548
  // non-integer, or sub-1 value reads as page 1 (the canonical first page);
1471
1549
  // the upper bound is clamped against the real page count by `_clampPage`
@@ -2884,6 +2962,16 @@ function renderCollection(opts) {
2884
2962
  var grid = cards
2885
2963
  ? "<div class=\"catalog-grid collection-grid\">" + cards + "</div>"
2886
2964
  : "<p class=\"collection-empty\">No products in this collection yet.</p>";
2965
+ // Prev/next nav under the grid — the `?cursor=` trail carries the lib's
2966
+ // opaque forward cursor so a collection larger than one page is fully
2967
+ // reachable (the silent 24-cap truncation is the bug this closes). Renders
2968
+ // nothing for a single-page collection (no trail + no next cursor), so the
2969
+ // small-collection render is unchanged.
2970
+ var pagination = _renderCollectionPagination(
2971
+ col.slug,
2972
+ opts.cursor_trail || [],
2973
+ opts.next_cursor == null ? null : opts.next_cursor
2974
+ );
2887
2975
  var body =
2888
2976
  "<section class=\"collection-page\">" +
2889
2977
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -2896,6 +2984,7 @@ function renderCollection(opts) {
2896
2984
  (col.description ? "<p class=\"collection-page__desc\">" + esc(col.description) + "</p>" : "") +
2897
2985
  "</header>" +
2898
2986
  grid +
2987
+ pagination +
2899
2988
  "</section>";
2900
2989
  // BreadcrumbList JSON-LD mirroring the on-page `<nav class="breadcrumb">`
2901
2990
  // trail (Shop → Collections → this collection). Google's result panel
@@ -8902,18 +8991,49 @@ function mount(router, deps) {
8902
8991
 
8903
8992
  router.get("/collections/:slug", async function (req, res) {
8904
8993
  var slug = req.params && req.params.slug;
8905
- // get() and productsIn() both throw on a malformed slug (the
8906
- // primitive validates shape). A bad path segment is a 404, not a
8907
- // 500 — the route is a defensive request-shape reader.
8908
- var col, result;
8994
+ // get() throws a TypeError on a malformed slug (the primitive
8995
+ // validates shape). A bad path segment / unknown / archived
8996
+ // collection is a 404, not a 500 — the route is a defensive
8997
+ // request-shape reader.
8998
+ var col;
8909
8999
  try {
8910
9000
  col = slug ? await deps.collections.get(slug) : null;
8911
9001
  if (!col || col.archived_at != null) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
8912
- result = await deps.collections.productsIn({ slug: slug, limit: 24 });
8913
9002
  } catch (e) {
8914
9003
  if (e instanceof TypeError) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
8915
9004
  throw e;
8916
9005
  }
9006
+
9007
+ // `?cursor=` trail — the comma-joined list of page-start cursors. The
9008
+ // current page starts at the trail's last cursor (page 1 = empty
9009
+ // trail). Parsed defensively (garbage entries dropped); the lib still
9010
+ // HMAC-verifies the resolved start cursor, so a tampered-but-well-
9011
+ // shaped cursor is caught below and falls back to page 1 rather than
9012
+ // 500/404 — matching how `/search` treats a bad `?page=`.
9013
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
9014
+ var trail = _parseCollectionCursorTrail(url);
9015
+ var startCursor = trail.length ? trail[trail.length - 1] : null;
9016
+
9017
+ var result;
9018
+ try {
9019
+ result = await deps.collections.productsIn({ slug: slug, limit: COLLECTION_PAGE_SIZE, cursor: startCursor });
9020
+ } catch (e2) {
9021
+ // A bad/stale/tampered cursor surfaces as a TypeError whose message
9022
+ // names the cursor. Behave like page 1 (a reachable, link-followable
9023
+ // result) instead of a 404 — the collection still exists. Any other
9024
+ // TypeError (an impossible slug change between get() and productsIn)
9025
+ // is a 404; non-TypeErrors propagate.
9026
+ if (e2 instanceof TypeError && /cursor/i.test(e2.message || "")) {
9027
+ trail = [];
9028
+ startCursor = null;
9029
+ result = await deps.collections.productsIn({ slug: slug, limit: COLLECTION_PAGE_SIZE, cursor: null });
9030
+ } else if (e2 instanceof TypeError) {
9031
+ return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
9032
+ } else {
9033
+ throw e2;
9034
+ }
9035
+ }
9036
+
8917
9037
  var products = [];
8918
9038
  for (var i = 0; i < result.rows.length; i += 1) {
8919
9039
  var pid = result.rows[i].product_id || result.rows[i].id;
@@ -8923,6 +9043,7 @@ function mount(router, deps) {
8923
9043
  var cartCount = await _cartCountForReq(req);
8924
9044
  _send(res, 200, renderCollection(Object.assign({
8925
9045
  collection: col, products: products, shop_name: shopName, cart_count: cartCount,
9046
+ cursor_trail: trail, next_cursor: result.next_cursor,
8926
9047
  }, _requestUrls(req))));
8927
9048
  });
8928
9049
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.31",
3
+ "version": "0.3.32",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {