@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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/collections.js +13 -5
- package/lib/storefront.js +126 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.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.
|
package/lib/asset-manifest.json
CHANGED
package/lib/collections.js
CHANGED
|
@@ -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
|
|
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 &&
|
|
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:
|
|
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()
|
|
8906
|
-
//
|
|
8907
|
-
// 500 — the route is a defensive
|
|
8908
|
-
|
|
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