@blamejs/blamejs-shop 0.3.32 → 0.3.34

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.34 (2026-05-31) — **Edit, pin, and archive the internal notes on a customer.** Internal notes on a customer could only be added — a note with a typo or one that was no longer true could not be corrected or removed, which is a liability for notes like "do not ship to this address" or "allergic to X". The customer detail screen now lets you edit a note's text, pin important notes to the top, and archive notes that no longer apply (and restore them). Each action is limited to notes that belong to the customer being viewed. **Added:** *Edit, pin, and archive customer notes* — The notes panel on a customer's detail screen now supports the full lifecycle: edit a note's text to fix or update it, pin the notes that matter so they sort to the top, and archive notes that no longer apply, with a toggle to show archived notes and restore them. Previously notes were append-only, so a wrong or stale note could not be corrected or retired. Every edit, pin, and archive action is scoped to the customer being viewed, so a note can only be changed from the record it belongs to.
12
+
13
+ - 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.
14
+
11
15
  - 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
16
 
13
17
  - 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.
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
 
@@ -2674,8 +2674,13 @@ function mount(router, deps) {
2674
2674
  }
2675
2675
 
2676
2676
  var notes = [];
2677
+ var showArchivedNotes = flags.show_archived_notes === true;
2677
2678
  if (customerNotes) {
2678
- try { notes = (await customerNotes.notesForCustomer({ customer_id: customer.id, limit: 20 })).rows || []; }
2679
+ try {
2680
+ notes = (await customerNotes.notesForCustomer({
2681
+ customer_id: customer.id, limit: 50, include_archived: showArchivedNotes,
2682
+ })).rows || [];
2683
+ }
2679
2684
  catch (_e) { notes = []; }
2680
2685
  }
2681
2686
 
@@ -2698,6 +2703,7 @@ function mount(router, deps) {
2698
2703
  loyalty_link: !!deps.loyalty,
2699
2704
  can_notes: !!customerNotes,
2700
2705
  notes: notes,
2706
+ show_archived_notes: showArchivedNotes,
2701
2707
  can_segments: !!customerSegments,
2702
2708
  segments: segments,
2703
2709
  }, flags);
@@ -2716,8 +2722,13 @@ function mount(router, deps) {
2716
2722
  R(async function (req, res) {
2717
2723
  var c = await _resolveCustomer(req.params.id);
2718
2724
  if (!c) return _problem(res, 404, "customer-not-found");
2719
- // Bearer JSON: the customer record + its satellites (no HTML).
2720
- var model = await _customerDetailModel(c, {});
2725
+ // Bearer JSON: the customer record + its satellites (no HTML). A
2726
+ // tooling client can opt into the archived notes via
2727
+ // ?notes_archived=1, matching the browser detail-screen toggle.
2728
+ var apiUrl = req.url ? new URL(req.url, "http://localhost") : null;
2729
+ var model = await _customerDetailModel(c, {
2730
+ show_archived_notes: !!(apiUrl && apiUrl.searchParams.get("notes_archived") === "1"),
2731
+ });
2721
2732
  _json(res, 200, {
2722
2733
  customer: c,
2723
2734
  recent_orders: model.recent_orders,
@@ -2738,6 +2749,7 @@ function mount(router, deps) {
2738
2749
  saved: url && url.searchParams.get("saved"),
2739
2750
  credit_notice: url && url.searchParams.get("credit_err") ? url.searchParams.get("credit_err") : null,
2740
2751
  note_notice: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
2752
+ show_archived_notes: !!(url && url.searchParams.get("notes_archived") === "1"),
2741
2753
  };
2742
2754
  _sendHtml(res, 200, renderAdminCustomerDetail(await _customerDetailModel(c, flags)));
2743
2755
  },
@@ -2870,6 +2882,104 @@ function mount(router, deps) {
2870
2882
  _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) + "?saved=1");
2871
2883
  },
2872
2884
  ));
2885
+
2886
+ // ---- customer notes (edit / pin / archive lifecycle) ---------------
2887
+ // A note belongs to a customer. customerNotes' update / pin / archive
2888
+ // methods move a row by note id alone — they carry no notion of which
2889
+ // customer the operator is acting on — so every per-note write route
2890
+ // under :id MUST first assert the note belongs to THAT customer before
2891
+ // mutating it, or an operator on customer A's screen could edit / pin /
2892
+ // retire customer B's note by guessing its id (an IDOR). This mirrors
2893
+ // the media setPrimary (_mediaBelongsToProduct) + support thread
2894
+ // (_ownedTicket) scoping: load the note, refuse it unless its
2895
+ // customer_id matches the path id. A malformed note id throws a
2896
+ // TypeError inside getNote's UUID guard → a clean 400; a well-formed
2897
+ // unknown id (or one owned by a different customer) returns null /
2898
+ // false → a clean 404, with nothing written either way.
2899
+ async function _noteBelongsToCustomer(noteId, customerId) {
2900
+ var note;
2901
+ try { note = await customerNotes.getNote(noteId); }
2902
+ catch (e) { if (e instanceof TypeError) throw e; return false; }
2903
+ return (note && note.customer_id === customerId) ? note : false;
2904
+ }
2905
+
2906
+ // Map a per-note write to a content-negotiated outcome. `mutate(note)`
2907
+ // performs the customerNotes call (already ownership-checked); on the
2908
+ // bearer path it returns the updated note as JSON, on the browser path
2909
+ // it PRGs back to the detail screen with a saved / note_err banner.
2910
+ // A malformed note id is a clean 400 (bearer) / note_err redirect
2911
+ // (browser); a missing / cross-customer note is a clean 404 (bearer) /
2912
+ // note_err redirect (browser). Nothing is mutated on any refusal.
2913
+ function _noteWriteRoute(routePath, audit, mutate) {
2914
+ router.post(routePath, _pageOrApi(false,
2915
+ W(audit, async function (req, res) {
2916
+ var c = await _resolveCustomer(req.params.id);
2917
+ if (!c) return _problem(res, 404, "customer-not-found");
2918
+ var owned;
2919
+ try { owned = await _noteBelongsToCustomer(req.params.noteId, c.id); }
2920
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
2921
+ if (!owned) return _problem(res, 404, "note-not-found");
2922
+ var updated;
2923
+ try { updated = await mutate(owned, req.body || {}); }
2924
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
2925
+ _json(res, 200, updated);
2926
+ return { id: c.id };
2927
+ }),
2928
+ async function (req, res) {
2929
+ var c = await _resolveCustomer(req.params.id);
2930
+ if (!c) return _sendHtml(res, 404, renderAdminCustomers({
2931
+ shop_name: deps.shop_name, nav_available: navAvailable, customers: [], notice: "Customer not found.",
2932
+ }));
2933
+ var enc = encodeURIComponent(c.id);
2934
+ var owned;
2935
+ try { owned = await _noteBelongsToCustomer(req.params.noteId, c.id); }
2936
+ catch (e) {
2937
+ var nm = _safeNotice(e, audit);
2938
+ return _redirect(res, "/admin/customers/" + enc +
2939
+ "?note_err=" + encodeURIComponent(nm.message.replace(/^(?:admin|customerNotes)[.:]\s*/, "")));
2940
+ }
2941
+ if (!owned) {
2942
+ return _redirect(res, "/admin/customers/" + enc +
2943
+ "?note_err=" + encodeURIComponent("That note was not found on this customer."));
2944
+ }
2945
+ try { await mutate(owned, req.body || {}); }
2946
+ catch (e) {
2947
+ var n = _safeNotice(e, audit);
2948
+ return _redirect(res, "/admin/customers/" + enc +
2949
+ "?note_err=" + encodeURIComponent(n.message.replace(/^(?:admin|customerNotes)[.:]\s*/, "")));
2950
+ }
2951
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + audit, outcome: "success", metadata: { id: c.id } });
2952
+ _redirect(res, "/admin/customers/" + enc + "?saved=1");
2953
+ },
2954
+ ));
2955
+ }
2956
+
2957
+ // Edit the note body / kind. The new text is required + length-capped by
2958
+ // the primitive's validator (empty / over-long body, bad kind → a clean
2959
+ // 4xx with nothing written); the kind is left unchanged when the form
2960
+ // omits it.
2961
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/edit", "customer.note.edit",
2962
+ function (owned, body) {
2963
+ var patch = { body: body.body };
2964
+ if (body.kind != null && body.kind !== "") patch.kind = body.kind;
2965
+ return customerNotes.updateNote(owned.id, patch);
2966
+ });
2967
+
2968
+ // Pin / unpin — a pinned note floats to the top of the customer's note
2969
+ // list (notesForCustomer orders pinned DESC). Idempotent at the
2970
+ // primitive.
2971
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/pin", "customer.note.pin",
2972
+ function (owned) { return customerNotes.pinNote(owned.id); });
2973
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/unpin", "customer.note.unpin",
2974
+ function (owned) { return customerNotes.unpinNote(owned.id); });
2975
+
2976
+ // Archive / unarchive — a soft-retire that drops the note from the
2977
+ // default (active) listing; unarchive brings it back. Both idempotent at
2978
+ // the primitive.
2979
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/archive", "customer.note.archive",
2980
+ function (owned) { return customerNotes.archiveNote(owned.id); });
2981
+ _noteWriteRoute("/admin/customers/:id/notes/:noteId/unarchive", "customer.note.unarchive",
2982
+ function (owned) { return customerNotes.unarchiveNote(owned.id); });
2873
2983
  }
2874
2984
  }
2875
2985
 
@@ -7113,6 +7223,305 @@ function mount(router, deps) {
7113
7223
  }
7114
7224
  }
7115
7225
 
7226
+ // ---- customer segments ----------------------------------------------
7227
+ //
7228
+ // Operator-defined RFM-style groupings ("VIPs", "lapsed-90d",
7229
+ // "high-refund-rate") used to target campaigns. A segment is a slug +
7230
+ // title + a bag of rule predicates ANDed together; membership is
7231
+ // RULE-DERIVED — the recompute action re-evaluates the rules against the
7232
+ // live order history and rewrites the membership cache. This console
7233
+ // manages the segment DEFINITIONS + triggers a recompute; it never
7234
+ // hand-assigns individual customers (the customer-detail screen shows
7235
+ // the segments a customer sits in, read-only). The structured create /
7236
+ // edit form covers the numeric RFM predicates (recency / frequency /
7237
+ // monetary / AOV / refund-rate); the list-valued predicates
7238
+ // (last_order_status_in / country_in / currency_in) and any future rule
7239
+ // keys pass through the bearer JSON surface untouched. Every rule is
7240
+ // validated by composing defineSegment / update — the form translator
7241
+ // never persists a rules_json blob straight from the body, so a bad /
7242
+ // missing rule throws a TypeError the routes map to a clean 400.
7243
+ if (customerSegments) {
7244
+ // Translate the structured create form (or a bearer JSON body) into a
7245
+ // defineSegment input. A body already carrying an object-shaped `rules`
7246
+ // is a bearer client passing the full vocabulary — forward untouched so
7247
+ // the primitive's validator owns it. Otherwise build `rules` from the
7248
+ // numeric RFM fields the console exposes; an empty field is omitted (a
7249
+ // segment needs at least one predicate, which defineSegment enforces).
7250
+ function _segmentInput(body) {
7251
+ if (body.rules && typeof body.rules === "object") {
7252
+ return body; // full JSON shape from a bearer client
7253
+ }
7254
+ return {
7255
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
7256
+ title: body.title,
7257
+ description: body.description,
7258
+ rules: _segmentRules(body),
7259
+ };
7260
+ }
7261
+
7262
+ // Translate the edit form (or a bearer JSON patch) into an update patch.
7263
+ // Title / description forward when present; the rules are re-collected
7264
+ // from the same numeric RFM fields the create form uses, but only when
7265
+ // the form declared it via the hidden `rules_present` marker — so a
7266
+ // title-only edit doesn't wipe the rule set. A bearer client posting an
7267
+ // object-shaped `rules` forwards it verbatim for the primitive to validate.
7268
+ function _segmentPatch(body) {
7269
+ var patch = {};
7270
+ if (Object.prototype.hasOwnProperty.call(body, "title") && body.title !== "") patch.title = body.title;
7271
+ if (Object.prototype.hasOwnProperty.call(body, "description")) patch.description = body.description;
7272
+ if (body.rules && typeof body.rules === "object") {
7273
+ patch.rules = body.rules;
7274
+ } else if (body.rules_present === "1") {
7275
+ patch.rules = _segmentRules(body);
7276
+ }
7277
+ return patch;
7278
+ }
7279
+
7280
+ // Build the `rules` object from the structured form's numeric RFM
7281
+ // fields. Each field is optional; a blank field is left out. The values
7282
+ // pass into defineSegment / update verbatim, which validate every key
7283
+ // (non-negative integers, bps caps, min ≤ max coherence) and throw a
7284
+ // TypeError on a bad / empty rule set — surfaced as a clean 400. This
7285
+ // translator never writes the rule itself; it only shapes the typed
7286
+ // bag the primitive's validator owns.
7287
+ function _segmentRules(body) {
7288
+ var rules = {};
7289
+ var NUMERIC = [
7290
+ "recency_days_max", "recency_days_min",
7291
+ "frequency_orders_min", "lifetime_orders_min", "lifetime_orders_max",
7292
+ "monetary_minor_min", "monetary_minor_max", "aov_minor_min",
7293
+ "refund_rate_bps_max", "refund_rate_bps_min",
7294
+ ];
7295
+ for (var i = 0; i < NUMERIC.length; i += 1) {
7296
+ var k = NUMERIC[i];
7297
+ if (body[k] != null && body[k] !== "") {
7298
+ rules[k] = _strictMinorInt(body[k], "customerSegments", k);
7299
+ }
7300
+ }
7301
+ return rules;
7302
+ }
7303
+
7304
+ // One-line human summary of a segment's rules for the list/detail.
7305
+ function _segmentSummary(seg) {
7306
+ var rules = (seg && seg.rules) || {};
7307
+ var keys = Object.keys(rules);
7308
+ if (!keys.length) return "—";
7309
+ return keys.map(function (k) {
7310
+ var v = rules[k];
7311
+ return k + " " + (Array.isArray(v) ? v.join("/") : String(v));
7312
+ }).join(" · ");
7313
+ }
7314
+
7315
+ async function _renderSegments(flags) {
7316
+ flags = flags || {};
7317
+ var url = flags.url || null;
7318
+ var filter = url && url.searchParams.get("filter");
7319
+ var includeArchived = filter === "all" || filter === "archived";
7320
+ var all = await customerSegments.listSegments({ include_archived: includeArchived });
7321
+ var segments = all;
7322
+ if (filter === "archived") {
7323
+ segments = all.filter(function (s) { return s.archived_at != null; });
7324
+ } else if (filter !== "all") {
7325
+ // default = active only (listSegments already excluded archived).
7326
+ segments = all;
7327
+ }
7328
+ return renderAdminSegments(Object.assign({
7329
+ shop_name: deps.shop_name, nav_available: navAvailable,
7330
+ segments: segments, filter: filter || "active", summarize: _segmentSummary,
7331
+ }, flags));
7332
+ }
7333
+
7334
+ // List — every segment with its rule summary, member count, and archived
7335
+ // state; an All/Active/Archived filter; a clean empty state. Content-
7336
+ // negotiated: bearer → the JSON array; browser cookie → the rendered table.
7337
+ router.get("/admin/segments", _pageOrApi(true,
7338
+ R(async function (req, res) {
7339
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7340
+ var includeArchived = url && (url.searchParams.get("filter") === "all" || url.searchParams.get("filter") === "archived");
7341
+ var segments = await customerSegments.listSegments({ include_archived: !!includeArchived });
7342
+ _json(res, 200, { segments: segments });
7343
+ }),
7344
+ async function (req, res) {
7345
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7346
+ _sendHtml(res, 200, await _renderSegments({
7347
+ url: url,
7348
+ created: url && url.searchParams.get("created"),
7349
+ updated: url && url.searchParams.get("updated"),
7350
+ archived: url && url.searchParams.get("archived"),
7351
+ unarchived: url && url.searchParams.get("unarchived"),
7352
+ recomputed: url && url.searchParams.get("recomputed"),
7353
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
7354
+ }));
7355
+ },
7356
+ ));
7357
+
7358
+ // New-segment form — its own GET so the create page survives a bad
7359
+ // submit's err redirect with the operator's context intact.
7360
+ router.get("/admin/segments/new", _pageOrApi(true,
7361
+ R(async function (_req, res) {
7362
+ // Bearer clients don't need a form; hand back the rule vocabulary so
7363
+ // tooling knows what a valid create body accepts.
7364
+ _json(res, 200, {
7365
+ known_rule_keys: customerSegments.KNOWN_RULE_KEYS,
7366
+ allowed_order_statuses: customerSegments.ALLOWED_ORDER_STATUSES,
7367
+ });
7368
+ }),
7369
+ async function (req, res) {
7370
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7371
+ _sendHtml(res, 200, renderAdminSegmentNew({
7372
+ shop_name: deps.shop_name, nav_available: navAvailable,
7373
+ notice: (url && url.searchParams.get("err")) ? "Check the segment's slug and rules — at least one rule is required." : null,
7374
+ }));
7375
+ },
7376
+ ));
7377
+
7378
+ // Create — composes defineSegment, which validates the slug + rules and
7379
+ // throws a TypeError (→ 400) on a bad / missing rule. The bearer path
7380
+ // returns 201 + the created segment; the browser path PRGs to the list.
7381
+ router.post("/admin/segments", _pageOrApi(false,
7382
+ W("customer_segment.create", async function (req, res) {
7383
+ var seg = await customerSegments.defineSegment(_segmentInput(req.body || {}));
7384
+ _json(res, 201, seg);
7385
+ return { id: seg.slug };
7386
+ }),
7387
+ async function (req, res) {
7388
+ try {
7389
+ await customerSegments.defineSegment(_segmentInput(req.body || {}));
7390
+ } catch (e) {
7391
+ // A bad / missing rule (TypeError) or a duplicate slug
7392
+ // (CUSTOMER_SEGMENT_SLUG_EXISTS) is operator input — bounce back to
7393
+ // the create form's err state, never a 500. _safeNotice records an
7394
+ // unknown failure server-side and yields a 5xx only for a genuine
7395
+ // fault.
7396
+ var n = _safeNotice(e, "customer_segment.create");
7397
+ if (n.status >= 500) throw e;
7398
+ return _redirect(res, "/admin/segments/new?err=1");
7399
+ }
7400
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer_segment.create", outcome: "success" });
7401
+ _redirect(res, "/admin/segments?created=1");
7402
+ },
7403
+ ));
7404
+
7405
+ // Resolve a :slug to its segment. A malformed slug throws inside
7406
+ // listSegments' slug guard chain — but here we read via listSegments and
7407
+ // match, so an unknown slug simply yields null → a clean 404.
7408
+ async function _segmentBySlug(slug) {
7409
+ // stats() is the cheapest single-segment read that also throws a
7410
+ // TypeError on a malformed slug; pair it with the full hydrate via the
7411
+ // list so the detail screen has the rules + archived state.
7412
+ var all = await customerSegments.listSegments({ include_archived: true });
7413
+ for (var i = 0; i < all.length; i += 1) {
7414
+ if (all[i].slug === slug) return all[i];
7415
+ }
7416
+ return null;
7417
+ }
7418
+
7419
+ // Detail — the single segment + its current member count + stats, and a
7420
+ // full edit form (title / description / rules). Content-negotiated.
7421
+ router.get("/admin/segments/:slug", _pageOrApi(true,
7422
+ R(async function (req, res) {
7423
+ var seg = await _segmentBySlug(req.params.slug);
7424
+ if (!seg) return _problem(res, 404, "customer-segment-not-found");
7425
+ var stats = await customerSegments.stats(seg.slug);
7426
+ _json(res, 200, Object.assign({}, seg, { stats: stats }));
7427
+ }),
7428
+ async function (req, res) {
7429
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7430
+ var seg = await _segmentBySlug(req.params.slug);
7431
+ if (!seg) return _sendHtml(res, 404, await _renderSegments({ url: url, notice: "Segment not found." }));
7432
+ var stats = null;
7433
+ try { stats = await customerSegments.stats(seg.slug); }
7434
+ catch (_e) { stats = null; }
7435
+ _sendHtml(res, 200, renderAdminSegment({
7436
+ shop_name: deps.shop_name, nav_available: navAvailable,
7437
+ segment: seg, stats: stats, summary: _segmentSummary(seg),
7438
+ updated: url && url.searchParams.get("updated"),
7439
+ recomputed: url && url.searchParams.get("recomputed"),
7440
+ recomputed_count: url && url.searchParams.get("members"),
7441
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the segment." : null,
7442
+ }));
7443
+ },
7444
+ ));
7445
+
7446
+ // Edit — composes update, which re-validates the rules and throws on a
7447
+ // bad set. A terms edit returns to the detail screen on error so the
7448
+ // operator sees their input in context.
7449
+ router.post("/admin/segments/:slug/edit", _pageOrApi(false,
7450
+ W("customer_segment.update", async function (req, res) {
7451
+ var seg;
7452
+ try { seg = await customerSegments.update(req.params.slug, _segmentPatch(req.body || {})); }
7453
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7454
+ _json(res, 200, seg);
7455
+ return { id: seg.slug };
7456
+ }),
7457
+ async function (req, res) {
7458
+ var slug = req.params.slug;
7459
+ var enc = encodeURIComponent(slug);
7460
+ try { await customerSegments.update(slug, _segmentPatch(req.body || {})); }
7461
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/segments/" + enc + "?err=1"); }
7462
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer_segment.update", outcome: "success", metadata: { slug: slug } });
7463
+ _redirect(res, "/admin/segments/" + enc + "?updated=1");
7464
+ },
7465
+ ));
7466
+
7467
+ // Recompute — re-evaluate this segment's rules against the live order
7468
+ // history and rewrite its membership cache. Surfaces the resulting member
7469
+ // count in the PRG banner (this is the action that populates a freshly
7470
+ // defined or edited segment).
7471
+ router.post("/admin/segments/:slug/recompute", _pageOrApi(false,
7472
+ W("customer_segment.recompute", async function (req, res) {
7473
+ var report;
7474
+ try { report = await customerSegments.recompute({ slugs: [req.params.slug] }); }
7475
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7476
+ var count = report.per_segment[req.params.slug];
7477
+ _json(res, 200, { slug: req.params.slug, member_count: count == null ? 0 : count, report: report });
7478
+ return { id: req.params.slug };
7479
+ }),
7480
+ async function (req, res) {
7481
+ var slug = req.params.slug;
7482
+ var enc = encodeURIComponent(slug);
7483
+ var report;
7484
+ try { report = await customerSegments.recompute({ slugs: [slug] }); }
7485
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/segments/" + enc + "?err=1"); }
7486
+ var count = report.per_segment[slug];
7487
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer_segment.recompute", outcome: "success", metadata: { slug: slug } });
7488
+ _redirect(res, "/admin/segments/" + enc + "?recomputed=1&members=" + encodeURIComponent(String(count == null ? 0 : count)));
7489
+ },
7490
+ ));
7491
+
7492
+ router.post("/admin/segments/:slug/archive", _pageOrApi(false,
7493
+ W("customer_segment.archive", async function (req, res) {
7494
+ var seg;
7495
+ try { seg = await customerSegments.archive(req.params.slug); }
7496
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7497
+ _json(res, 200, seg);
7498
+ return { id: seg.slug };
7499
+ }),
7500
+ async function (req, res) {
7501
+ try { await customerSegments.archive(req.params.slug); }
7502
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/segments?err=1"); }
7503
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer_segment.archive", outcome: "success", metadata: { slug: req.params.slug } });
7504
+ _redirect(res, "/admin/segments?archived=1");
7505
+ },
7506
+ ));
7507
+
7508
+ router.post("/admin/segments/:slug/unarchive", _pageOrApi(false,
7509
+ W("customer_segment.unarchive", async function (req, res) {
7510
+ var seg;
7511
+ try { seg = await customerSegments.unarchive(req.params.slug); }
7512
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7513
+ _json(res, 200, seg);
7514
+ return { id: seg.slug };
7515
+ }),
7516
+ async function (req, res) {
7517
+ try { await customerSegments.unarchive(req.params.slug); }
7518
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/segments?err=1"); }
7519
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer_segment.unarchive", outcome: "success", metadata: { slug: req.params.slug } });
7520
+ _redirect(res, "/admin/segments?unarchived=1");
7521
+ },
7522
+ ));
7523
+ }
7524
+
7116
7525
  // ---- quantity discounts ---------------------------------------------
7117
7526
  // Tier-set CRUD for automatic per-line quantity breaks ("buy 5, save
7118
7527
  // 10%"). The pricing engine already applies these at PDP / cart /
@@ -8347,6 +8756,7 @@ var ADMIN_NAV_ITEMS = [
8347
8756
  { key: "orders", href: "/admin/orders", label: "Orders" },
8348
8757
  { key: "reports", href: "/admin/reports", label: "Reports" },
8349
8758
  { key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
8759
+ { key: "segments", href: "/admin/segments", label: "Segments", requires: "customerSegments" },
8350
8760
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
8351
8761
  { key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
8352
8762
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
@@ -9625,25 +10035,56 @@ function renderAdminCustomerDetail(opts) {
9625
10035
  "<p class=\"empty\">Customer notes aren't wired in this deployment.</p></div>";
9626
10036
  } else {
9627
10037
  var noteNotice = opts.note_notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.note_notice) + "</div>" : "";
10038
+ var showArchived = opts.show_archived_notes === true;
10039
+ // A <select name="kind"> with the active kind preselected. Reused by the
10040
+ // add form and each note's inline edit form.
10041
+ function _noteKindSelect(active) {
10042
+ return ["general", "preference", "escalation", "warning", "billing"].map(function (k) {
10043
+ return "<option value=\"" + k + "\"" + (k === active ? " selected" : "") + ">" + k + "</option>";
10044
+ }).join("");
10045
+ }
9628
10046
  var noteRows = (opts.notes || []).map(function (n) {
9629
- return "<li><div class=\"note-meta\">" + _htmlEscape(n.kind) + " · " + _htmlEscape(_fmtDate(n.created_at)) + "</div>" +
9630
- "<div class=\"note-body\">" + _htmlEscape(n.body) + "</div></li>";
10047
+ var nenc = _htmlEscape(encodeURIComponent(n.id));
10048
+ var base = "/admin/customers/" + _htmlEscape(enc) + "/notes/" + nenc;
10049
+ var isPinned = Number(n.pinned) === 1;
10050
+ var isArchived = n.archived_at != null;
10051
+ var pills =
10052
+ (isPinned ? "<span class=\"status-pill paid\">Pinned</span>" : "") +
10053
+ (isArchived ? "<span class=\"status-pill cancelled\">Archived</span>" : "");
10054
+ // Inline edit form — kind + body, prefilled with the note's current
10055
+ // values; the primitive re-validates an empty / over-long body.
10056
+ var editForm = "<details class=\"note-edit\"><summary class=\"btn btn--ghost\">Edit</summary>" +
10057
+ "<form method=\"post\" action=\"" + base + "/edit\">" +
10058
+ "<label class=\"form-field\"><span>Kind</span>" +
10059
+ "<select name=\"kind\">" + _noteKindSelect(n.kind) + "</select></label>" +
10060
+ "<label class=\"form-field\"><span>Note</span>" +
10061
+ "<textarea name=\"body\" required maxlength=\"8000\" rows=\"3\">" + _htmlEscape(n.body) + "</textarea></label>" +
10062
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save note</button></div>" +
10063
+ "</form></details>";
10064
+ var pinForm = "<form method=\"post\" action=\"" + base + (isPinned ? "/unpin" : "/pin") + "\">" +
10065
+ "<button class=\"btn btn--ghost\" type=\"submit\">" + (isPinned ? "Unpin" : "Pin") + "</button></form>";
10066
+ var archiveForm = "<form method=\"post\" action=\"" + base + (isArchived ? "/unarchive" : "/archive") + "\">" +
10067
+ "<button class=\"btn btn--ghost\" type=\"submit\">" + (isArchived ? "Unarchive" : "Archive") + "</button></form>";
10068
+ return "<li class=\"note-item\">" +
10069
+ "<div class=\"note-meta\">" + _htmlEscape(n.kind) + " · " + _htmlEscape(_fmtDate(n.created_at)) +
10070
+ (pills ? " " + pills : "") + "</div>" +
10071
+ "<div class=\"note-body\">" + _htmlEscape(n.body) + "</div>" +
10072
+ "<div class=\"actions-row\">" + editForm + pinForm + archiveForm + "</div>" +
10073
+ "</li>";
9631
10074
  }).join("");
9632
- notesPanel = "<div class=\"panel\"><h3 class=\"subhead\">Notes</h3>" +
10075
+ var archivedToggle = showArchived
10076
+ ? "<a class=\"btn btn--ghost\" href=\"/admin/customers/" + _htmlEscape(enc) + "\">Hide archived</a>"
10077
+ : "<a class=\"btn btn--ghost\" href=\"/admin/customers/" + _htmlEscape(enc) + "?notes_archived=1\">Show archived</a>";
10078
+ notesPanel = "<div class=\"panel\"><div class=\"actions-row\"><h3 class=\"subhead\">Notes</h3>" + archivedToggle + "</div>" +
9633
10079
  noteNotice +
9634
- "<p class=\"meta\">Operator-only annotations on this customer. Never shown to the customer.</p>" +
10080
+ "<p class=\"meta\">Operator-only annotations on this customer. Never shown to the customer. " +
10081
+ "Pin the notes every shift should see first; archive a stale or wrong note to retire it from the active list.</p>" +
9635
10082
  ((opts.notes || []).length
9636
10083
  ? "<ul class=\"note-list\">" + noteRows + "</ul>"
9637
- : "<p class=\"empty\">No notes yet.</p>") +
10084
+ : "<p class=\"empty\">" + (showArchived ? "No notes (including archived)." : "No notes yet.") + "</p>") +
9638
10085
  "<form method=\"post\" action=\"/admin/customers/" + _htmlEscape(enc) + "/notes\">" +
9639
10086
  "<label class=\"form-field\"><span>Kind</span>" +
9640
- "<select name=\"kind\">" +
9641
- "<option value=\"general\">general</option>" +
9642
- "<option value=\"preference\">preference</option>" +
9643
- "<option value=\"escalation\">escalation</option>" +
9644
- "<option value=\"warning\">warning</option>" +
9645
- "<option value=\"billing\">billing</option>" +
9646
- "</select></label>" +
10087
+ "<select name=\"kind\">" + _noteKindSelect("general") + "</select></label>" +
9647
10088
  "<label class=\"form-field\"><span>Note</span>" +
9648
10089
  "<textarea name=\"body\" required maxlength=\"8000\" rows=\"3\" placeholder=\"e.g. VIP — comp shipping where possible\"></textarea></label>" +
9649
10090
  "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add note</button></div>" +
@@ -11036,6 +11477,192 @@ function renderAdminDiscounts(opts) {
11036
11477
  return _renderAdminShell(opts.shop_name, "Discounts", body, "discounts", opts.nav_available);
11037
11478
  }
11038
11479
 
11480
+ // The numeric RFM rule fields the structured segment form collects. Each
11481
+ // maps 1:1 to a customer-segments rule key; a blank field is omitted from
11482
+ // the rules object, and the backend validates whatever's present (rejecting
11483
+ // an empty rule set, a bad integer, or an out-of-range basis-point value).
11484
+ var SEGMENT_RULE_FIELDS = [
11485
+ { name: "frequency_orders_min", label: "Min lifetime orders (frequency)", hint: "Customer has placed at least this many paid orders.", extra: " min=\"0\"" },
11486
+ { name: "lifetime_orders_max", label: "Max lifetime orders", hint: "At most this many orders — gate \"new\" buyers.", extra: " min=\"0\"" },
11487
+ { name: "recency_days_max", label: "Ordered within N days (recency)", hint: "Last order ≤ N days ago — recent buyers.", extra: " min=\"0\"" },
11488
+ { name: "recency_days_min", label: "Lapsed for N+ days", hint: "Last order ≥ N days ago — the lapsed half of RFM.", extra: " min=\"0\"" },
11489
+ { name: "monetary_minor_min", label: "Min lifetime spend (minor)", hint: "Lifetime gross ≥ N in the smallest currency unit.", extra: " min=\"0\"" },
11490
+ { name: "monetary_minor_max", label: "Max lifetime spend (minor)", hint: "Lifetime gross ≤ N.", extra: " min=\"0\"" },
11491
+ { name: "aov_minor_min", label: "Min average order value (minor)", hint: "Average order value ≥ N.", extra: " min=\"0\"" },
11492
+ { name: "refund_rate_bps_max", label: "Max refund rate (bps)", hint: "Refunds ÷ orders ≤ N basis points (10000 = 100%).", extra: " min=\"0\" max=\"10000\"" },
11493
+ { 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\"" },
11494
+ ];
11495
+
11496
+ // Status pill for a segment's lifecycle: active = green (paid), archived =
11497
+ // neutral-off (cancelled). Reuses the shared order-pill classes.
11498
+ function _segmentPill(seg) {
11499
+ var archived = seg && seg.archived_at != null;
11500
+ return "<span class=\"status-pill " + (archived ? "cancelled" : "paid") + "\">" +
11501
+ (archived ? "archived" : "active") + "</span>";
11502
+ }
11503
+
11504
+ // Render the numeric RFM rule field set, each prefilled from the supplied
11505
+ // rules object (empty when creating). The list-valued predicates
11506
+ // (last_order_status_in / country_in / currency_in) and any future rule key
11507
+ // stay on the bearer JSON surface — documented in the form's help text.
11508
+ function _segmentRuleFields(rules) {
11509
+ rules = rules || {};
11510
+ return SEGMENT_RULE_FIELDS.map(function (f) {
11511
+ var v = rules[f.name] != null ? String(rules[f.name]) : "";
11512
+ return _setupField(f.label, f.name, v, "number", f.hint, f.extra);
11513
+ }).join("");
11514
+ }
11515
+
11516
+ function renderAdminSegments(opts) {
11517
+ opts = opts || {};
11518
+ var segments = opts.segments || [];
11519
+ var summarize = typeof opts.summarize === "function" ? opts.summarize : function () { return "—"; };
11520
+ var filter = opts.filter || "active";
11521
+ var created = opts.created ? "<div class=\"banner banner--ok\">Segment created.</div>" : "";
11522
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Segment updated.</div>" : "";
11523
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Segment archived.</div>" : "";
11524
+ var unarchived = opts.unarchived ? "<div class=\"banner banner--ok\">Segment unarchived.</div>" : "";
11525
+ var recomputed = opts.recomputed ? "<div class=\"banner banner--ok\">Membership recomputed.</div>" : "";
11526
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
11527
+
11528
+ var FILTERS = [
11529
+ { value: "active", label: "Active" },
11530
+ { value: "all", label: "All" },
11531
+ { value: "archived", label: "Archived" },
11532
+ ];
11533
+ var chips = "<div class=\"order-filters\">" + FILTERS.map(function (fl) {
11534
+ var href = fl.value === "active" ? "/admin/segments" : "/admin/segments?filter=" + fl.value;
11535
+ return "<a class=\"chip" + (filter === fl.value ? " chip--on" : "") + "\" href=\"" + href + "\">" + _htmlEscape(fl.label) + "</a>";
11536
+ }).join("") + "</div>";
11537
+
11538
+ var rows = segments.map(function (s) {
11539
+ var enc = encodeURIComponent(s.slug);
11540
+ var isArchived = s.archived_at != null;
11541
+ var actions =
11542
+ "<a class=\"btn btn--ghost\" href=\"/admin/segments/" + _htmlEscape(enc) + "\">Manage</a> " +
11543
+ (isArchived
11544
+ ? "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/unarchive\" class=\"form-inline\">" +
11545
+ "<button class=\"btn btn--ghost\" type=\"submit\">Unarchive</button></form>"
11546
+ : "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/recompute\" class=\"form-inline\">" +
11547
+ "<button class=\"btn btn--ghost\" type=\"submit\">Recompute</button></form> " +
11548
+ "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
11549
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>");
11550
+ return "<tr>" +
11551
+ "<td><a href=\"/admin/segments/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(s.title) + "</strong></a><br><code class=\"order-id\">" + _htmlEscape(s.slug) + "</code></td>" +
11552
+ "<td>" + _htmlEscape(summarize(s)) + "</td>" +
11553
+ "<td class=\"num\">" + _htmlEscape(String(s.last_member_count == null ? 0 : s.last_member_count)) + "</td>" +
11554
+ "<td>" + _segmentPill(s) + "</td>" +
11555
+ "<td><div class=\"actions-row\">" + actions + "</div></td>" +
11556
+ "</tr>";
11557
+ }).join("");
11558
+
11559
+ var table = segments.length
11560
+ ? "<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>"
11561
+ : "<p class=\"empty\">No " + (filter === "archived" ? "archived " : (filter === "all" ? "" : "active ")) + "segments yet. Define one to start grouping customers for campaigns.</p>";
11562
+
11563
+ var body = "<section><h2>Customer segments</h2>" + created + updated + archived + unarchived + recomputed + notice +
11564
+ "<p class=\"meta\">Rule-derived groupings for targeted campaigns. Membership is recomputed from order history — define a segment, then recompute to populate it.</p>" +
11565
+ chips + table +
11566
+ "<div class=\"actions-row mt\"><a class=\"btn\" href=\"/admin/segments/new\">Define a segment</a></div>" +
11567
+ "</section>";
11568
+ return _renderAdminShell(opts.shop_name, "Segments", body, "segments", opts.nav_available);
11569
+ }
11570
+
11571
+ function renderAdminSegmentNew(opts) {
11572
+ opts = opts || {};
11573
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
11574
+ var form =
11575
+ "<div class=\"panel mw-42\">" +
11576
+ "<h3 class=\"subhead\">Define a segment</h3>" +
11577
+ "<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>" +
11578
+ "<form method=\"post\" action=\"/admin/segments\">" +
11579
+ _setupField("Slug", "slug", "", "text", "Stable handle, e.g. vip-buyers.", " maxlength=\"64\" required") +
11580
+ _setupField("Title", "title", "", "text", "Operator-facing name, e.g. VIP buyers.", " maxlength=\"200\" required") +
11581
+ _setupField("Description (optional)", "description", "", "text", "What this segment is for.", " maxlength=\"1000\"") +
11582
+ "<h3 class=\"subhead subhead--sp-lg\">Rules (all must hold)</h3>" +
11583
+ _segmentRuleFields({}) +
11584
+ "<p class=\"meta\">List-valued rules (last order status / country / currency) are available through the JSON API.</p>" +
11585
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create segment</button>" +
11586
+ "<a class=\"btn btn--ghost\" href=\"/admin/segments\">Cancel</a></div>" +
11587
+ "</form>" +
11588
+ "</div>";
11589
+ var body = "<section><h2>Define a segment</h2>" + notice + form + "</section>";
11590
+ return _renderAdminShell(opts.shop_name, "New segment", body, "segments", opts.nav_available);
11591
+ }
11592
+
11593
+ function renderAdminSegment(opts) {
11594
+ opts = opts || {};
11595
+ var s = opts.segment;
11596
+ if (!s) {
11597
+ var nf = "<section><h2>Segment</h2><p class=\"empty\">Segment not found.</p>" +
11598
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/segments\">Back to segments</a></div></section>";
11599
+ return _renderAdminShell(opts.shop_name, "Segment", nf, "segments", opts.nav_available);
11600
+ }
11601
+ var stats = opts.stats || null;
11602
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Segment updated.</div>" : "";
11603
+ var recomputed = opts.recomputed
11604
+ ? "<div class=\"banner banner--ok\">Membership recomputed — " + _htmlEscape(String(opts.recomputed_count == null ? 0 : opts.recomputed_count)) + " member(s).</div>"
11605
+ : "";
11606
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
11607
+ var isArchived = s.archived_at != null;
11608
+ var enc = encodeURIComponent(s.slug);
11609
+
11610
+ var memberCount = stats && stats.member_count != null ? stats.member_count : (s.last_member_count == null ? 0 : s.last_member_count);
11611
+ var lastRecomputed = stats && stats.last_recomputed_at != null ? _fmtDate(stats.last_recomputed_at)
11612
+ : (s.last_recomputed_at != null ? _fmtDate(s.last_recomputed_at) : "never");
11613
+
11614
+ var statCards =
11615
+ "<div class=\"stat-grid\">" +
11616
+ _statCard("Members", String(memberCount)) +
11617
+ _statCard("Avg lifetime (minor)", stats && stats.avg_lifetime_minor != null ? String(stats.avg_lifetime_minor) : "0") +
11618
+ _statCard("Last recomputed", lastRecomputed) +
11619
+ "</div>";
11620
+
11621
+ var head = "<div class=\"panel\"><dl class=\"detail-grid\">" +
11622
+ "<div><dt>Segment</dt><dd><strong>" + _htmlEscape(s.title) + "</strong><br><code class=\"order-id\">" + _htmlEscape(s.slug) + "</code></dd></div>" +
11623
+ "<div><dt>Rules</dt><dd>" + _htmlEscape(opts.summary || "—") + "</dd></div>" +
11624
+ (s.description ? "<div><dt>Description</dt><dd>" + _htmlEscape(s.description) + "</dd></div>" : "") +
11625
+ "<div><dt>Status</dt><dd>" + _segmentPill(s) + "</dd></div>" +
11626
+ "</dl></div>";
11627
+
11628
+ // Recompute is the action that makes the rules actually populate the
11629
+ // membership cache — surface it prominently for an active segment.
11630
+ var recomputeForm = isArchived ? "" :
11631
+ "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/recompute\" class=\"form-inline\">" +
11632
+ "<button class=\"btn\" type=\"submit\">Recompute membership</button>" +
11633
+ "</form>";
11634
+
11635
+ var editForm = isArchived
11636
+ ? "<p class=\"empty\">This segment is archived. Unarchive it to edit or recompute.</p>" +
11637
+ "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/unarchive\" class=\"form-inline\">" +
11638
+ "<button class=\"btn\" type=\"submit\">Unarchive</button></form>"
11639
+ : "<div class=\"panel mt mw-42\">" +
11640
+ "<h3 class=\"subhead\">Edit segment</h3>" +
11641
+ "<p class=\"meta\">Change the title, description, or rules. Editing the rules takes effect on the next recompute.</p>" +
11642
+ "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/edit\">" +
11643
+ "<input type=\"hidden\" name=\"rules_present\" value=\"1\">" +
11644
+ _setupField("Title", "title", s.title, "text", "Operator-facing name.", " maxlength=\"200\"") +
11645
+ _setupField("Description", "description", s.description || "", "text", "What this segment is for.", " maxlength=\"1000\"") +
11646
+ "<h3 class=\"subhead subhead--sp-lg\">Rules (all must hold)</h3>" +
11647
+ _segmentRuleFields(s.rules) +
11648
+ "<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>" +
11649
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
11650
+ "</form>" +
11651
+ "</div>";
11652
+
11653
+ var archiveForm = isArchived ? "" :
11654
+ "<form method=\"post\" action=\"/admin/segments/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
11655
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive segment</button></form>";
11656
+
11657
+ var body = "<section><h2>" + _htmlEscape(s.title) + "</h2>" + updated + recomputed + notice +
11658
+ head + statCards +
11659
+ "<div class=\"actions-row mt\">" + recomputeForm + archiveForm + "</div>" +
11660
+ editForm +
11661
+ "<div class=\"actions-row mt\"><a class=\"btn btn--ghost\" href=\"/admin/segments\">Back to segments</a></div>" +
11662
+ "</section>";
11663
+ return _renderAdminShell(opts.shop_name, "Segment " + s.slug, body, "segments", opts.nav_available);
11664
+ }
11665
+
11039
11666
  function renderAdminGiftCards(opts) {
11040
11667
  opts = opts || {};
11041
11668
  var rows = opts.cards || [];
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.32",
2
+ "version": "0.3.34",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.32",
3
+ "version": "0.3.34",
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": {