@blamejs/blamejs-shop 0.4.53 → 0.4.55

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/admin.js +255 -1
  3. package/lib/asset-manifest.json +3 -3
  4. package/lib/storefront.js +135 -0
  5. package/lib/vendor/MANIFEST.json +41 -35
  6. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  7. package/lib/vendor/blamejs/SECURITY.md +1 -0
  8. package/lib/vendor/blamejs/api-snapshot.json +10 -2
  9. package/lib/vendor/blamejs/examples/wiki/lib/html-entities.js +24 -0
  10. package/lib/vendor/blamejs/examples/wiki/lib/symbol-index.js +7 -5
  11. package/lib/vendor/blamejs/examples/wiki/test/e2e.js +9 -1
  12. package/lib/vendor/blamejs/examples/wiki/test/validate-nav-coverage.js +2 -8
  13. package/lib/vendor/blamejs/lib/acme.js +7 -11
  14. package/lib/vendor/blamejs/lib/client-hints.js +3 -1
  15. package/lib/vendor/blamejs/lib/cluster.js +4 -2
  16. package/lib/vendor/blamejs/lib/guard-filename.js +6 -2
  17. package/lib/vendor/blamejs/lib/http-client-cache.js +3 -1
  18. package/lib/vendor/blamejs/lib/http-message-signature.js +25 -8
  19. package/lib/vendor/blamejs/lib/log-stream-otlp-grpc.js +12 -1
  20. package/lib/vendor/blamejs/lib/log-stream-syslog.js +6 -0
  21. package/lib/vendor/blamejs/lib/log.js +24 -2
  22. package/lib/vendor/blamejs/lib/mail.js +5 -0
  23. package/lib/vendor/blamejs/lib/middleware/body-parser.js +48 -6
  24. package/lib/vendor/blamejs/lib/network-dns.js +22 -26
  25. package/lib/vendor/blamejs/lib/network-heartbeat.js +3 -3
  26. package/lib/vendor/blamejs/lib/network-proxy.js +3 -7
  27. package/lib/vendor/blamejs/lib/network-tls.js +34 -13
  28. package/lib/vendor/blamejs/lib/network.js +2 -6
  29. package/lib/vendor/blamejs/lib/notify.js +7 -12
  30. package/lib/vendor/blamejs/lib/seeders.js +5 -10
  31. package/lib/vendor/blamejs/lib/structured-fields.js +38 -1
  32. package/lib/vendor/blamejs/package.json +1 -1
  33. package/lib/vendor/blamejs/release-notes/v0.15.12.json +47 -0
  34. package/lib/vendor/blamejs/test/00-primitives.js +24 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-error-redaction.test.js +74 -0
  36. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -8
  37. package/lib/vendor/blamejs/test/layer-0-primitives/guard-filename.test.js +11 -0
  38. package/lib/vendor/blamejs/test/layer-0-primitives/http-message-signature.test.js +33 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/log-stream-otlp-grpc.test.js +27 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/network-tls.test.js +31 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/structured-fields.test.js +14 -0
  42. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.55 (2026-06-14) — **Refresh the vendored blamejs framework to 0.15.12.** Refreshes the vendored blamejs framework from 0.15.11 to 0.15.12, a sweep of defense-in-depth hardening the shop picks up by composing the framework. The body parser no longer echoes a caught exception's internal detail (a filesystem errno and temp path, or a parse hook's thrown message) to the HTTP client — the client gets a generic status phrase while the full detail stays on the server-side audit chain. Filename guarding now strips every reserved character rather than only the first. The boot-time logger escapes bidirectional and control characters on every sink, closing a terminal log-forging and line-reordering vector. Structured-field string values (used by HTTP Message Signatures, Client Hints, and Cache-Control) are now decoded in a single conformant pass, the HTTP Message Signature content-digest check matches in constant time against the exact digest member, and any outbound TLS connection that runs with certificate validation disabled now emits an audit event so the degraded posture is visible. This refresh carries no shop-facing API change and applies no migration; it keeps the bundled framework current and the security posture aligned with the latest release. **Changed:** *Vendored blamejs refreshed to 0.15.12* — The bundled framework is updated to blamejs 0.15.12. It redacts internal error detail from body-parser error responses (the client gets a generic status phrase; full diagnostics stay on the audit chain), strips every reserved character in filename guarding instead of only the first, escapes bidirectional and control characters in the boot logger on every sink (Trojan-Source / log-forging defense), decodes RFC 8941 structured-field strings in a single conformant pass, verifies the HTTP Message Signature content-digest by exact constant-time member match, and emits an audit event whenever an outbound TLS connection is configured to skip certificate validation. No shop API change; the framework's PQC-first crypto, security middleware, and request lifecycle are carried forward as-is.
12
+
13
+ - v0.4.54 (2026-06-14) — **The rewards page now shows what a customer's loyalty tier includes and their progress to the next tier.** A signed-in customer's rewards page now shows their current loyalty tier, how many points remain to reach the next tier (with a progress bar), and the perks their tier includes. Operators author the per-tier perks from a new console screen — free shipping over a threshold, a percent discount, early or exclusive access, priority support, or a birthday bonus. The perks are presented to customers as what their tier includes and that the shop honours them; they are not applied automatically at checkout, so the wording never promises an automatic discount the store doesn't yet apply. No migration to apply. **Added:** *Loyalty tier perks and next-tier progress on the rewards page* — The /account rewards page gains two sections: a tier-progress panel naming the customer's current tier and the points still needed to reach the next one (with a labelled progress bar, or a top-tier acknowledgement), and a list of the perks the customer's tier includes. The tier is resolved from the customer's own loyalty balance; the perks come from the operator-authored tier-benefit definitions. The perks are framed as tier inclusions the shop honours — the copy directs the customer to ask at checkout or contact support to have a perk applied — rather than implying an automatic discount. · *Tier-benefit authoring in the loyalty console* — A new admin screen under the loyalty console lets operators define the perks each tier includes: free shipping (optionally over a minimum order), a percent discount, early access (hours before general release), priority support (an SLA in minutes), exclusive access to a collection, or a birthday bonus. Benefits are created and archived from the screen, each change recorded to the audit trail under the loyalty permission. The screen states that these perks are shown to customers as tier inclusions the shop honours and are not applied automatically at checkout.
14
+
11
15
  - v0.4.53 (2026-06-14) — **A signed-in customer's cookie choices and newsletter unsubscribe now land in the durable consent record.** The durable, per-customer consent ledger — the GDPR Article 7(1) record a controller keeps to demonstrate consent — is now written from the real consent events for identified customers. When a signed-in customer saves their cookie preferences, each category (functional, analytics, marketing, preferences) is recorded as granted or withdrawn in the durable ledger, alongside the existing session-level cookie record. When a newsletter unsubscribe resolves to a customer account, a marketing-email withdrawal is recorded there too. Anonymous visitors and email-only subscribers with no account are unchanged — their cookie choice stays in the session-level store and their unsubscribe in the email-suppression list, neither of which can be customer-keyed. The ledger writes are best-effort and never block the banner save or the unsubscribe. No migration to apply. **Added:** *Cookie-banner choices recorded in the durable consent ledger for signed-in customers* — When an authenticated customer saves their cookie preferences, each of the four optional categories is now mirrored into the durable per-customer consent ledger as a granted or withdrawn decision (source: cookie banner), so a supervisory-authority audit shows the identified individual's choice and not only the session-keyed record. The durable record reflects the consent the storefront actually enforces: a browser-level opt-out signal (Global Privacy Control or Do Not Track) collapses the analytics and marketing categories to withdrawn even if their boxes were ticked, matching the runtime gate, so the record never claims consent the app refuses to honor. The customer is resolved from the existing signed-in session (a revoked session is treated as signed-out); anonymous visitors carry no account and are written only to the session-level cookie record as before. · *Newsletter unsubscribe records a marketing withdrawal for account holders* — A newsletter unsubscribe that resolves to a customer account now records a marketing-email withdrawal in the durable consent ledger. The unsubscribed address is matched to an account by its hashed form; an email-only subscriber with no account is handled by the existing email-suppression path only, since the durable ledger is keyed by customer. The existing unsubscribe behavior — the suppression entry and the one-click RFC 8058 flow — is unchanged.
12
16
 
13
17
  - v0.4.52 (2026-06-14) — **Consent records now carry the GDPR Article 6 lawful basis they rest on.** Each decision in the durable per-customer consent ledger now records the GDPR Article 6(1) lawful basis it is processed under — consent, contract, legal obligation, vital interests, public task, or legitimate interests — alongside what was decided and when. The basis defaults from the consent kind (cookie categories, marketing email/SMS, and data-sharing opt-ins are all consent-based), and a caller may pass an explicit, validated basis for a record that rests on another. The basis is surfaced in the subject-access and supervisory-authority exports, so a consent audit now shows not just the decision but the legal ground for it. A migration adds a nullable, constrained lawful_basis column; pre-existing rows keep a null basis rather than being assigned one retroactively. Apply the pending migration on upgrade. **Added:** *Lawful basis on consent-ledger records* — The consent ledger now stamps the GDPR Article 6(1) lawful basis on every recorded decision. The basis defaults from the consent kind — every category the ledger tracks (cookie functional/analytics/marketing/preferences, marketing email, marketing SMS, partner and analytics data-sharing, and general data processing) is consent-based — and an explicit basis can be supplied and is validated against the six Article 6(1) values. Withdrawal records carry the same basis as the grant they revoke. The basis is included in the subject-access export (CSV and JSON) and the jurisdiction bulk export, so a consent audit shows the legal ground each decision rests on. **Changed:** *Migration: lawful_basis column on consent_ledger* — A new migration adds a nullable lawful_basis column to consent_ledger, constrained to the six Article 6(1) bases. The column is nullable and the constraint applies only to rows written after the migration, so existing records keep a null basis (not retroactively assigned) while every new record carries one. Apply the pending migration on upgrade.
package/lib/admin.js CHANGED
@@ -36,6 +36,7 @@ var collectionsModule = require("./collections");
36
36
  var quantityDiscountsModule = require("./quantity-discounts");
37
37
  var loyaltyEarnRulesModule = require("./loyalty-earn-rules");
38
38
  var loyaltyRedemptionModule = require("./loyalty-redemption");
39
+ var tierBenefitsModule = require("./tier-benefits");
39
40
  var trustBadgesModule = require("./trust-badges");
40
41
  var cartModule = require("./cart"); // ABANDONED_* window/limit constants for the /admin/carts console
41
42
  var inventoryWriteoffsModule = require("./inventory-writeoffs"); // WRITEOFF_REASONS enum for the /admin/inventory/writeoffs console
@@ -13174,6 +13175,11 @@ function mount(router, deps) {
13174
13175
  try { rewards = await loyaltyRedemption.listRewards({ limit: 200 }); }
13175
13176
  catch (_e) { rewards = []; }
13176
13177
  }
13178
+ var tierBenefitRows = null;
13179
+ if (deps.tierBenefits) {
13180
+ try { tierBenefitRows = await deps.tierBenefits.listBenefits({ limit: 200 }); }
13181
+ catch (_e) { tierBenefitRows = []; }
13182
+ }
13177
13183
  return Object.assign({
13178
13184
  shop_name: deps.shop_name,
13179
13185
  nav_available: navAvailable,
@@ -13185,8 +13191,10 @@ function mount(router, deps) {
13185
13191
  reward_kinds: loyaltyRedemptionModule.KINDS,
13186
13192
  earn_rules: earnRules,
13187
13193
  rewards: rewards,
13194
+ tier_benefits: tierBenefitRows,
13188
13195
  can_manage_rules: !!loyaltyEarnRules,
13189
13196
  can_manage_rewards: !!loyaltyRedemption,
13197
+ can_manage_tier_benefits: !!deps.tierBenefits,
13190
13198
  }, flags);
13191
13199
  }
13192
13200
 
@@ -13609,6 +13617,136 @@ function mount(router, deps) {
13609
13617
  return patch;
13610
13618
  }
13611
13619
  }
13620
+
13621
+ // ---- tier benefits ------------------------------------------------
13622
+ //
13623
+ // The perks a loyalty tier INCLUDES — operator-authored rows the
13624
+ // customer's /account/loyalty page surfaces under "what your tier
13625
+ // includes". This screen is the authoring path so the customer
13626
+ // display is non-empty end-to-end (no dormant-table display).
13627
+ //
13628
+ // HONESTY: these benefits are operator-defined inclusions. The
13629
+ // framework does NOT auto-apply them at checkout / shipping / earn —
13630
+ // the storefront frames them as perks the shop honours, and the
13631
+ // create form tells the operator the same so they don't expect
13632
+ // automatic enforcement.
13633
+ //
13634
+ // Authoring rather than seeding: the primitive already exposes
13635
+ // defineBenefit / listBenefits / archiveBenefit, the loyalty admin
13636
+ // screen is the natural home (the earn-rules / rewards screens are
13637
+ // the reference CRUD shape), and seeding default rows would guess at
13638
+ // the operator's tier ladder + thresholds. Cloning the earn-rule CRUD
13639
+ // keeps the surface consistent.
13640
+ if (deps.tierBenefits) {
13641
+ var tierBenefits = deps.tierBenefits;
13642
+
13643
+ // The benefit's value_json shape depends on its kind. The console
13644
+ // collects one or two typed fields and the create path assembles
13645
+ // the per-kind payload the primitive validates. An unknown kind
13646
+ // falls through to the primitive's refusal.
13647
+ function _tierBenefitValue(kind, body) {
13648
+ switch (kind) {
13649
+ case "free_shipping": {
13650
+ var minRaw = body.value_min_order_minor == null ? "" : String(body.value_min_order_minor).trim();
13651
+ return minRaw !== ""
13652
+ ? { min_order_minor: _strictMinorInt(minRaw, "admin", "min_order_minor") }
13653
+ : {};
13654
+ }
13655
+ case "percent_off":
13656
+ return { percent: _strictMinorInt(body.value_percent, "admin", "percent") };
13657
+ case "early_access":
13658
+ return { hours: _strictMinorInt(body.value_hours, "admin", "hours") };
13659
+ case "priority_support":
13660
+ return { sla_minutes: _strictMinorInt(body.value_sla_minutes, "admin", "sla_minutes") };
13661
+ case "exclusive_access":
13662
+ return { collection_slug: typeof body.value_collection_slug === "string" ? body.value_collection_slug.trim() : body.value_collection_slug };
13663
+ case "birthday_bonus": {
13664
+ var ptsRaw = body.value_points == null ? "" : String(body.value_points).trim();
13665
+ var pctRaw = body.value_percent_off == null ? "" : String(body.value_percent_off).trim();
13666
+ // Exactly one of points / percent_off — the primitive refuses
13667
+ // both or neither. Forward what the operator filled in; if both
13668
+ // are blank we send an empty object so the primitive's
13669
+ // "exactly one" refusal surfaces as a clean 400 notice.
13670
+ if (ptsRaw !== "") return { points: _strictMinorInt(ptsRaw, "admin", "points") };
13671
+ if (pctRaw !== "") return { percent_off: _strictMinorInt(pctRaw, "admin", "percent_off") };
13672
+ return {};
13673
+ }
13674
+ default:
13675
+ return {};
13676
+ }
13677
+ }
13678
+
13679
+ function _tierBenefitDefineInput(body) {
13680
+ var kind = typeof body.kind === "string" ? body.kind.trim() : body.kind;
13681
+ return {
13682
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
13683
+ tier: typeof body.tier === "string" ? body.tier.trim() : body.tier,
13684
+ kind: kind,
13685
+ value: _tierBenefitValue(kind, body),
13686
+ };
13687
+ }
13688
+
13689
+ router.get("/admin/loyalty/tier-benefits", _pageOrApi(true,
13690
+ R(async function (_req, res) {
13691
+ _json(res, 200, { rows: await tierBenefits.listBenefits({ include_archived: true }) });
13692
+ }),
13693
+ async function (req, res) {
13694
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
13695
+ _sendHtml(res, 200, renderAdminTierBenefits({
13696
+ shop_name: deps.shop_name, nav_available: navAvailable,
13697
+ kinds: tierBenefitsModule.KINDS,
13698
+ tiers: loyalty.TIERS,
13699
+ benefits: await tierBenefits.listBenefits({ include_archived: true }),
13700
+ created: url && url.searchParams.get("created"),
13701
+ archived: url && url.searchParams.get("archived_ok"),
13702
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the benefit." : null,
13703
+ }));
13704
+ },
13705
+ ));
13706
+
13707
+ router.post("/admin/loyalty/tier-benefits", _pageOrApi(false,
13708
+ W("loyalty.tier_benefit.create", async function (req, res) {
13709
+ var benefit;
13710
+ try { benefit = await tierBenefits.defineBenefit(_tierBenefitDefineInput(req.body || {})); }
13711
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
13712
+ _json(res, 201, benefit);
13713
+ return { id: benefit.slug };
13714
+ }),
13715
+ async function (req, res) {
13716
+ try {
13717
+ await tierBenefits.defineBenefit(_tierBenefitDefineInput(req.body || {}));
13718
+ } catch (e) {
13719
+ var n = _safeNotice(e, "loyalty.tier_benefit.create");
13720
+ return _sendHtml(res, n.status, renderAdminTierBenefits({
13721
+ shop_name: deps.shop_name, nav_available: navAvailable,
13722
+ kinds: tierBenefitsModule.KINDS,
13723
+ tiers: loyalty.TIERS,
13724
+ benefits: await tierBenefits.listBenefits({ include_archived: true }),
13725
+ notice: n.message.replace(/^tierBenefits[.:]\s*/, ""),
13726
+ }));
13727
+ }
13728
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.tier_benefit.create", outcome: "success" });
13729
+ _redirect(res, "/admin/loyalty/tier-benefits?created=1");
13730
+ },
13731
+ ));
13732
+
13733
+ router.post("/admin/loyalty/tier-benefits/:slug/archive", _pageOrApi(false,
13734
+ W("loyalty.tier_benefit.archive", async function (req, res) {
13735
+ var benefit;
13736
+ try { benefit = await tierBenefits.archiveBenefit(req.params.slug); }
13737
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
13738
+ _json(res, 200, benefit);
13739
+ return { id: req.params.slug };
13740
+ }),
13741
+ async function (req, res) {
13742
+ var slug = req.params.slug;
13743
+ try { await tierBenefits.archiveBenefit(slug); }
13744
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/loyalty/tier-benefits?err=1"); }
13745
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.tier_benefit.archive", outcome: "success", metadata: { slug: slug } });
13746
+ _redirect(res, "/admin/loyalty/tier-benefits?archived_ok=1");
13747
+ },
13748
+ ));
13749
+ }
13612
13750
  }
13613
13751
 
13614
13752
  // Patch builder for the loyalty earn-rule edit form. trigger is
@@ -22930,6 +23068,32 @@ function renderAdminLoyalty(opts) {
22930
23068
  "</div>";
22931
23069
  }
22932
23070
 
23071
+ // Tier-benefits summary — the perks each tier includes, with a link to
23072
+ // the authoring screen. The customer's /account/loyalty page surfaces
23073
+ // these as "what your tier includes". They are operator-defined
23074
+ // inclusions (not auto-applied at checkout), so the panel says so.
23075
+ var tierBenefitSummary;
23076
+ if (!opts.can_manage_tier_benefits) {
23077
+ tierBenefitSummary = "";
23078
+ } else {
23079
+ var tbRows = opts.tier_benefits || [];
23080
+ var tbBody = tbRows.map(function (ben) {
23081
+ return "<tr>" +
23082
+ "<td><strong>" + _htmlEscape(ben.slug) + "</strong></td>" +
23083
+ "<td>" + _htmlEscape(String(ben.tier)) + "</td>" +
23084
+ "<td>" + _htmlEscape(String(ben.kind)) + "</td>" +
23085
+ "<td>" + _tierBenefitValueLabel(ben) + "</td>" +
23086
+ "</tr>";
23087
+ }).join("");
23088
+ tierBenefitSummary = "<div class=\"panel\"><div class=\"actions-row\"><h3 class=\"subhead\">Tier benefits</h3>" +
23089
+ "<a class=\"btn btn--ghost\" href=\"/admin/loyalty/tier-benefits\">Manage tier benefits</a></div>" +
23090
+ "<p class=\"meta\">The perks a tier includes — shown to customers on their rewards page as what their tier includes. The shop honours them; they aren't applied automatically at checkout.</p>" +
23091
+ (tbRows.length
23092
+ ? _tableWrap("<table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Tier</th><th scope=\"col\">Kind</th><th scope=\"col\">Value</th></tr></thead><tbody>" + tbBody + "</tbody></table>")
23093
+ : "<p class=\"empty\">No tier benefits yet. Customers see a perk list only once you add benefits.</p>") +
23094
+ "</div>";
23095
+ }
23096
+
22933
23097
  // Points-adjustment form. The customer id is pasted (the customers
22934
23098
  // roster shows each customer's id); the amount is a positive integer; a
22935
23099
  // grant / deduct radio sets the sign; the reason is required and lands
@@ -22957,7 +23121,7 @@ function renderAdminLoyalty(opts) {
22957
23121
  "</div>";
22958
23122
 
22959
23123
  var bodyHtml = "<section><h2>Loyalty</h2>" + adjusted + notice +
22960
- tierTable + earnSummary + rewardSummary + adjustForm + "</section>";
23124
+ tierTable + earnSummary + rewardSummary + tierBenefitSummary + adjustForm + "</section>";
22961
23125
  return _renderAdminShell(opts.shop_name, "Loyalty", bodyHtml, "loyalty", opts.nav_available);
22962
23126
  }
22963
23127
 
@@ -23183,6 +23347,96 @@ function renderAdminLoyaltyReward(opts) {
23183
23347
  return _renderAdminShell(opts.shop_name, "Reward", body, "loyalty", opts.nav_available);
23184
23348
  }
23185
23349
 
23350
+ // A tier benefit's value rendered for the admin list — the typed
23351
+ // value_json per kind, kept terse. Escaped (operator-authored fields).
23352
+ function _tierBenefitValueLabel(benefit) {
23353
+ var v = benefit && benefit.value ? benefit.value : {};
23354
+ switch (benefit && benefit.kind) {
23355
+ case "free_shipping":
23356
+ return v.min_order_minor != null
23357
+ ? _htmlEscape("min order " + String(v.min_order_minor) + " minor")
23358
+ : _htmlEscape("any order");
23359
+ case "percent_off": return _htmlEscape(String(v.percent || 0) + "%");
23360
+ case "early_access": return _htmlEscape(String(v.hours || 0) + "h early");
23361
+ case "priority_support": return _htmlEscape(String(v.sla_minutes || 0) + " min SLA");
23362
+ case "exclusive_access": return _htmlEscape(String(v.collection_slug || ""));
23363
+ case "birthday_bonus":
23364
+ if (v.points != null) return _htmlEscape(String(v.points) + " pts");
23365
+ if (v.percent_off != null) return _htmlEscape(String(v.percent_off) + "% off");
23366
+ return _htmlEscape("—");
23367
+ default: return _htmlEscape("—");
23368
+ }
23369
+ }
23370
+
23371
+ // Tier-benefits list + create form. A benefit is a (tier, kind) pair plus
23372
+ // a typed value — the perks the customer's /account/loyalty page surfaces
23373
+ // as "what your tier includes". These are operator-defined inclusions: the
23374
+ // framework does NOT auto-apply them at checkout / shipping / earn, so the
23375
+ // create-form copy says so (and the customer page frames them the same).
23376
+ function renderAdminTierBenefits(opts) {
23377
+ opts = opts || {};
23378
+ var benefits = opts.benefits || [];
23379
+ var kinds = opts.kinds || tierBenefitsModule.KINDS;
23380
+ var tiers = opts.tiers || [];
23381
+ var created = opts.created ? "<div class=\"banner banner--ok\">Benefit saved.</div>" : "";
23382
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Benefit archived.</div>" : "";
23383
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
23384
+
23385
+ var bodyRows = benefits.map(function (ben) {
23386
+ var enc = encodeURIComponent(ben.slug);
23387
+ return "<tr>" +
23388
+ "<td><strong>" + _htmlEscape(ben.slug) + "</strong></td>" +
23389
+ "<td>" + _htmlEscape(String(ben.tier)) + "</td>" +
23390
+ "<td>" + _htmlEscape(String(ben.kind)) + "</td>" +
23391
+ "<td>" + _tierBenefitValueLabel(ben) + "</td>" +
23392
+ "<td>" + (ben.archived_at == null
23393
+ ? "<span class=\"status-pill paid\">active</span>"
23394
+ : "<span class=\"status-pill\">archived</span>") + "</td>" +
23395
+ "<td><div class=\"actions-row\">" +
23396
+ (ben.archived_at == null
23397
+ ? "<form method=\"post\" action=\"/admin/loyalty/tier-benefits/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
23398
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>"
23399
+ : "") +
23400
+ "</div></td>" +
23401
+ "</tr>";
23402
+ }).join("");
23403
+ var table = benefits.length
23404
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Tier</th><th scope=\"col\">Kind</th><th scope=\"col\">Value</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table>") + "</div>"
23405
+ : "<p class=\"empty\">No tier benefits yet. Add one below so customers see what their tier includes.</p>";
23406
+
23407
+ var kindOpts = kinds.map(function (k) {
23408
+ return "<option value=\"" + _htmlEscape(k) + "\">" + _htmlEscape(k) + "</option>";
23409
+ }).join("");
23410
+ var tierField = tiers.length
23411
+ ? "<label class=\"form-field\"><span>Tier</span><select name=\"tier\">" +
23412
+ tiers.map(function (t) { return "<option value=\"" + _htmlEscape(t) + "\">" + _htmlEscape(t) + "</option>"; }).join("") +
23413
+ "</select><small>The tier this perk is included with.</small></label>"
23414
+ : _setupField("Tier", "tier", "", "text", "The tier this perk is included with (e.g. gold).", " maxlength=\"32\" required");
23415
+
23416
+ var createForm =
23417
+ "<div class=\"panel mt mw-40\"><h3 class=\"subhead\">Add a tier benefit</h3>" +
23418
+ "<p class=\"meta\">A benefit is a perk a tier includes. Fill only the value fields that match the kind: free_shipping → optional minimum order (minor units, blank for any); percent_off → percent (1–100); early_access → hours; priority_support → SLA minutes; exclusive_access → a collection slug; birthday_bonus → either points or a percent off (exactly one). These perks are shown to customers as what their tier includes — the shop honours them; they are not applied automatically at checkout.</p>" +
23419
+ "<form method=\"post\" action=\"/admin/loyalty/tier-benefits\">" +
23420
+ _setupField("Slug", "slug", "", "text", "Letters, digits, dashes, dots, underscores (e.g. platinum-free-ship).", " maxlength=\"80\" required") +
23421
+ tierField +
23422
+ "<label class=\"form-field\"><span>Kind</span><select name=\"kind\">" + kindOpts + "</select></label>" +
23423
+ _setupField("Free shipping: min order (minor)", "value_min_order_minor", "", "number", "free_shipping only — minimum order in minor units. Blank for any order.", " min=\"0\" step=\"1\" class=\"input-code\"") +
23424
+ _setupField("Percent off", "value_percent", "", "number", "percent_off only — 1 to 100.", " min=\"1\" max=\"100\" step=\"1\" class=\"input-code\"") +
23425
+ _setupField("Early access (hours)", "value_hours", "", "number", "early_access only — 1 to 720.", " min=\"1\" max=\"720\" step=\"1\" class=\"input-code\"") +
23426
+ _setupField("Priority support (SLA minutes)", "value_sla_minutes", "", "number", "priority_support only — 1 to 10080.", " min=\"1\" max=\"10080\" step=\"1\" class=\"input-code\"") +
23427
+ _setupField("Exclusive collection slug", "value_collection_slug", "", "text", "exclusive_access only — the collection slug.", " maxlength=\"80\"") +
23428
+ _setupField("Birthday bonus: points", "value_points", "", "number", "birthday_bonus only — points granted on the birthday (set this OR percent off).", " min=\"1\" step=\"1\" class=\"input-code\"") +
23429
+ _setupField("Birthday bonus: percent off", "value_percent_off", "", "number", "birthday_bonus only — percent off on the birthday (set this OR points).", " min=\"1\" max=\"100\" step=\"1\" class=\"input-code\"") +
23430
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add benefit</button></div>" +
23431
+ "</form>" +
23432
+ "</div>";
23433
+
23434
+ var bodyHtml = "<section><h2>Tier benefits</h2>" +
23435
+ "<p class=\"meta\"><a href=\"/admin/loyalty\">&larr; Loyalty</a></p>" +
23436
+ created + archived + notice + table + createForm + "</section>";
23437
+ return _renderAdminShell(opts.shop_name, "Tier benefits", bodyHtml, "loyalty", opts.nav_available);
23438
+ }
23439
+
23186
23440
  // Product detail / management screen — the console's full editor for a
23187
23441
  // single catalog product: its fields (slug / title / description /
23188
23442
  // status), its variants (create / edit / delete), each variant's price
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.4.53",
2
+ "version": "0.4.55",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
6
6
  "fingerprinted": "css/admin.6941d5151488a7c1.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-z6x2cNhNfsxxs7OZi3ZIg84HAcsJIUyhH4RjZwuHCdf03Rr68dm9zxciLgThkGLh",
10
- "fingerprinted": "css/main.cf0a6763075ece73.css"
9
+ "integrity": "sha384-wm9Kgl9osJlGxNW9swpY/yaFoS07Sw6ek5e9sce9RLd5W5BPwz9DcXs0wxym4oAn",
10
+ "fingerprinted": "css/main.9f60d689ff4715d7.css"
11
11
  },
12
12
  "js/announcement.js": {
13
13
  "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
package/lib/storefront.js CHANGED
@@ -5586,6 +5586,74 @@ function _loyaltyRewardValue(reward) {
5586
5586
  return reward.kind;
5587
5587
  }
5588
5588
 
5589
+ // A tier benefit's customer-facing label + value description, keyed by
5590
+ // the benefit `kind`. These are the operator-authored perks a tier
5591
+ // INCLUDES — not auto-applied guarantees (checkout/shipping/earn do not
5592
+ // consume tier-benefits), so the copy describes the inclusion in plain
5593
+ // language and the rendered page frames the whole set as "what your tier
5594
+ // includes". Falls back to the raw kind so a future kind still renders a
5595
+ // row. Returns plain strings — the caller esc()'s every field before it
5596
+ // reaches HTML.
5597
+ function _loyaltyTierBenefitLabel(benefit) {
5598
+ var v = benefit && benefit.value ? benefit.value : {};
5599
+ switch (benefit && benefit.kind) {
5600
+ case "free_shipping":
5601
+ return v.min_order_minor != null
5602
+ ? "Free shipping on orders over " + pricing.format(Number(v.min_order_minor) || 0, "USD")
5603
+ : "Free shipping";
5604
+ case "percent_off":
5605
+ return (Number(v.percent) || 0) + "% off your order";
5606
+ case "early_access":
5607
+ return "Early access — shop new drops " + (Number(v.hours) || 0) + " hours before everyone else";
5608
+ case "priority_support":
5609
+ return "Priority support" + (v.sla_minutes != null
5610
+ ? " — a reply within " + (Number(v.sla_minutes) || 0) + " minutes"
5611
+ : "");
5612
+ case "exclusive_access":
5613
+ return "Exclusive access to the " + String(v.collection_slug || "") + " collection";
5614
+ case "birthday_bonus":
5615
+ if (v.points != null) return "Birthday bonus — " + (Number(v.points) || 0) + " points on your birthday";
5616
+ if (v.percent_off != null) return "Birthday bonus — " + (Number(v.percent_off) || 0) + "% off on your birthday";
5617
+ return "Birthday bonus";
5618
+ default:
5619
+ return benefit && benefit.kind ? String(benefit.kind) : "Tier perk";
5620
+ }
5621
+ }
5622
+
5623
+ // Progress toward the next tier for a balance + the operator's tier
5624
+ // ladder. Returns the current tier, the next tier (or null at the top),
5625
+ // the lifetime points still needed, and a 0-100 percent for the bar.
5626
+ // Defensive read — a missing/garbage balance or threshold map degrades to
5627
+ // a sane "bronze, no progress" rather than throwing on the customer page.
5628
+ function _loyaltyTierProgress(tiers, thresholds, lifetime) {
5629
+ var ladder = Array.isArray(tiers) && tiers.length ? tiers : ["bronze"];
5630
+ var th = thresholds && typeof thresholds === "object" ? thresholds : {};
5631
+ var lp = Number(lifetime);
5632
+ if (!isFinite(lp) || lp < 0) lp = 0;
5633
+ // Walk the ladder from the top down; the current tier is the highest
5634
+ // whose threshold the lifetime total has reached. Mirrors
5635
+ // loyalty.computeTier's inclusive-on-upgrade ordering.
5636
+ var curIdx = 0;
5637
+ for (var i = ladder.length - 1; i >= 0; i -= 1) {
5638
+ var t = th[ladder[i]];
5639
+ if (typeof t === "number" && lp >= t) { curIdx = i; break; }
5640
+ }
5641
+ var current = ladder[curIdx];
5642
+ var nextTier = curIdx + 1 < ladder.length ? ladder[curIdx + 1] : null;
5643
+ if (nextTier == null) {
5644
+ return { current: current, next: null, remaining: 0, percent: 100 };
5645
+ }
5646
+ var curFloor = typeof th[current] === "number" ? th[current] : 0;
5647
+ var nextFloor = typeof th[nextTier] === "number" ? th[nextTier] : curFloor;
5648
+ var span = nextFloor - curFloor;
5649
+ var remaining = nextFloor - lp;
5650
+ if (remaining < 0) remaining = 0;
5651
+ var percent = span > 0 ? Math.floor(((lp - curFloor) / span) * 100) : 100;
5652
+ if (percent < 0) percent = 0;
5653
+ if (percent > 100) percent = 100;
5654
+ return { current: current, next: nextTier, remaining: remaining, percent: percent };
5655
+ }
5656
+
5589
5657
  // Earn-rule trigger → customer-facing phrase. Operators see slugs;
5590
5658
  // customers see plain language. Unknown triggers fall back to the raw
5591
5659
  // trigger so a future enum addition still renders something.
@@ -5625,6 +5693,54 @@ function renderLoyalty(opts) {
5625
5693
  "<div><dt>Worth</dt><dd>" + esc(spendableValue) + "</dd></div>" +
5626
5694
  "</dl>";
5627
5695
 
5696
+ // Progress toward the next tier — the points the customer still needs
5697
+ // and a labelled progress bar. At the top tier there's no "next", so we
5698
+ // congratulate instead of rendering a stalled bar.
5699
+ var prog = _loyaltyTierProgress(opts.tiers, opts.tier_thresholds, bal.lifetime);
5700
+ var progressSection;
5701
+ if (prog.next) {
5702
+ progressSection =
5703
+ "<h2 class=\"pdp__variants-title\">Your tier progress</h2>" +
5704
+ "<p class=\"return-card__meta\">You're <strong>" + esc(String(prog.current)) + "</strong>. " +
5705
+ "Earn <strong>" + esc(String(prog.remaining)) + "</strong> more lifetime points to reach <strong>" +
5706
+ esc(String(prog.next)) + "</strong>.</p>" +
5707
+ "<div class=\"loyalty-progress\" role=\"progressbar\" aria-valuenow=\"" + esc(String(prog.percent)) +
5708
+ "\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-label=\"Progress to " + esc(String(prog.next)) + " tier\">" +
5709
+ "<span class=\"loyalty-progress__bar\" style=\"width:" + esc(String(prog.percent)) + "%\"></span>" +
5710
+ "</div>";
5711
+ } else {
5712
+ progressSection =
5713
+ "<h2 class=\"pdp__variants-title\">Your tier progress</h2>" +
5714
+ "<p class=\"return-card__meta\">You've reached <strong>" + esc(String(prog.current)) +
5715
+ "</strong> — the top tier. Thanks for being a loyal customer.</p>";
5716
+ }
5717
+
5718
+ // Tier benefits — the perks the current tier INCLUDES. These are
5719
+ // operator-defined inclusions; the framework does not auto-apply them at
5720
+ // checkout, so the copy says "includes" and the footnote tells the
5721
+ // customer perks are applied by the shop, not automatically. Every
5722
+ // operator-authored field is esc()'d (manual HTML concat — a forgotten
5723
+ // esc is stored XSS).
5724
+ var benefits = opts.tier_benefits || [];
5725
+ var benefitsSection;
5726
+ if (benefits.length) {
5727
+ var benefitItems = "";
5728
+ for (var bi = 0; bi < benefits.length; bi += 1) {
5729
+ var ben = benefits[bi];
5730
+ benefitItems +=
5731
+ "<li class=\"return-card\"><div class=\"return-card__head\">" +
5732
+ "<span class=\"return-card__rma\">" + esc(_loyaltyTierBenefitLabel(ben)) + "</span>" +
5733
+ "<span class=\"pdp__badge\">" + esc(String(ben.tier || "")) + "</span>" +
5734
+ "</div></li>";
5735
+ }
5736
+ benefitsSection =
5737
+ "<h2 class=\"pdp__variants-title\">What your " + esc(String(bal.tier || "bronze")) + " tier includes</h2>" +
5738
+ "<ul class=\"return-list\">" + benefitItems + "</ul>" +
5739
+ "<p class=\"return-card__meta\">These perks come with your tier. Ask at checkout or contact support to have a perk applied to an order.</p>";
5740
+ } else {
5741
+ benefitsSection = "";
5742
+ }
5743
+
5628
5744
  // How points are earned.
5629
5745
  var rules = opts.earn_rules || [];
5630
5746
  var earnInner = "";
@@ -5732,6 +5848,8 @@ function renderLoyalty(opts) {
5732
5848
  "<h1 class=\"account-returns__title\">Rewards</h1>" +
5733
5849
  notice +
5734
5850
  stats +
5851
+ progressSection +
5852
+ benefitsSection +
5735
5853
  rewardSection +
5736
5854
  earnSection +
5737
5855
  redSection +
@@ -19890,6 +20008,21 @@ function mount(router, deps) {
19890
20008
  redemptions = rpage.rows;
19891
20009
  } catch (_e) { redemptions = []; }
19892
20010
  }
20011
+ // Tier benefits — the perks the customer's current tier includes.
20012
+ // Best-effort: a not-wired / not-migrated table degrades this panel
20013
+ // rather than the page. benefitsForCustomer resolves the tier off
20014
+ // the SAME loyalty handle (balance().tier), so it's the signed-in
20015
+ // customer's own tier — no IDOR surface (the id comes from auth,
20016
+ // never from a path/query param). No context envelope is passed:
20017
+ // the conditional gates (min order, currency, region, time window)
20018
+ // can't be evaluated on a static account page, so an
20019
+ // unconditional perk shows and a gated one is suppressed until
20020
+ // checkout — see _loyaltyTierBenefitLabel's framing.
20021
+ var tierBenefits = [];
20022
+ if (deps.tierBenefits) {
20023
+ try { tierBenefits = await deps.tierBenefits.benefitsForCustomer(auth.customer_id); }
20024
+ catch (_e) { tierBenefits = []; }
20025
+ }
19893
20026
  var cartCount = await _cartCountForReq(req);
19894
20027
  return {
19895
20028
  balance: bal,
@@ -19901,6 +20034,7 @@ function mount(router, deps) {
19901
20034
  earn_rules: rules,
19902
20035
  rewards: rewards,
19903
20036
  redemptions: redemptions,
20037
+ tier_benefits: tierBenefits,
19904
20038
  can_redeem: !!deps.loyaltyRedemption,
19905
20039
  notice: opts2.notice || null,
19906
20040
  notice_kind: opts2.notice_kind || null,
@@ -21819,6 +21953,7 @@ module.exports = {
21819
21953
  renderAccountPickups: renderAccountPickups,
21820
21954
  renderProfile: renderProfile,
21821
21955
  renderAccountSubscriptions: renderAccountSubscriptions,
21956
+ renderLoyalty: renderLoyalty,
21822
21957
  renderCookiePreferences: renderCookiePreferences,
21823
21958
  renderSurveyPage: renderSurveyPage,
21824
21959
  renderSuggestionsPage: renderSuggestionsPage,