@blamejs/blamejs-shop 0.3.31 → 0.3.33

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.33 (2026-05-31) — **Create and manage customer segments from the admin console.** Customer segments could be shown on a customer's profile but there was no way to define or manage them, so the feature could not actually be used. A new Segments screen in the admin console creates rule-based segments from recency, frequency, lifetime-order, spend, average-order-value, and refund-rate criteria, edits their rules, archives and restores them, and recomputes membership on demand — showing how many customers each segment now contains. Segment membership stays rule-derived: it is recalculated from order history, not assigned by hand. **Added:** *Customer segments administration* — A Segments screen in the admin console manages rule-based customer segments. Each segment is defined by RFM-style criteria — how recently and how often a customer orders, their lifetime order count and spend, average order value, and refund rate — and a list shows every segment with its rules, current member count, and status, filterable by active or archived. You can create and edit segments, archive and restore them, and recompute membership on demand, which re-evaluates the rules against order history and reports how many customers match. Membership is always derived from the rules, never assigned per customer.
12
+
13
+ - 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.
14
+
11
15
  - 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
16
 
13
17
  - 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/admin.js CHANGED
@@ -516,7 +516,7 @@ function mount(router, deps) {
516
516
  // `reports` is always present in the nav (read-only sales summary needs no
517
517
  // extra dep); its route mounts unconditionally and renders an unconfigured
518
518
  // notice when the salesReports primitive isn't wired.
519
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets };
519
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets };
520
520
 
521
521
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
522
522
 
@@ -7113,6 +7113,305 @@ function mount(router, deps) {
7113
7113
  }
7114
7114
  }
7115
7115
 
7116
+ // ---- customer segments ----------------------------------------------
7117
+ //
7118
+ // Operator-defined RFM-style groupings ("VIPs", "lapsed-90d",
7119
+ // "high-refund-rate") used to target campaigns. A segment is a slug +
7120
+ // title + a bag of rule predicates ANDed together; membership is
7121
+ // RULE-DERIVED — the recompute action re-evaluates the rules against the
7122
+ // live order history and rewrites the membership cache. This console
7123
+ // manages the segment DEFINITIONS + triggers a recompute; it never
7124
+ // hand-assigns individual customers (the customer-detail screen shows
7125
+ // the segments a customer sits in, read-only). The structured create /
7126
+ // edit form covers the numeric RFM predicates (recency / frequency /
7127
+ // monetary / AOV / refund-rate); the list-valued predicates
7128
+ // (last_order_status_in / country_in / currency_in) and any future rule
7129
+ // keys pass through the bearer JSON surface untouched. Every rule is
7130
+ // validated by composing defineSegment / update — the form translator
7131
+ // never persists a rules_json blob straight from the body, so a bad /
7132
+ // missing rule throws a TypeError the routes map to a clean 400.
7133
+ if (customerSegments) {
7134
+ // Translate the structured create form (or a bearer JSON body) into a
7135
+ // defineSegment input. A body already carrying an object-shaped `rules`
7136
+ // is a bearer client passing the full vocabulary — forward untouched so
7137
+ // the primitive's validator owns it. Otherwise build `rules` from the
7138
+ // numeric RFM fields the console exposes; an empty field is omitted (a
7139
+ // segment needs at least one predicate, which defineSegment enforces).
7140
+ function _segmentInput(body) {
7141
+ if (body.rules && typeof body.rules === "object") {
7142
+ return body; // full JSON shape from a bearer client
7143
+ }
7144
+ return {
7145
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
7146
+ title: body.title,
7147
+ description: body.description,
7148
+ rules: _segmentRules(body),
7149
+ };
7150
+ }
7151
+
7152
+ // Translate the edit form (or a bearer JSON patch) into an update patch.
7153
+ // Title / description forward when present; the rules are re-collected
7154
+ // from the same numeric RFM fields the create form uses, but only when
7155
+ // the form declared it via the hidden `rules_present` marker — so a
7156
+ // title-only edit doesn't wipe the rule set. A bearer client posting an
7157
+ // object-shaped `rules` forwards it verbatim for the primitive to validate.
7158
+ function _segmentPatch(body) {
7159
+ var patch = {};
7160
+ if (Object.prototype.hasOwnProperty.call(body, "title") && body.title !== "") patch.title = body.title;
7161
+ if (Object.prototype.hasOwnProperty.call(body, "description")) patch.description = body.description;
7162
+ if (body.rules && typeof body.rules === "object") {
7163
+ patch.rules = body.rules;
7164
+ } else if (body.rules_present === "1") {
7165
+ patch.rules = _segmentRules(body);
7166
+ }
7167
+ return patch;
7168
+ }
7169
+
7170
+ // Build the `rules` object from the structured form's numeric RFM
7171
+ // fields. Each field is optional; a blank field is left out. The values
7172
+ // pass into defineSegment / update verbatim, which validate every key
7173
+ // (non-negative integers, bps caps, min ≤ max coherence) and throw a
7174
+ // TypeError on a bad / empty rule set — surfaced as a clean 400. This
7175
+ // translator never writes the rule itself; it only shapes the typed
7176
+ // bag the primitive's validator owns.
7177
+ function _segmentRules(body) {
7178
+ var rules = {};
7179
+ var NUMERIC = [
7180
+ "recency_days_max", "recency_days_min",
7181
+ "frequency_orders_min", "lifetime_orders_min", "lifetime_orders_max",
7182
+ "monetary_minor_min", "monetary_minor_max", "aov_minor_min",
7183
+ "refund_rate_bps_max", "refund_rate_bps_min",
7184
+ ];
7185
+ for (var i = 0; i < NUMERIC.length; i += 1) {
7186
+ var k = NUMERIC[i];
7187
+ if (body[k] != null && body[k] !== "") {
7188
+ rules[k] = _strictMinorInt(body[k], "customerSegments", k);
7189
+ }
7190
+ }
7191
+ return rules;
7192
+ }
7193
+
7194
+ // One-line human summary of a segment's rules for the list/detail.
7195
+ function _segmentSummary(seg) {
7196
+ var rules = (seg && seg.rules) || {};
7197
+ var keys = Object.keys(rules);
7198
+ if (!keys.length) return "—";
7199
+ return keys.map(function (k) {
7200
+ var v = rules[k];
7201
+ return k + " " + (Array.isArray(v) ? v.join("/") : String(v));
7202
+ }).join(" · ");
7203
+ }
7204
+
7205
+ async function _renderSegments(flags) {
7206
+ flags = flags || {};
7207
+ var url = flags.url || null;
7208
+ var filter = url && url.searchParams.get("filter");
7209
+ var includeArchived = filter === "all" || filter === "archived";
7210
+ var all = await customerSegments.listSegments({ include_archived: includeArchived });
7211
+ var segments = all;
7212
+ if (filter === "archived") {
7213
+ segments = all.filter(function (s) { return s.archived_at != null; });
7214
+ } else if (filter !== "all") {
7215
+ // default = active only (listSegments already excluded archived).
7216
+ segments = all;
7217
+ }
7218
+ return renderAdminSegments(Object.assign({
7219
+ shop_name: deps.shop_name, nav_available: navAvailable,
7220
+ segments: segments, filter: filter || "active", summarize: _segmentSummary,
7221
+ }, flags));
7222
+ }
7223
+
7224
+ // List — every segment with its rule summary, member count, and archived
7225
+ // state; an All/Active/Archived filter; a clean empty state. Content-
7226
+ // negotiated: bearer → the JSON array; browser cookie → the rendered table.
7227
+ router.get("/admin/segments", _pageOrApi(true,
7228
+ R(async function (req, res) {
7229
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7230
+ var includeArchived = url && (url.searchParams.get("filter") === "all" || url.searchParams.get("filter") === "archived");
7231
+ var segments = await customerSegments.listSegments({ include_archived: !!includeArchived });
7232
+ _json(res, 200, { segments: segments });
7233
+ }),
7234
+ async function (req, res) {
7235
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7236
+ _sendHtml(res, 200, await _renderSegments({
7237
+ url: url,
7238
+ created: url && url.searchParams.get("created"),
7239
+ updated: url && url.searchParams.get("updated"),
7240
+ archived: url && url.searchParams.get("archived"),
7241
+ unarchived: url && url.searchParams.get("unarchived"),
7242
+ recomputed: url && url.searchParams.get("recomputed"),
7243
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
7244
+ }));
7245
+ },
7246
+ ));
7247
+
7248
+ // New-segment form — its own GET so the create page survives a bad
7249
+ // submit's err redirect with the operator's context intact.
7250
+ router.get("/admin/segments/new", _pageOrApi(true,
7251
+ R(async function (_req, res) {
7252
+ // Bearer clients don't need a form; hand back the rule vocabulary so
7253
+ // tooling knows what a valid create body accepts.
7254
+ _json(res, 200, {
7255
+ known_rule_keys: customerSegments.KNOWN_RULE_KEYS,
7256
+ allowed_order_statuses: customerSegments.ALLOWED_ORDER_STATUSES,
7257
+ });
7258
+ }),
7259
+ async function (req, res) {
7260
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7261
+ _sendHtml(res, 200, renderAdminSegmentNew({
7262
+ shop_name: deps.shop_name, nav_available: navAvailable,
7263
+ notice: (url && url.searchParams.get("err")) ? "Check the segment's slug and rules — at least one rule is required." : null,
7264
+ }));
7265
+ },
7266
+ ));
7267
+
7268
+ // Create — composes defineSegment, which validates the slug + rules and
7269
+ // throws a TypeError (→ 400) on a bad / missing rule. The bearer path
7270
+ // returns 201 + the created segment; the browser path PRGs to the list.
7271
+ router.post("/admin/segments", _pageOrApi(false,
7272
+ W("customer_segment.create", async function (req, res) {
7273
+ var seg = await customerSegments.defineSegment(_segmentInput(req.body || {}));
7274
+ _json(res, 201, seg);
7275
+ return { id: seg.slug };
7276
+ }),
7277
+ async function (req, res) {
7278
+ try {
7279
+ await customerSegments.defineSegment(_segmentInput(req.body || {}));
7280
+ } catch (e) {
7281
+ // A bad / missing rule (TypeError) or a duplicate slug
7282
+ // (CUSTOMER_SEGMENT_SLUG_EXISTS) is operator input — bounce back to
7283
+ // the create form's err state, never a 500. _safeNotice records an
7284
+ // unknown failure server-side and yields a 5xx only for a genuine
7285
+ // fault.
7286
+ var n = _safeNotice(e, "customer_segment.create");
7287
+ if (n.status >= 500) throw e;
7288
+ return _redirect(res, "/admin/segments/new?err=1");
7289
+ }
7290
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer_segment.create", outcome: "success" });
7291
+ _redirect(res, "/admin/segments?created=1");
7292
+ },
7293
+ ));
7294
+
7295
+ // Resolve a :slug to its segment. A malformed slug throws inside
7296
+ // listSegments' slug guard chain — but here we read via listSegments and
7297
+ // match, so an unknown slug simply yields null → a clean 404.
7298
+ async function _segmentBySlug(slug) {
7299
+ // stats() is the cheapest single-segment read that also throws a
7300
+ // TypeError on a malformed slug; pair it with the full hydrate via the
7301
+ // list so the detail screen has the rules + archived state.
7302
+ var all = await customerSegments.listSegments({ include_archived: true });
7303
+ for (var i = 0; i < all.length; i += 1) {
7304
+ if (all[i].slug === slug) return all[i];
7305
+ }
7306
+ return null;
7307
+ }
7308
+
7309
+ // Detail — the single segment + its current member count + stats, and a
7310
+ // full edit form (title / description / rules). Content-negotiated.
7311
+ router.get("/admin/segments/:slug", _pageOrApi(true,
7312
+ R(async function (req, res) {
7313
+ var seg = await _segmentBySlug(req.params.slug);
7314
+ if (!seg) return _problem(res, 404, "customer-segment-not-found");
7315
+ var stats = await customerSegments.stats(seg.slug);
7316
+ _json(res, 200, Object.assign({}, seg, { stats: stats }));
7317
+ }),
7318
+ async function (req, res) {
7319
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7320
+ var seg = await _segmentBySlug(req.params.slug);
7321
+ if (!seg) return _sendHtml(res, 404, await _renderSegments({ url: url, notice: "Segment not found." }));
7322
+ var stats = null;
7323
+ try { stats = await customerSegments.stats(seg.slug); }
7324
+ catch (_e) { stats = null; }
7325
+ _sendHtml(res, 200, renderAdminSegment({
7326
+ shop_name: deps.shop_name, nav_available: navAvailable,
7327
+ segment: seg, stats: stats, summary: _segmentSummary(seg),
7328
+ updated: url && url.searchParams.get("updated"),
7329
+ recomputed: url && url.searchParams.get("recomputed"),
7330
+ recomputed_count: url && url.searchParams.get("members"),
7331
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the segment." : null,
7332
+ }));
7333
+ },
7334
+ ));
7335
+
7336
+ // Edit — composes update, which re-validates the rules and throws on a
7337
+ // bad set. A terms edit returns to the detail screen on error so the
7338
+ // operator sees their input in context.
7339
+ router.post("/admin/segments/:slug/edit", _pageOrApi(false,
7340
+ W("customer_segment.update", async function (req, res) {
7341
+ var seg;
7342
+ try { seg = await customerSegments.update(req.params.slug, _segmentPatch(req.body || {})); }
7343
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7344
+ _json(res, 200, seg);
7345
+ return { id: seg.slug };
7346
+ }),
7347
+ async function (req, res) {
7348
+ var slug = req.params.slug;
7349
+ var enc = encodeURIComponent(slug);
7350
+ try { await customerSegments.update(slug, _segmentPatch(req.body || {})); }
7351
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/segments/" + enc + "?err=1"); }
7352
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer_segment.update", outcome: "success", metadata: { slug: slug } });
7353
+ _redirect(res, "/admin/segments/" + enc + "?updated=1");
7354
+ },
7355
+ ));
7356
+
7357
+ // Recompute — re-evaluate this segment's rules against the live order
7358
+ // history and rewrite its membership cache. Surfaces the resulting member
7359
+ // count in the PRG banner (this is the action that populates a freshly
7360
+ // defined or edited segment).
7361
+ router.post("/admin/segments/:slug/recompute", _pageOrApi(false,
7362
+ W("customer_segment.recompute", async function (req, res) {
7363
+ var report;
7364
+ try { report = await customerSegments.recompute({ slugs: [req.params.slug] }); }
7365
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7366
+ var count = report.per_segment[req.params.slug];
7367
+ _json(res, 200, { slug: req.params.slug, member_count: count == null ? 0 : count, report: report });
7368
+ return { id: req.params.slug };
7369
+ }),
7370
+ async function (req, res) {
7371
+ var slug = req.params.slug;
7372
+ var enc = encodeURIComponent(slug);
7373
+ var report;
7374
+ try { report = await customerSegments.recompute({ slugs: [slug] }); }
7375
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/segments/" + enc + "?err=1"); }
7376
+ var count = report.per_segment[slug];
7377
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer_segment.recompute", outcome: "success", metadata: { slug: slug } });
7378
+ _redirect(res, "/admin/segments/" + enc + "?recomputed=1&members=" + encodeURIComponent(String(count == null ? 0 : count)));
7379
+ },
7380
+ ));
7381
+
7382
+ router.post("/admin/segments/:slug/archive", _pageOrApi(false,
7383
+ W("customer_segment.archive", async function (req, res) {
7384
+ var seg;
7385
+ try { seg = await customerSegments.archive(req.params.slug); }
7386
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7387
+ _json(res, 200, seg);
7388
+ return { id: seg.slug };
7389
+ }),
7390
+ async function (req, res) {
7391
+ try { await customerSegments.archive(req.params.slug); }
7392
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/segments?err=1"); }
7393
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer_segment.archive", outcome: "success", metadata: { slug: req.params.slug } });
7394
+ _redirect(res, "/admin/segments?archived=1");
7395
+ },
7396
+ ));
7397
+
7398
+ router.post("/admin/segments/:slug/unarchive", _pageOrApi(false,
7399
+ W("customer_segment.unarchive", async function (req, res) {
7400
+ var seg;
7401
+ try { seg = await customerSegments.unarchive(req.params.slug); }
7402
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7403
+ _json(res, 200, seg);
7404
+ return { id: seg.slug };
7405
+ }),
7406
+ async function (req, res) {
7407
+ try { await customerSegments.unarchive(req.params.slug); }
7408
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/segments?err=1"); }
7409
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer_segment.unarchive", outcome: "success", metadata: { slug: req.params.slug } });
7410
+ _redirect(res, "/admin/segments?unarchived=1");
7411
+ },
7412
+ ));
7413
+ }
7414
+
7116
7415
  // ---- quantity discounts ---------------------------------------------
7117
7416
  // Tier-set CRUD for automatic per-line quantity breaks ("buy 5, save
7118
7417
  // 10%"). The pricing engine already applies these at PDP / cart /
@@ -8347,6 +8646,7 @@ var ADMIN_NAV_ITEMS = [
8347
8646
  { key: "orders", href: "/admin/orders", label: "Orders" },
8348
8647
  { key: "reports", href: "/admin/reports", label: "Reports" },
8349
8648
  { key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
8649
+ { key: "segments", href: "/admin/segments", label: "Segments", requires: "customerSegments" },
8350
8650
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
8351
8651
  { key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
8352
8652
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
@@ -11036,6 +11336,192 @@ function renderAdminDiscounts(opts) {
11036
11336
  return _renderAdminShell(opts.shop_name, "Discounts", body, "discounts", opts.nav_available);
11037
11337
  }
11038
11338
 
11339
+ // The numeric RFM rule fields the structured segment form collects. Each
11340
+ // maps 1:1 to a customer-segments rule key; a blank field is omitted from
11341
+ // the rules object, and the backend validates whatever's present (rejecting
11342
+ // an empty rule set, a bad integer, or an out-of-range basis-point value).
11343
+ var SEGMENT_RULE_FIELDS = [
11344
+ { name: "frequency_orders_min", label: "Min lifetime orders (frequency)", hint: "Customer has placed at least this many paid orders.", extra: " min=\"0\"" },
11345
+ { name: "lifetime_orders_max", label: "Max lifetime orders", hint: "At most this many orders — gate \"new\" buyers.", extra: " min=\"0\"" },
11346
+ { name: "recency_days_max", label: "Ordered within N days (recency)", hint: "Last order ≤ N days ago — recent buyers.", extra: " min=\"0\"" },
11347
+ { name: "recency_days_min", label: "Lapsed for N+ days", hint: "Last order ≥ N days ago — the lapsed half of RFM.", extra: " min=\"0\"" },
11348
+ { name: "monetary_minor_min", label: "Min lifetime spend (minor)", hint: "Lifetime gross ≥ N in the smallest currency unit.", extra: " min=\"0\"" },
11349
+ { name: "monetary_minor_max", label: "Max lifetime spend (minor)", hint: "Lifetime gross ≤ N.", extra: " min=\"0\"" },
11350
+ { name: "aov_minor_min", label: "Min average order value (minor)", hint: "Average order value ≥ N.", extra: " min=\"0\"" },
11351
+ { name: "refund_rate_bps_max", label: "Max refund rate (bps)", hint: "Refunds ÷ orders ≤ N basis points (10000 = 100%).", extra: " min=\"0\" max=\"10000\"" },
11352
+ { name: "refund_rate_bps_min", label: "Min refund rate (bps)", hint: "Refunds ÷ orders ≥ N basis points — catch high-refund customers.", extra: " min=\"0\" max=\"10000\"" },
11353
+ ];
11354
+
11355
+ // Status pill for a segment's lifecycle: active = green (paid), archived =
11356
+ // neutral-off (cancelled). Reuses the shared order-pill classes.
11357
+ function _segmentPill(seg) {
11358
+ var archived = seg && seg.archived_at != null;
11359
+ return "<span class=\"status-pill " + (archived ? "cancelled" : "paid") + "\">" +
11360
+ (archived ? "archived" : "active") + "</span>";
11361
+ }
11362
+
11363
+ // Render the numeric RFM rule field set, each prefilled from the supplied
11364
+ // rules object (empty when creating). The list-valued predicates
11365
+ // (last_order_status_in / country_in / currency_in) and any future rule key
11366
+ // stay on the bearer JSON surface — documented in the form's help text.
11367
+ function _segmentRuleFields(rules) {
11368
+ rules = rules || {};
11369
+ return SEGMENT_RULE_FIELDS.map(function (f) {
11370
+ var v = rules[f.name] != null ? String(rules[f.name]) : "";
11371
+ return _setupField(f.label, f.name, v, "number", f.hint, f.extra);
11372
+ }).join("");
11373
+ }
11374
+
11375
+ function renderAdminSegments(opts) {
11376
+ opts = opts || {};
11377
+ var segments = opts.segments || [];
11378
+ var summarize = typeof opts.summarize === "function" ? opts.summarize : function () { return "—"; };
11379
+ var filter = opts.filter || "active";
11380
+ var created = opts.created ? "<div class=\"banner banner--ok\">Segment created.</div>" : "";
11381
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Segment updated.</div>" : "";
11382
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Segment archived.</div>" : "";
11383
+ var unarchived = opts.unarchived ? "<div class=\"banner banner--ok\">Segment unarchived.</div>" : "";
11384
+ var recomputed = opts.recomputed ? "<div class=\"banner banner--ok\">Membership recomputed.</div>" : "";
11385
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
11386
+
11387
+ var FILTERS = [
11388
+ { value: "active", label: "Active" },
11389
+ { value: "all", label: "All" },
11390
+ { value: "archived", label: "Archived" },
11391
+ ];
11392
+ var chips = "<div class=\"order-filters\">" + FILTERS.map(function (fl) {
11393
+ var href = fl.value === "active" ? "/admin/segments" : "/admin/segments?filter=" + fl.value;
11394
+ return "<a class=\"chip" + (filter === fl.value ? " chip--on" : "") + "\" href=\"" + href + "\">" + _htmlEscape(fl.label) + "</a>";
11395
+ }).join("") + "</div>";
11396
+
11397
+ var rows = segments.map(function (s) {
11398
+ var enc = encodeURIComponent(s.slug);
11399
+ var isArchived = s.archived_at != null;
11400
+ var actions =
11401
+ "<a class=\"btn btn--ghost\" href=\"/admin/segments/" + _htmlEscape(enc) + "\">Manage</a> " +
11402
+ (isArchived
11403
+ ? "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/unarchive\" class=\"form-inline\">" +
11404
+ "<button class=\"btn btn--ghost\" type=\"submit\">Unarchive</button></form>"
11405
+ : "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/recompute\" class=\"form-inline\">" +
11406
+ "<button class=\"btn btn--ghost\" type=\"submit\">Recompute</button></form> " +
11407
+ "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
11408
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>");
11409
+ return "<tr>" +
11410
+ "<td><a href=\"/admin/segments/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(s.title) + "</strong></a><br><code class=\"order-id\">" + _htmlEscape(s.slug) + "</code></td>" +
11411
+ "<td>" + _htmlEscape(summarize(s)) + "</td>" +
11412
+ "<td class=\"num\">" + _htmlEscape(String(s.last_member_count == null ? 0 : s.last_member_count)) + "</td>" +
11413
+ "<td>" + _segmentPill(s) + "</td>" +
11414
+ "<td><div class=\"actions-row\">" + actions + "</div></td>" +
11415
+ "</tr>";
11416
+ }).join("");
11417
+
11418
+ var table = segments.length
11419
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Segment</th><th scope=\"col\">Rules</th><th scope=\"col\" class=\"num\">Members</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
11420
+ : "<p class=\"empty\">No " + (filter === "archived" ? "archived " : (filter === "all" ? "" : "active ")) + "segments yet. Define one to start grouping customers for campaigns.</p>";
11421
+
11422
+ var body = "<section><h2>Customer segments</h2>" + created + updated + archived + unarchived + recomputed + notice +
11423
+ "<p class=\"meta\">Rule-derived groupings for targeted campaigns. Membership is recomputed from order history — define a segment, then recompute to populate it.</p>" +
11424
+ chips + table +
11425
+ "<div class=\"actions-row mt\"><a class=\"btn\" href=\"/admin/segments/new\">Define a segment</a></div>" +
11426
+ "</section>";
11427
+ return _renderAdminShell(opts.shop_name, "Segments", body, "segments", opts.nav_available);
11428
+ }
11429
+
11430
+ function renderAdminSegmentNew(opts) {
11431
+ opts = opts || {};
11432
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
11433
+ var form =
11434
+ "<div class=\"panel mw-42\">" +
11435
+ "<h3 class=\"subhead\">Define a segment</h3>" +
11436
+ "<p class=\"meta\">Fill at least one rule — every rule must hold for a customer to land in the segment. Leave a rule blank to skip it. Members are populated by a recompute after the segment is saved.</p>" +
11437
+ "<form method=\"post\" action=\"/admin/segments\">" +
11438
+ _setupField("Slug", "slug", "", "text", "Stable handle, e.g. vip-buyers.", " maxlength=\"64\" required") +
11439
+ _setupField("Title", "title", "", "text", "Operator-facing name, e.g. VIP buyers.", " maxlength=\"200\" required") +
11440
+ _setupField("Description (optional)", "description", "", "text", "What this segment is for.", " maxlength=\"1000\"") +
11441
+ "<h3 class=\"subhead subhead--sp-lg\">Rules (all must hold)</h3>" +
11442
+ _segmentRuleFields({}) +
11443
+ "<p class=\"meta\">List-valued rules (last order status / country / currency) are available through the JSON API.</p>" +
11444
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create segment</button>" +
11445
+ "<a class=\"btn btn--ghost\" href=\"/admin/segments\">Cancel</a></div>" +
11446
+ "</form>" +
11447
+ "</div>";
11448
+ var body = "<section><h2>Define a segment</h2>" + notice + form + "</section>";
11449
+ return _renderAdminShell(opts.shop_name, "New segment", body, "segments", opts.nav_available);
11450
+ }
11451
+
11452
+ function renderAdminSegment(opts) {
11453
+ opts = opts || {};
11454
+ var s = opts.segment;
11455
+ if (!s) {
11456
+ var nf = "<section><h2>Segment</h2><p class=\"empty\">Segment not found.</p>" +
11457
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/segments\">Back to segments</a></div></section>";
11458
+ return _renderAdminShell(opts.shop_name, "Segment", nf, "segments", opts.nav_available);
11459
+ }
11460
+ var stats = opts.stats || null;
11461
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Segment updated.</div>" : "";
11462
+ var recomputed = opts.recomputed
11463
+ ? "<div class=\"banner banner--ok\">Membership recomputed — " + _htmlEscape(String(opts.recomputed_count == null ? 0 : opts.recomputed_count)) + " member(s).</div>"
11464
+ : "";
11465
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
11466
+ var isArchived = s.archived_at != null;
11467
+ var enc = encodeURIComponent(s.slug);
11468
+
11469
+ var memberCount = stats && stats.member_count != null ? stats.member_count : (s.last_member_count == null ? 0 : s.last_member_count);
11470
+ var lastRecomputed = stats && stats.last_recomputed_at != null ? _fmtDate(stats.last_recomputed_at)
11471
+ : (s.last_recomputed_at != null ? _fmtDate(s.last_recomputed_at) : "never");
11472
+
11473
+ var statCards =
11474
+ "<div class=\"stat-grid\">" +
11475
+ _statCard("Members", String(memberCount)) +
11476
+ _statCard("Avg lifetime (minor)", stats && stats.avg_lifetime_minor != null ? String(stats.avg_lifetime_minor) : "0") +
11477
+ _statCard("Last recomputed", lastRecomputed) +
11478
+ "</div>";
11479
+
11480
+ var head = "<div class=\"panel\"><dl class=\"detail-grid\">" +
11481
+ "<div><dt>Segment</dt><dd><strong>" + _htmlEscape(s.title) + "</strong><br><code class=\"order-id\">" + _htmlEscape(s.slug) + "</code></dd></div>" +
11482
+ "<div><dt>Rules</dt><dd>" + _htmlEscape(opts.summary || "—") + "</dd></div>" +
11483
+ (s.description ? "<div><dt>Description</dt><dd>" + _htmlEscape(s.description) + "</dd></div>" : "") +
11484
+ "<div><dt>Status</dt><dd>" + _segmentPill(s) + "</dd></div>" +
11485
+ "</dl></div>";
11486
+
11487
+ // Recompute is the action that makes the rules actually populate the
11488
+ // membership cache — surface it prominently for an active segment.
11489
+ var recomputeForm = isArchived ? "" :
11490
+ "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/recompute\" class=\"form-inline\">" +
11491
+ "<button class=\"btn\" type=\"submit\">Recompute membership</button>" +
11492
+ "</form>";
11493
+
11494
+ var editForm = isArchived
11495
+ ? "<p class=\"empty\">This segment is archived. Unarchive it to edit or recompute.</p>" +
11496
+ "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/unarchive\" class=\"form-inline\">" +
11497
+ "<button class=\"btn\" type=\"submit\">Unarchive</button></form>"
11498
+ : "<div class=\"panel mt mw-42\">" +
11499
+ "<h3 class=\"subhead\">Edit segment</h3>" +
11500
+ "<p class=\"meta\">Change the title, description, or rules. Editing the rules takes effect on the next recompute.</p>" +
11501
+ "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/edit\">" +
11502
+ "<input type=\"hidden\" name=\"rules_present\" value=\"1\">" +
11503
+ _setupField("Title", "title", s.title, "text", "Operator-facing name.", " maxlength=\"200\"") +
11504
+ _setupField("Description", "description", s.description || "", "text", "What this segment is for.", " maxlength=\"1000\"") +
11505
+ "<h3 class=\"subhead subhead--sp-lg\">Rules (all must hold)</h3>" +
11506
+ _segmentRuleFields(s.rules) +
11507
+ "<p class=\"meta\">Leaving every rule blank is rejected — a segment needs at least one rule. List-valued rules stay on the JSON API.</p>" +
11508
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
11509
+ "</form>" +
11510
+ "</div>";
11511
+
11512
+ var archiveForm = isArchived ? "" :
11513
+ "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
11514
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive segment</button></form>";
11515
+
11516
+ var body = "<section><h2>" + _htmlEscape(s.title) + "</h2>" + updated + recomputed + notice +
11517
+ head + statCards +
11518
+ "<div class=\"actions-row mt\">" + recomputeForm + archiveForm + "</div>" +
11519
+ editForm +
11520
+ "<div class=\"actions-row mt\"><a class=\"btn btn--ghost\" href=\"/admin/segments\">Back to segments</a></div>" +
11521
+ "</section>";
11522
+ return _renderAdminShell(opts.shop_name, "Segment " + s.slug, body, "segments", opts.nav_available);
11523
+ }
11524
+
11039
11525
  function renderAdminGiftCards(opts) {
11040
11526
  opts = opts || {};
11041
11527
  var rows = opts.cards || [];
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.31",
2
+ "version": "0.3.33",
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.33",
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": {