@blamejs/blamejs-shop 0.4.53 → 0.4.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/admin.js +255 -1
- package/lib/asset-manifest.json +3 -3
- package/lib/storefront.js +135 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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\">← 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
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.54",
|
|
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-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
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,
|
package/package.json
CHANGED