@blamejs/blamejs-shop 0.3.23 → 0.3.24
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 +886 -1
- package/lib/asset-manifest.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.24 (2026-05-30) — **Manage the loyalty program from the admin console.** The loyalty program was visible to customers at /account/loyalty — their points balance, tier, the ways to earn, and the rewards — but there was no way to configure it from the console, so it could not actually be run. A new Loyalty admin screen now manages the whole program: create and edit the rules that award points (and archive ones that should no longer apply), build and edit the rewards catalog (a reward must be active to appear to customers), and grant or deduct a specific customer's points balance. A points adjustment requires a reason and is written to the loyalty ledger, so every manual change is recorded. **Added:** *Loyalty program administration* — A Loyalty screen in the admin console manages earn rules (the events that award points and how many), the rewards catalog (what points can be redeemed for, with per-kind values), and per-customer point adjustments. Only active, non-archived rules and rewards appear to customers. A manual grant or deduction requires a reason and is recorded in the loyalty ledger alongside automatic earn and redeem activity, and an over-deduction or invalid adjustment is rejected without changing the balance. This completes a program that previously could be shown to shoppers but not configured.
|
|
12
|
+
|
|
11
13
|
- v0.3.23 (2026-05-30) — **Fix page corruption from a dollar sign in content, keep search pages out of the index, and add a working unsubscribe link.** Three storefront fixes. First, a content-corruption bug: a dollar sign in any page's dynamic content — a product description, a blog post, a content page, a cart or admin page — could mangle the rendered HTML, and a dollar immediately followed by a backtick could splice the page's head markup into the body. Every page assembled its body in a way that gave dollar sequences special meaning; all of them now insert content verbatim. Second, internal search result pages are now marked noindex so the many query and filter URLs are not indexed as thin, duplicate pages (their product links are still followed). Third, the unsubscribe link that marketing emails point to now has a route behind it, so a recipient can actually unsubscribe (and resubscribe) instead of hitting a missing page. **Added:** *Newsletter unsubscribe link works* — A /unsubscribe route now backs the one-click unsubscribe link in marketing emails: opening the link shows a confirmation page, confirming removes the address, and a resubscribe option is offered. Invalid, expired, and already-used links each show a clear page rather than an error. The backend for this already existed; it simply had no route. **Fixed:** *A dollar sign in content no longer corrupts the page* — Pages were assembled by substituting the body into a template in a way that treated dollar sequences (such as a dollar followed by a backtick) as special, so content containing them could corrupt the output or pull head markup into the body. Every render path now inserts dynamic content literally, so any text — prices, code samples, anything with a dollar sign — renders exactly as written. · *Search result pages are no longer indexed* — Internal search result pages now carry a noindex robots tag so the large number of query and facet URL combinations aren't indexed as thin or duplicate content; the product links on those pages are still followed.
|
|
12
14
|
|
|
13
15
|
- v0.3.22 (2026-05-30) — **Create and publish content pages (About, Shipping, Returns, and the like) from the admin console.** The shop now has editable content pages. An admin Pages screen lets an operator create a page with a slug, title, Markdown body, layout, and meta fields, then publish, unpublish, archive, or restore it — and the storefront serves each published page at /pages/<slug>. Previously the content-page backend existed but had no admin screen and the storefront did not serve it, so it could not be used; a page is created as a draft and is not visible on the storefront until it is published. **Added:** *Content pages: author in the console, serve on the storefront* — A new Pages admin screen manages content pages through their full lifecycle (draft, published, archived, with restore). Published pages are served at /pages/<slug> with the page's chosen layout; a draft or archived slug returns not-found. The Markdown body is rendered with the same escaping and safe-link handling as the rest of the storefront. This makes a standard set of pages — about, shipping, returns, and similar — editable instead of requiring a code change.
|
package/lib/admin.js
CHANGED
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
var pricing = require("./pricing");
|
|
34
34
|
var collectionsModule = require("./collections");
|
|
35
35
|
var quantityDiscountsModule = require("./quantity-discounts");
|
|
36
|
+
var loyaltyEarnRulesModule = require("./loyalty-earn-rules");
|
|
37
|
+
var loyaltyRedemptionModule = require("./loyalty-redemption");
|
|
36
38
|
var textGuard = require("./text-guard");
|
|
37
39
|
var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
|
|
38
40
|
|
|
@@ -510,7 +512,7 @@ function mount(router, deps) {
|
|
|
510
512
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
511
513
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
512
514
|
// notice when the salesReports primitive isn't wired.
|
|
513
|
-
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, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
|
|
515
|
+
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, 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 };
|
|
514
516
|
|
|
515
517
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
516
518
|
|
|
@@ -6628,6 +6630,535 @@ function mount(router, deps) {
|
|
|
6628
6630
|
));
|
|
6629
6631
|
}
|
|
6630
6632
|
|
|
6633
|
+
// ---- loyalty --------------------------------------------------------
|
|
6634
|
+
// Manage the loyalty program from the console: the earn rules that mint
|
|
6635
|
+
// points, the rewards catalog customers redeem against, and a direct
|
|
6636
|
+
// per-customer points adjustment (grant / deduct with a required reason,
|
|
6637
|
+
// recorded in the loyalty ledger). The customer-facing /account/loyalty
|
|
6638
|
+
// page shows ONLY active earn rules + active rewards, so the console
|
|
6639
|
+
// flags inactive rows and an archive removes them from the storefront.
|
|
6640
|
+
//
|
|
6641
|
+
// `loyalty` (the points ledger) is the core dep — the overview + the
|
|
6642
|
+
// adjustment action mount on it. Earn rules + the rewards catalog mount
|
|
6643
|
+
// additionally on their own primitives, so a deployment that wired only
|
|
6644
|
+
// the ledger still gets the adjustment surface (and the nav link).
|
|
6645
|
+
if (deps.loyalty) {
|
|
6646
|
+
var loyalty = deps.loyalty;
|
|
6647
|
+
var loyaltyEarnRules = deps.loyaltyEarnRules || null;
|
|
6648
|
+
var loyaltyRedemption = deps.loyaltyRedemption || null;
|
|
6649
|
+
|
|
6650
|
+
// Validate the operator's free-text adjustment reason at the route.
|
|
6651
|
+
// A points adjustment is a money-adjacent action, so the reason is
|
|
6652
|
+
// MANDATORY — it rides into the ledger row's `notes` column. Throws a
|
|
6653
|
+
// TypeError (→ 400) on a missing / blank / over-long / control-byte
|
|
6654
|
+
// reason so the browser form surfaces a clean notice and writes
|
|
6655
|
+
// nothing. The amount goes through the strict signed-integer reader
|
|
6656
|
+
// (refuses "", floats, "12abc") and must be non-zero.
|
|
6657
|
+
var LOYALTY_REASON_MAX = 256;
|
|
6658
|
+
function _loyaltyReason(raw) {
|
|
6659
|
+
if (raw == null) throw new TypeError("admin: a reason is required for a points adjustment");
|
|
6660
|
+
var s = String(raw).trim();
|
|
6661
|
+
if (!s.length) throw new TypeError("admin: a reason is required for a points adjustment");
|
|
6662
|
+
if (s.length > LOYALTY_REASON_MAX) throw new TypeError("admin: reason must be <= " + LOYALTY_REASON_MAX + " chars");
|
|
6663
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) throw new TypeError("admin: reason must not contain control bytes");
|
|
6664
|
+
return s;
|
|
6665
|
+
}
|
|
6666
|
+
// Coerce the grant/deduct form into a signed delta. `direction` is
|
|
6667
|
+
// "grant" (positive) or "deduct" (negative); `amount` is a positive
|
|
6668
|
+
// integer count of points. The two compose into the non-zero signed
|
|
6669
|
+
// delta loyalty.adjust expects, so the operator never types a sign.
|
|
6670
|
+
function _loyaltyDelta(body) {
|
|
6671
|
+
var amount = _strictMinorInt(body.amount, "admin", "amount");
|
|
6672
|
+
if (amount <= 0) throw new TypeError("admin: amount must be a positive integer");
|
|
6673
|
+
var dir = typeof body.direction === "string" ? body.direction.trim() : "";
|
|
6674
|
+
if (dir !== "grant" && dir !== "deduct") {
|
|
6675
|
+
throw new TypeError("admin: direction must be 'grant' or 'deduct'");
|
|
6676
|
+
}
|
|
6677
|
+
return dir === "deduct" ? -amount : amount;
|
|
6678
|
+
}
|
|
6679
|
+
|
|
6680
|
+
// Build the overview model: thresholds + ratios from the ledger, plus
|
|
6681
|
+
// (best-effort) the earn-rule + reward lists. A not-wired or
|
|
6682
|
+
// not-migrated sub-primitive degrades that panel rather than the page.
|
|
6683
|
+
async function _loyaltyOverview(flags) {
|
|
6684
|
+
flags = flags || {};
|
|
6685
|
+
var earnRules = null;
|
|
6686
|
+
if (loyaltyEarnRules) {
|
|
6687
|
+
try { earnRules = await loyaltyEarnRules.listRules({ limit: 200 }); }
|
|
6688
|
+
catch (_e) { earnRules = []; }
|
|
6689
|
+
}
|
|
6690
|
+
var rewards = null;
|
|
6691
|
+
if (loyaltyRedemption) {
|
|
6692
|
+
try { rewards = await loyaltyRedemption.listRewards({ limit: 200 }); }
|
|
6693
|
+
catch (_e) { rewards = []; }
|
|
6694
|
+
}
|
|
6695
|
+
return Object.assign({
|
|
6696
|
+
shop_name: deps.shop_name,
|
|
6697
|
+
nav_available: navAvailable,
|
|
6698
|
+
tiers: loyalty.TIERS,
|
|
6699
|
+
tier_thresholds: loyalty.TIER_THRESHOLDS,
|
|
6700
|
+
points_per_usd: loyalty.POINTS_PER_USD,
|
|
6701
|
+
redemption_points_per_usd: loyalty.REDEMPTION_POINTS_PER_USD,
|
|
6702
|
+
earn_triggers: loyaltyEarnRulesModule.TRIGGERS,
|
|
6703
|
+
reward_kinds: loyaltyRedemptionModule.KINDS,
|
|
6704
|
+
earn_rules: earnRules,
|
|
6705
|
+
rewards: rewards,
|
|
6706
|
+
can_manage_rules: !!loyaltyEarnRules,
|
|
6707
|
+
can_manage_rewards: !!loyaltyRedemption,
|
|
6708
|
+
}, flags);
|
|
6709
|
+
}
|
|
6710
|
+
|
|
6711
|
+
router.get("/admin/loyalty", _pageOrApi(true,
|
|
6712
|
+
R(async function (_req, res) {
|
|
6713
|
+
// Bearer JSON: the program's configuration snapshot.
|
|
6714
|
+
_json(res, 200, {
|
|
6715
|
+
tiers: loyalty.TIERS,
|
|
6716
|
+
tier_thresholds: loyalty.TIER_THRESHOLDS,
|
|
6717
|
+
points_per_usd: loyalty.POINTS_PER_USD,
|
|
6718
|
+
redemption_points_per_usd: loyalty.REDEMPTION_POINTS_PER_USD,
|
|
6719
|
+
earn_rules: loyaltyEarnRules ? await loyaltyEarnRules.listRules({ limit: 200 }) : null,
|
|
6720
|
+
rewards: loyaltyRedemption ? await loyaltyRedemption.listRewards({ limit: 200 }) : null,
|
|
6721
|
+
});
|
|
6722
|
+
}),
|
|
6723
|
+
async function (req, res) {
|
|
6724
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6725
|
+
var flags = {
|
|
6726
|
+
adjusted: url && url.searchParams.get("adjusted"),
|
|
6727
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
|
|
6728
|
+
};
|
|
6729
|
+
// A failed adjustment round-trips its message through a one-shot
|
|
6730
|
+
// query flag set on the redirect (kept short + already escaped on
|
|
6731
|
+
// render) so the operator sees WHY without a 5xx.
|
|
6732
|
+
if (url && url.searchParams.get("adjust_err")) {
|
|
6733
|
+
flags.adjust_notice = url.searchParams.get("adjust_err");
|
|
6734
|
+
}
|
|
6735
|
+
_sendHtml(res, 200, renderAdminLoyalty(await _loyaltyOverview(flags)));
|
|
6736
|
+
},
|
|
6737
|
+
));
|
|
6738
|
+
|
|
6739
|
+
// Points adjustment — grant or deduct a specific customer's balance
|
|
6740
|
+
// with a required reason. Composes loyalty.adjust, which writes the
|
|
6741
|
+
// signed delta to loyalty_transactions (the audited ledger) and
|
|
6742
|
+
// recomputes the tier. A bad customer id / amount / missing reason is
|
|
6743
|
+
// a clean 4xx with nothing written; an underflowing deduction surfaces
|
|
6744
|
+
// the primitive's LOYALTY_INSUFFICIENT_BALANCE as a notice.
|
|
6745
|
+
router.post("/admin/loyalty/adjust", _pageOrApi(false,
|
|
6746
|
+
W("loyalty.adjust", async function (req, res) {
|
|
6747
|
+
var body = req.body || {};
|
|
6748
|
+
var customerId = body.customer_id;
|
|
6749
|
+
var delta;
|
|
6750
|
+
var reason;
|
|
6751
|
+
try {
|
|
6752
|
+
delta = _loyaltyDelta(body);
|
|
6753
|
+
reason = _loyaltyReason(body.reason);
|
|
6754
|
+
} catch (e) {
|
|
6755
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
6756
|
+
throw e;
|
|
6757
|
+
}
|
|
6758
|
+
var result;
|
|
6759
|
+
try {
|
|
6760
|
+
result = await loyalty.adjust({
|
|
6761
|
+
customer_id: customerId,
|
|
6762
|
+
points: delta,
|
|
6763
|
+
source: "admin-adjustment",
|
|
6764
|
+
notes: reason,
|
|
6765
|
+
});
|
|
6766
|
+
} catch (e) {
|
|
6767
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
6768
|
+
if (e && e.code === "LOYALTY_INSUFFICIENT_BALANCE") {
|
|
6769
|
+
return _problem(res, 409, "insufficient-balance", "Adjustment would drop the balance below zero.");
|
|
6770
|
+
}
|
|
6771
|
+
throw e;
|
|
6772
|
+
}
|
|
6773
|
+
_json(res, 200, result);
|
|
6774
|
+
return { id: customerId };
|
|
6775
|
+
}),
|
|
6776
|
+
async function (req, res) {
|
|
6777
|
+
var body = req.body || {};
|
|
6778
|
+
var customerId = body.customer_id;
|
|
6779
|
+
var cidPrefill = typeof customerId === "string" ? customerId : "";
|
|
6780
|
+
// Re-render the overview with the adjustment form's error notice.
|
|
6781
|
+
// The message is classified by the shared _safeNotice funnel (so
|
|
6782
|
+
// the cookie/HTML path can never reveal more than the bearer JSON
|
|
6783
|
+
// path); the `admin:`/`loyalty:` namespace prefix is stripped for
|
|
6784
|
+
// the operator. An insufficient-balance refusal is the one
|
|
6785
|
+
// primitive code _safeNotice would otherwise genericize to a 500,
|
|
6786
|
+
// so it's mapped to a clean 4xx notice ahead of the funnel.
|
|
6787
|
+
async function _adjustFail(e) {
|
|
6788
|
+
if (e && e.code === "LOYALTY_INSUFFICIENT_BALANCE") {
|
|
6789
|
+
return _sendHtml(res, 409, renderAdminLoyalty(await _loyaltyOverview({
|
|
6790
|
+
adjust_notice: "That deduction would drop the balance below zero.",
|
|
6791
|
+
adjust_customer_id: cidPrefill,
|
|
6792
|
+
})));
|
|
6793
|
+
}
|
|
6794
|
+
var n = _safeNotice(e, "loyalty.adjust");
|
|
6795
|
+
return _sendHtml(res, n.status, renderAdminLoyalty(await _loyaltyOverview({
|
|
6796
|
+
adjust_notice: n.message.replace(/^(?:admin|loyalty)[.:]\s*/, ""),
|
|
6797
|
+
adjust_customer_id: cidPrefill,
|
|
6798
|
+
})));
|
|
6799
|
+
}
|
|
6800
|
+
var delta;
|
|
6801
|
+
var reason;
|
|
6802
|
+
try {
|
|
6803
|
+
delta = _loyaltyDelta(body);
|
|
6804
|
+
reason = _loyaltyReason(body.reason);
|
|
6805
|
+
} catch (e) {
|
|
6806
|
+
return _adjustFail(e);
|
|
6807
|
+
}
|
|
6808
|
+
try {
|
|
6809
|
+
await loyalty.adjust({
|
|
6810
|
+
customer_id: customerId,
|
|
6811
|
+
points: delta,
|
|
6812
|
+
source: "admin-adjustment",
|
|
6813
|
+
notes: reason,
|
|
6814
|
+
});
|
|
6815
|
+
} catch (e) {
|
|
6816
|
+
return _adjustFail(e);
|
|
6817
|
+
}
|
|
6818
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.adjust", outcome: "success" });
|
|
6819
|
+
_redirect(res, "/admin/loyalty?adjusted=1");
|
|
6820
|
+
},
|
|
6821
|
+
));
|
|
6822
|
+
|
|
6823
|
+
// ---- earn rules ---------------------------------------------------
|
|
6824
|
+
if (loyaltyEarnRules) {
|
|
6825
|
+
// Translate the create / edit form into a defineRule / updateRule
|
|
6826
|
+
// input. The status list is a comma-separated free-text field
|
|
6827
|
+
// (e.g. "active, vip"); a blank field means no restriction (null).
|
|
6828
|
+
function _loyaltyStatusList(raw) {
|
|
6829
|
+
if (raw == null) return null;
|
|
6830
|
+
var s = String(raw).trim();
|
|
6831
|
+
if (!s.length) return null;
|
|
6832
|
+
return s.split(",").map(function (x) { return x.trim(); }).filter(Boolean);
|
|
6833
|
+
}
|
|
6834
|
+
function _earnDefineInput(body) {
|
|
6835
|
+
var input = {
|
|
6836
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
6837
|
+
trigger: typeof body.trigger === "string" ? body.trigger.trim() : body.trigger,
|
|
6838
|
+
points_per_unit: _strictMinorInt(body.points_per_unit, "admin", "points_per_unit"),
|
|
6839
|
+
active: (body.active === "on" || body.active === "1" || body.active === true),
|
|
6840
|
+
};
|
|
6841
|
+
var maxRaw = body.max_per_event == null ? "" : String(body.max_per_event).trim();
|
|
6842
|
+
if (maxRaw !== "") input.max_per_event = _strictMinorInt(maxRaw, "admin", "max_per_event");
|
|
6843
|
+
var statusList = _loyaltyStatusList(body.customer_status_in);
|
|
6844
|
+
if (statusList) input.customer_status_in = statusList;
|
|
6845
|
+
return input;
|
|
6846
|
+
}
|
|
6847
|
+
|
|
6848
|
+
router.get("/admin/loyalty/earn-rules", _pageOrApi(true,
|
|
6849
|
+
R(async function (_req, res) {
|
|
6850
|
+
_json(res, 200, { rows: await loyaltyEarnRules.listRules({ limit: 200 }) });
|
|
6851
|
+
}),
|
|
6852
|
+
async function (req, res) {
|
|
6853
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6854
|
+
_sendHtml(res, 200, renderAdminLoyaltyEarnRules({
|
|
6855
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
6856
|
+
triggers: loyaltyEarnRulesModule.TRIGGERS,
|
|
6857
|
+
rules: await loyaltyEarnRules.listRules({ limit: 200 }),
|
|
6858
|
+
created: url && url.searchParams.get("created"),
|
|
6859
|
+
saved: url && url.searchParams.get("saved"),
|
|
6860
|
+
archived: url && url.searchParams.get("archived_ok"),
|
|
6861
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the rule." : null,
|
|
6862
|
+
}));
|
|
6863
|
+
},
|
|
6864
|
+
));
|
|
6865
|
+
|
|
6866
|
+
router.post("/admin/loyalty/earn-rules", _pageOrApi(false,
|
|
6867
|
+
W("loyalty.earn_rule.create", async function (req, res) {
|
|
6868
|
+
var rule;
|
|
6869
|
+
try { rule = await loyaltyEarnRules.defineRule(_earnDefineInput(req.body || {})); }
|
|
6870
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
6871
|
+
_json(res, 201, rule);
|
|
6872
|
+
return { id: rule.slug };
|
|
6873
|
+
}),
|
|
6874
|
+
async function (req, res) {
|
|
6875
|
+
try {
|
|
6876
|
+
await loyaltyEarnRules.defineRule(_earnDefineInput(req.body || {}));
|
|
6877
|
+
} catch (e) {
|
|
6878
|
+
var n = _safeNotice(e, "loyalty.earn_rule.create");
|
|
6879
|
+
return _sendHtml(res, n.status, renderAdminLoyaltyEarnRules({
|
|
6880
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
6881
|
+
triggers: loyaltyEarnRulesModule.TRIGGERS,
|
|
6882
|
+
rules: await loyaltyEarnRules.listRules({ limit: 200 }),
|
|
6883
|
+
notice: n.message.replace(/^loyaltyEarnRules[.:]\s*/, ""),
|
|
6884
|
+
}));
|
|
6885
|
+
}
|
|
6886
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.earn_rule.create", outcome: "success" });
|
|
6887
|
+
_redirect(res, "/admin/loyalty/earn-rules?created=1");
|
|
6888
|
+
},
|
|
6889
|
+
));
|
|
6890
|
+
|
|
6891
|
+
router.get("/admin/loyalty/earn-rules/:slug", _pageOrApi(true,
|
|
6892
|
+
R(async function (req, res) {
|
|
6893
|
+
var rule;
|
|
6894
|
+
try { rule = await loyaltyEarnRules.getRule(req.params.slug); }
|
|
6895
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
6896
|
+
if (!rule) return _problem(res, 404, "loyalty-earn-rule-not-found");
|
|
6897
|
+
_json(res, 200, rule);
|
|
6898
|
+
}),
|
|
6899
|
+
async function (req, res) {
|
|
6900
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6901
|
+
var rule;
|
|
6902
|
+
try { rule = await loyaltyEarnRules.getRule(req.params.slug); }
|
|
6903
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; rule = null; }
|
|
6904
|
+
if (!rule) return _sendHtml(res, 404, renderAdminLoyaltyEarnRule({
|
|
6905
|
+
shop_name: deps.shop_name, nav_available: navAvailable, rule: null,
|
|
6906
|
+
}));
|
|
6907
|
+
_sendHtml(res, 200, renderAdminLoyaltyEarnRule({
|
|
6908
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
6909
|
+
triggers: loyaltyEarnRulesModule.TRIGGERS, rule: rule,
|
|
6910
|
+
saved: url && url.searchParams.get("saved"),
|
|
6911
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
|
|
6912
|
+
}));
|
|
6913
|
+
},
|
|
6914
|
+
));
|
|
6915
|
+
|
|
6916
|
+
router.post("/admin/loyalty/earn-rules/:slug/edit", _pageOrApi(false,
|
|
6917
|
+
W("loyalty.earn_rule.update", async function (req, res) {
|
|
6918
|
+
var patch = _earnPatchFromForm(req.body || {});
|
|
6919
|
+
var rule;
|
|
6920
|
+
try { rule = await loyaltyEarnRules.updateRule(req.params.slug, patch); }
|
|
6921
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
6922
|
+
if (!rule) return _problem(res, 404, "loyalty-earn-rule-not-found");
|
|
6923
|
+
_json(res, 200, rule);
|
|
6924
|
+
return { id: rule.slug };
|
|
6925
|
+
}),
|
|
6926
|
+
async function (req, res) {
|
|
6927
|
+
var slug = req.params.slug;
|
|
6928
|
+
var enc = encodeURIComponent(slug);
|
|
6929
|
+
try {
|
|
6930
|
+
var rule = await loyaltyEarnRules.updateRule(slug, _earnPatchFromForm(req.body || {}));
|
|
6931
|
+
if (!rule) return _redirect(res, "/admin/loyalty/earn-rules?err=1");
|
|
6932
|
+
} catch (e) {
|
|
6933
|
+
if (!(e instanceof TypeError)) throw e;
|
|
6934
|
+
return _redirect(res, "/admin/loyalty/earn-rules/" + enc + "?err=1");
|
|
6935
|
+
}
|
|
6936
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.earn_rule.update", outcome: "success", metadata: { slug: slug } });
|
|
6937
|
+
_redirect(res, "/admin/loyalty/earn-rules/" + enc + "?saved=1");
|
|
6938
|
+
},
|
|
6939
|
+
));
|
|
6940
|
+
|
|
6941
|
+
router.post("/admin/loyalty/earn-rules/:slug/archive", _pageOrApi(false,
|
|
6942
|
+
W("loyalty.earn_rule.archive", async function (req, res) {
|
|
6943
|
+
var rule;
|
|
6944
|
+
try { rule = await loyaltyEarnRules.archiveRule(req.params.slug); }
|
|
6945
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
6946
|
+
if (!rule) return _problem(res, 404, "loyalty-earn-rule-not-found");
|
|
6947
|
+
_json(res, 200, rule);
|
|
6948
|
+
return { id: req.params.slug };
|
|
6949
|
+
}),
|
|
6950
|
+
async function (req, res) {
|
|
6951
|
+
var slug = req.params.slug;
|
|
6952
|
+
try { await loyaltyEarnRules.archiveRule(slug); }
|
|
6953
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/loyalty/earn-rules?err=1"); }
|
|
6954
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.earn_rule.archive", outcome: "success", metadata: { slug: slug } });
|
|
6955
|
+
_redirect(res, "/admin/loyalty/earn-rules?archived_ok=1");
|
|
6956
|
+
},
|
|
6957
|
+
));
|
|
6958
|
+
}
|
|
6959
|
+
|
|
6960
|
+
// ---- rewards catalog ----------------------------------------------
|
|
6961
|
+
if (loyaltyRedemption) {
|
|
6962
|
+
// The reward's value_json shape depends on its kind. The console
|
|
6963
|
+
// collects one numeric field (percent / amount_minor / product_id)
|
|
6964
|
+
// and the create path assembles the per-kind payload the primitive
|
|
6965
|
+
// validates. free_shipping carries an empty object.
|
|
6966
|
+
function _rewardValueJson(kind, body) {
|
|
6967
|
+
if (kind === "discount_percent") {
|
|
6968
|
+
return { percent: _strictMinorInt(body.value_number, "admin", "percent") };
|
|
6969
|
+
}
|
|
6970
|
+
if (kind === "discount_amount") {
|
|
6971
|
+
return { amount_minor: _strictMinorInt(body.value_number, "admin", "amount_minor") };
|
|
6972
|
+
}
|
|
6973
|
+
if (kind === "free_product") {
|
|
6974
|
+
var pid = typeof body.value_text === "string" ? body.value_text.trim() : "";
|
|
6975
|
+
return { product_id: pid };
|
|
6976
|
+
}
|
|
6977
|
+
// free_shipping (or an unknown kind the primitive will reject).
|
|
6978
|
+
return {};
|
|
6979
|
+
}
|
|
6980
|
+
function _rewardDefineInput(body) {
|
|
6981
|
+
var kind = typeof body.kind === "string" ? body.kind.trim() : body.kind;
|
|
6982
|
+
var input = {
|
|
6983
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
6984
|
+
kind: kind,
|
|
6985
|
+
title: typeof body.title === "string" ? body.title : body.title,
|
|
6986
|
+
point_cost: _strictMinorInt(body.point_cost, "admin", "point_cost"),
|
|
6987
|
+
value_json: _rewardValueJson(kind, body),
|
|
6988
|
+
active: (body.active === "on" || body.active === "1" || body.active === true),
|
|
6989
|
+
};
|
|
6990
|
+
var maxRaw = body.max_per_customer == null ? "" : String(body.max_per_customer).trim();
|
|
6991
|
+
if (maxRaw !== "") input.max_per_customer = _strictMinorInt(maxRaw, "admin", "max_per_customer");
|
|
6992
|
+
var expRaw = body.expires_days_after_redemption == null ? "" : String(body.expires_days_after_redemption).trim();
|
|
6993
|
+
if (expRaw !== "") input.expires_days_after_redemption = _strictMinorInt(expRaw, "admin", "expires_days_after_redemption");
|
|
6994
|
+
return input;
|
|
6995
|
+
}
|
|
6996
|
+
|
|
6997
|
+
router.get("/admin/loyalty/rewards", _pageOrApi(true,
|
|
6998
|
+
R(async function (_req, res) {
|
|
6999
|
+
_json(res, 200, { rows: await loyaltyRedemption.listRewards({ limit: 200 }) });
|
|
7000
|
+
}),
|
|
7001
|
+
async function (req, res) {
|
|
7002
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
7003
|
+
_sendHtml(res, 200, renderAdminLoyaltyRewards({
|
|
7004
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
7005
|
+
kinds: loyaltyRedemptionModule.KINDS,
|
|
7006
|
+
rewards: await loyaltyRedemption.listRewards({ limit: 200 }),
|
|
7007
|
+
created: url && url.searchParams.get("created"),
|
|
7008
|
+
saved: url && url.searchParams.get("saved"),
|
|
7009
|
+
archived: url && url.searchParams.get("archived_ok"),
|
|
7010
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the reward." : null,
|
|
7011
|
+
}));
|
|
7012
|
+
},
|
|
7013
|
+
));
|
|
7014
|
+
|
|
7015
|
+
router.post("/admin/loyalty/rewards", _pageOrApi(false,
|
|
7016
|
+
W("loyalty.reward.create", async function (req, res) {
|
|
7017
|
+
var reward;
|
|
7018
|
+
try { reward = await loyaltyRedemption.defineReward(_rewardDefineInput(req.body || {})); }
|
|
7019
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7020
|
+
_json(res, 201, reward);
|
|
7021
|
+
return { id: reward.slug };
|
|
7022
|
+
}),
|
|
7023
|
+
async function (req, res) {
|
|
7024
|
+
try {
|
|
7025
|
+
await loyaltyRedemption.defineReward(_rewardDefineInput(req.body || {}));
|
|
7026
|
+
} catch (e) {
|
|
7027
|
+
var n = _safeNotice(e, "loyalty.reward.create");
|
|
7028
|
+
return _sendHtml(res, n.status, renderAdminLoyaltyRewards({
|
|
7029
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
7030
|
+
kinds: loyaltyRedemptionModule.KINDS,
|
|
7031
|
+
rewards: await loyaltyRedemption.listRewards({ limit: 200 }),
|
|
7032
|
+
notice: n.message.replace(/^loyaltyRedemption[.:]\s*/, ""),
|
|
7033
|
+
}));
|
|
7034
|
+
}
|
|
7035
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.reward.create", outcome: "success" });
|
|
7036
|
+
_redirect(res, "/admin/loyalty/rewards?created=1");
|
|
7037
|
+
},
|
|
7038
|
+
));
|
|
7039
|
+
|
|
7040
|
+
router.get("/admin/loyalty/rewards/:slug", _pageOrApi(true,
|
|
7041
|
+
R(async function (req, res) {
|
|
7042
|
+
var reward;
|
|
7043
|
+
try { reward = await loyaltyRedemption.getReward(req.params.slug); }
|
|
7044
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7045
|
+
if (!reward) return _problem(res, 404, "loyalty-reward-not-found");
|
|
7046
|
+
_json(res, 200, reward);
|
|
7047
|
+
}),
|
|
7048
|
+
async function (req, res) {
|
|
7049
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
7050
|
+
var reward;
|
|
7051
|
+
try { reward = await loyaltyRedemption.getReward(req.params.slug); }
|
|
7052
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; reward = null; }
|
|
7053
|
+
if (!reward) return _sendHtml(res, 404, renderAdminLoyaltyReward({
|
|
7054
|
+
shop_name: deps.shop_name, nav_available: navAvailable, reward: null,
|
|
7055
|
+
}));
|
|
7056
|
+
_sendHtml(res, 200, renderAdminLoyaltyReward({
|
|
7057
|
+
shop_name: deps.shop_name, nav_available: navAvailable, reward: reward,
|
|
7058
|
+
saved: url && url.searchParams.get("saved"),
|
|
7059
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
|
|
7060
|
+
}));
|
|
7061
|
+
},
|
|
7062
|
+
));
|
|
7063
|
+
|
|
7064
|
+
router.post("/admin/loyalty/rewards/:slug/edit", _pageOrApi(false,
|
|
7065
|
+
W("loyalty.reward.update", async function (req, res) {
|
|
7066
|
+
var reward;
|
|
7067
|
+
try { reward = await loyaltyRedemption.updateReward(req.params.slug, await _rewardPatchFromForm(req.params.slug, req.body || {})); }
|
|
7068
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7069
|
+
_json(res, 200, reward);
|
|
7070
|
+
return { id: reward.slug };
|
|
7071
|
+
}),
|
|
7072
|
+
async function (req, res) {
|
|
7073
|
+
var slug = req.params.slug;
|
|
7074
|
+
var enc = encodeURIComponent(slug);
|
|
7075
|
+
try {
|
|
7076
|
+
await loyaltyRedemption.updateReward(slug, await _rewardPatchFromForm(slug, req.body || {}));
|
|
7077
|
+
} catch (e) {
|
|
7078
|
+
if (!(e instanceof TypeError)) throw e;
|
|
7079
|
+
return _redirect(res, "/admin/loyalty/rewards/" + enc + "?err=1");
|
|
7080
|
+
}
|
|
7081
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.reward.update", outcome: "success", metadata: { slug: slug } });
|
|
7082
|
+
_redirect(res, "/admin/loyalty/rewards/" + enc + "?saved=1");
|
|
7083
|
+
},
|
|
7084
|
+
));
|
|
7085
|
+
|
|
7086
|
+
router.post("/admin/loyalty/rewards/:slug/archive", _pageOrApi(false,
|
|
7087
|
+
W("loyalty.reward.archive", async function (req, res) {
|
|
7088
|
+
var reward;
|
|
7089
|
+
try { reward = await loyaltyRedemption.archiveReward(req.params.slug); }
|
|
7090
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7091
|
+
_json(res, 200, reward);
|
|
7092
|
+
return { id: req.params.slug };
|
|
7093
|
+
}),
|
|
7094
|
+
async function (req, res) {
|
|
7095
|
+
var slug = req.params.slug;
|
|
7096
|
+
try { await loyaltyRedemption.archiveReward(slug); }
|
|
7097
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/loyalty/rewards?err=1"); }
|
|
7098
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.reward.archive", outcome: "success", metadata: { slug: slug } });
|
|
7099
|
+
_redirect(res, "/admin/loyalty/rewards?archived_ok=1");
|
|
7100
|
+
},
|
|
7101
|
+
));
|
|
7102
|
+
|
|
7103
|
+
// Build the updateReward patch from the edit form. Only the columns
|
|
7104
|
+
// the operator changed are forwarded; value_json is re-validated
|
|
7105
|
+
// against the reward's STORED kind (kind is immutable on update), so
|
|
7106
|
+
// the helper reads the current reward to resolve it.
|
|
7107
|
+
async function _rewardPatchFromForm(slug, body) {
|
|
7108
|
+
var patch = {};
|
|
7109
|
+
if (typeof body.title === "string" && body.title.length) patch.title = body.title;
|
|
7110
|
+
if (body.point_cost != null && String(body.point_cost).trim() !== "") {
|
|
7111
|
+
patch.point_cost = _strictMinorInt(body.point_cost, "admin", "point_cost");
|
|
7112
|
+
}
|
|
7113
|
+
if (body.active_present === "1") {
|
|
7114
|
+
patch.active = (body.active === "on" || body.active === "1");
|
|
7115
|
+
}
|
|
7116
|
+
var maxRaw = body.max_per_customer == null ? "" : String(body.max_per_customer).trim();
|
|
7117
|
+
if (maxRaw !== "") patch.max_per_customer = _strictMinorInt(maxRaw, "admin", "max_per_customer");
|
|
7118
|
+
var expRaw = body.expires_days_after_redemption == null ? "" : String(body.expires_days_after_redemption).trim();
|
|
7119
|
+
if (expRaw !== "") patch.expires_days_after_redemption = _strictMinorInt(expRaw, "admin", "expires_days_after_redemption");
|
|
7120
|
+
// A supplied value field rewrites value_json against the stored
|
|
7121
|
+
// kind. Absent it, value_json is left untouched.
|
|
7122
|
+
if ((body.value_number != null && String(body.value_number).trim() !== "") ||
|
|
7123
|
+
(typeof body.value_text === "string" && body.value_text.trim() !== "")) {
|
|
7124
|
+
var current = await loyaltyRedemption.getReward(slug);
|
|
7125
|
+
if (current) patch.value_json = _rewardValueJson(current.kind, body);
|
|
7126
|
+
}
|
|
7127
|
+
return patch;
|
|
7128
|
+
}
|
|
7129
|
+
}
|
|
7130
|
+
}
|
|
7131
|
+
|
|
7132
|
+
// Patch builder for the loyalty earn-rule edit form. trigger is
|
|
7133
|
+
// immutable on update (the primitive refuses a change), so the form
|
|
7134
|
+
// never sends it; only points_per_unit / max_per_event /
|
|
7135
|
+
// customer_status_in / active are editable. Hoisted above the rewards
|
|
7136
|
+
// block's IIFE-free scope so both the bearer + browser edit paths share
|
|
7137
|
+
// it. Defined as a closure inside mount() so it can stay near its
|
|
7138
|
+
// callers without polluting the module surface.
|
|
7139
|
+
function _earnPatchFromForm(body) {
|
|
7140
|
+
var patch = {};
|
|
7141
|
+
if (body.points_per_unit != null && String(body.points_per_unit).trim() !== "") {
|
|
7142
|
+
patch.points_per_unit = _strictMinorInt(body.points_per_unit, "admin", "points_per_unit");
|
|
7143
|
+
}
|
|
7144
|
+
if (body.max_per_event != null) {
|
|
7145
|
+
var maxRaw = String(body.max_per_event).trim();
|
|
7146
|
+
// An explicit "0" / "" clears the cap (null); a positive value sets it.
|
|
7147
|
+
if (maxRaw === "" || maxRaw === "0") patch.max_per_event = null;
|
|
7148
|
+
else patch.max_per_event = _strictMinorInt(maxRaw, "admin", "max_per_event");
|
|
7149
|
+
}
|
|
7150
|
+
if (body.customer_status_in != null) {
|
|
7151
|
+
var s = String(body.customer_status_in).trim();
|
|
7152
|
+
patch.customer_status_in = s.length
|
|
7153
|
+
? s.split(",").map(function (x) { return x.trim(); }).filter(Boolean)
|
|
7154
|
+
: null;
|
|
7155
|
+
}
|
|
7156
|
+
if (body.active_present === "1") {
|
|
7157
|
+
patch.active = (body.active === "on" || body.active === "1");
|
|
7158
|
+
}
|
|
7159
|
+
return patch;
|
|
7160
|
+
}
|
|
7161
|
+
|
|
6631
7162
|
// Render the discounts screen — gathers rules + (optional) stacking
|
|
6632
7163
|
// policies, then hands them to the renderer with whatever banner flags
|
|
6633
7164
|
// the caller passed.
|
|
@@ -7084,6 +7615,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
7084
7615
|
{ key: "discounts", href: "/admin/discounts", label: "Discounts", requires: "autoDiscount" },
|
|
7085
7616
|
{ key: "discount-allocation", href: "/admin/discount-allocation", label: "Discount splits", requires: "discountAllocation" },
|
|
7086
7617
|
{ key: "quantity-discounts", href: "/admin/quantity-discounts", label: "Quantity breaks", requires: "quantityDiscounts" },
|
|
7618
|
+
{ key: "loyalty", href: "/admin/loyalty", label: "Loyalty", requires: "loyalty" },
|
|
7087
7619
|
{ key: "tax", href: "/admin/tax-rates", label: "Tax", requires: "taxRates" },
|
|
7088
7620
|
{ key: "tax-filings", href: "/admin/tax-filings", label: "Tax filings", requires: "salesTaxFilings" },
|
|
7089
7621
|
{ key: "shipping", href: "/admin/shipping", label: "Shipping", requires: "shippingZones" },
|
|
@@ -10960,6 +11492,354 @@ function renderAdminQuantityDiscount(opts) {
|
|
|
10960
11492
|
return _renderAdminShell(opts.shop_name, "Quantity break", body, "quantity-discounts", opts.nav_available);
|
|
10961
11493
|
}
|
|
10962
11494
|
|
|
11495
|
+
// ---- loyalty render functions ------------------------------------------
|
|
11496
|
+
|
|
11497
|
+
// Status pill for an active / inactive / archived loyalty row — shared by
|
|
11498
|
+
// the earn-rule + reward lists.
|
|
11499
|
+
function _loyaltyStatusPill(active, archivedAt) {
|
|
11500
|
+
if (archivedAt != null) return "<span class=\"status-pill cancelled\">archived</span>";
|
|
11501
|
+
return active
|
|
11502
|
+
? "<span class=\"status-pill paid\">active</span>"
|
|
11503
|
+
: "<span class=\"status-pill pending\">inactive</span>";
|
|
11504
|
+
}
|
|
11505
|
+
|
|
11506
|
+
// Render a reward's value_json as a short human label per kind.
|
|
11507
|
+
function _loyaltyRewardValueLabel(reward) {
|
|
11508
|
+
var v = reward.value_json || {};
|
|
11509
|
+
if (reward.kind === "discount_percent") return _htmlEscape(String(v.percent)) + "% off";
|
|
11510
|
+
if (reward.kind === "discount_amount") return _htmlEscape(String(v.amount_minor)) + " minor off";
|
|
11511
|
+
if (reward.kind === "free_product") return "free product " + _htmlEscape(String(v.product_id || ""));
|
|
11512
|
+
if (reward.kind === "free_shipping") return "free shipping";
|
|
11513
|
+
return _htmlEscape(reward.kind);
|
|
11514
|
+
}
|
|
11515
|
+
|
|
11516
|
+
// Loyalty overview / settings — the program's tiers + conversion ratios,
|
|
11517
|
+
// a summary of earn rules + rewards (with links to manage each), and the
|
|
11518
|
+
// per-customer points-adjustment form (grant / deduct with a required
|
|
11519
|
+
// reason, recorded in the loyalty ledger). The customer's /account/loyalty
|
|
11520
|
+
// page shows only ACTIVE earn rules + ACTIVE rewards, so the summary flags
|
|
11521
|
+
// inactive rows.
|
|
11522
|
+
function renderAdminLoyalty(opts) {
|
|
11523
|
+
opts = opts || {};
|
|
11524
|
+
var adjusted = opts.adjusted ? "<div class=\"banner banner--ok\">Points adjusted. The ledger recorded the change.</div>" : "";
|
|
11525
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
11526
|
+
var adjustNotice = opts.adjust_notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.adjust_notice) + "</div>" : "";
|
|
11527
|
+
|
|
11528
|
+
var thresholds = opts.tier_thresholds || {};
|
|
11529
|
+
var tiers = opts.tiers || [];
|
|
11530
|
+
var tierRows = tiers.map(function (t) {
|
|
11531
|
+
return "<tr><td><strong>" + _htmlEscape(t) + "</strong></td>" +
|
|
11532
|
+
"<td class=\"num\">" + _htmlEscape(String(thresholds[t] == null ? "—" : thresholds[t])) + "</td></tr>";
|
|
11533
|
+
}).join("");
|
|
11534
|
+
var tierTable =
|
|
11535
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Tiers</h3>" +
|
|
11536
|
+
"<p class=\"meta\">Lifetime points (never decremented) place a customer in a tier. " +
|
|
11537
|
+
"Earning $1 grants " + _htmlEscape(String(opts.points_per_usd)) + " points; " +
|
|
11538
|
+
_htmlEscape(String(opts.redemption_points_per_usd)) + " points redeem for $1 of value.</p>" +
|
|
11539
|
+
"<table><thead><tr><th scope=\"col\">Tier</th><th scope=\"col\" class=\"num\">Lifetime points to reach</th></tr></thead>" +
|
|
11540
|
+
"<tbody>" + tierRows + "</tbody></table></div>";
|
|
11541
|
+
|
|
11542
|
+
// Earn-rule summary.
|
|
11543
|
+
var earnSummary;
|
|
11544
|
+
if (!opts.can_manage_rules) {
|
|
11545
|
+
earnSummary = "<div class=\"panel\"><h3 class=\"subhead\">Earn rules</h3>" +
|
|
11546
|
+
"<p class=\"empty\">Earn-rule management isn't wired in this deployment.</p></div>";
|
|
11547
|
+
} else {
|
|
11548
|
+
var earnRules = opts.earn_rules || [];
|
|
11549
|
+
var earnRows = earnRules.map(function (r) {
|
|
11550
|
+
var enc = encodeURIComponent(r.slug);
|
|
11551
|
+
return "<tr>" +
|
|
11552
|
+
"<td><a href=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(r.slug) + "</strong></a></td>" +
|
|
11553
|
+
"<td>" + _htmlEscape(r.trigger) + "</td>" +
|
|
11554
|
+
"<td class=\"num\">" + _htmlEscape(String(r.points_per_unit)) + "</td>" +
|
|
11555
|
+
"<td>" + _loyaltyStatusPill(r.active, r.archived_at) + "</td>" +
|
|
11556
|
+
"</tr>";
|
|
11557
|
+
}).join("");
|
|
11558
|
+
earnSummary = "<div class=\"panel\"><div class=\"actions-row\"><h3 class=\"subhead\">Earn rules</h3>" +
|
|
11559
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/loyalty/earn-rules\">Manage earn rules</a></div>" +
|
|
11560
|
+
(earnRules.length
|
|
11561
|
+
? "<table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Trigger</th><th scope=\"col\" class=\"num\">Points/unit</th><th scope=\"col\">Status</th></tr></thead><tbody>" + earnRows + "</tbody></table>"
|
|
11562
|
+
: "<p class=\"empty\">No earn rules yet. Customers only earn points from active rules.</p>") +
|
|
11563
|
+
"</div>";
|
|
11564
|
+
}
|
|
11565
|
+
|
|
11566
|
+
// Reward summary.
|
|
11567
|
+
var rewardSummary;
|
|
11568
|
+
if (!opts.can_manage_rewards) {
|
|
11569
|
+
rewardSummary = "<div class=\"panel\"><h3 class=\"subhead\">Rewards catalog</h3>" +
|
|
11570
|
+
"<p class=\"empty\">Rewards-catalog management isn't wired in this deployment.</p></div>";
|
|
11571
|
+
} else {
|
|
11572
|
+
var rewards = opts.rewards || [];
|
|
11573
|
+
var rewardRows = rewards.map(function (rw) {
|
|
11574
|
+
var enc = encodeURIComponent(rw.slug);
|
|
11575
|
+
return "<tr>" +
|
|
11576
|
+
"<td><a href=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(rw.slug) + "</strong></a></td>" +
|
|
11577
|
+
"<td>" + _htmlEscape(rw.title) + "</td>" +
|
|
11578
|
+
"<td class=\"num\">" + _htmlEscape(String(rw.point_cost)) + "</td>" +
|
|
11579
|
+
"<td>" + _loyaltyStatusPill(rw.active, rw.archived_at) + "</td>" +
|
|
11580
|
+
"</tr>";
|
|
11581
|
+
}).join("");
|
|
11582
|
+
rewardSummary = "<div class=\"panel\"><div class=\"actions-row\"><h3 class=\"subhead\">Rewards catalog</h3>" +
|
|
11583
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/loyalty/rewards\">Manage rewards</a></div>" +
|
|
11584
|
+
(rewards.length
|
|
11585
|
+
? "<table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Title</th><th scope=\"col\" class=\"num\">Point cost</th><th scope=\"col\">Status</th></tr></thead><tbody>" + rewardRows + "</tbody></table>"
|
|
11586
|
+
: "<p class=\"empty\">No rewards yet. Customers only see active rewards in their catalog.</p>") +
|
|
11587
|
+
"</div>";
|
|
11588
|
+
}
|
|
11589
|
+
|
|
11590
|
+
// Points-adjustment form. The customer id is pasted (the customers
|
|
11591
|
+
// roster shows each customer's id); the amount is a positive integer; a
|
|
11592
|
+
// grant / deduct radio sets the sign; the reason is required and lands
|
|
11593
|
+
// in the ledger row.
|
|
11594
|
+
var prefillCid = opts.adjust_customer_id ? _htmlEscape(opts.adjust_customer_id) : "";
|
|
11595
|
+
var adjustForm =
|
|
11596
|
+
"<div class=\"panel mw-40\"><h3 class=\"subhead\">Adjust a customer's points</h3>" +
|
|
11597
|
+
"<p class=\"meta\">Grant or deduct points for one customer. Every adjustment is recorded in the loyalty ledger with the reason you give. A grant also counts toward the customer's lifetime tier.</p>" +
|
|
11598
|
+
adjustNotice +
|
|
11599
|
+
"<form method=\"post\" action=\"/admin/loyalty/adjust\">" +
|
|
11600
|
+
"<label class=\"form-field\"><span>Customer id</span>" +
|
|
11601
|
+
"<input type=\"text\" name=\"customer_id\" value=\"" + prefillCid + "\" required maxlength=\"64\" class=\"input-code\" placeholder=\"customer UUID\">" +
|
|
11602
|
+
"<small>From the Customers roster — each row's id column.</small></label>" +
|
|
11603
|
+
"<fieldset class=\"box\"><legend class=\"legend-sm\">Direction</legend>" +
|
|
11604
|
+
"<label class=\"kv\"><input type=\"radio\" name=\"direction\" value=\"grant\" checked> Grant (add points)</label>" +
|
|
11605
|
+
"<label class=\"kv\"><input type=\"radio\" name=\"direction\" value=\"deduct\"> Deduct (remove points)</label>" +
|
|
11606
|
+
"</fieldset>" +
|
|
11607
|
+
"<label class=\"form-field\"><span>Amount (points)</span>" +
|
|
11608
|
+
"<input type=\"number\" name=\"amount\" min=\"1\" step=\"1\" required class=\"input-code\"></label>" +
|
|
11609
|
+
"<label class=\"form-field\"><span>Reason</span>" +
|
|
11610
|
+
"<input type=\"text\" name=\"reason\" required maxlength=\"256\" placeholder=\"e.g. service recovery for order #1234\">" +
|
|
11611
|
+
"<small>Required. Recorded with the adjustment in the ledger.</small></label>" +
|
|
11612
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Apply adjustment</button></div>" +
|
|
11613
|
+
"</form>" +
|
|
11614
|
+
"</div>";
|
|
11615
|
+
|
|
11616
|
+
var bodyHtml = "<section><h2>Loyalty</h2>" + adjusted + notice +
|
|
11617
|
+
tierTable + earnSummary + rewardSummary + adjustForm + "</section>";
|
|
11618
|
+
return _renderAdminShell(opts.shop_name, "Loyalty", bodyHtml, "loyalty", opts.nav_available);
|
|
11619
|
+
}
|
|
11620
|
+
|
|
11621
|
+
// Earn-rules list + create form. Customers earn points only from ACTIVE
|
|
11622
|
+
// rules, so the create form defaults `active` on and the list flags
|
|
11623
|
+
// inactive / archived rows.
|
|
11624
|
+
function renderAdminLoyaltyEarnRules(opts) {
|
|
11625
|
+
opts = opts || {};
|
|
11626
|
+
var rules = opts.rules || [];
|
|
11627
|
+
var triggers = opts.triggers || loyaltyEarnRulesModule.TRIGGERS;
|
|
11628
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Earn rule saved.</div>" : "";
|
|
11629
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
|
|
11630
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Earn rule archived.</div>" : "";
|
|
11631
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
11632
|
+
|
|
11633
|
+
var bodyRows = rules.map(function (r) {
|
|
11634
|
+
var enc = encodeURIComponent(r.slug);
|
|
11635
|
+
var statusList = (r.customer_status_in && r.customer_status_in.length) ? r.customer_status_in.join(", ") : "all";
|
|
11636
|
+
return "<tr>" +
|
|
11637
|
+
"<td><a href=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(r.slug) + "</strong></a></td>" +
|
|
11638
|
+
"<td>" + _htmlEscape(r.trigger) + "</td>" +
|
|
11639
|
+
"<td class=\"num\">" + _htmlEscape(String(r.points_per_unit)) + "</td>" +
|
|
11640
|
+
"<td class=\"num\">" + _htmlEscape(r.max_per_event == null ? "—" : String(r.max_per_event)) + "</td>" +
|
|
11641
|
+
"<td>" + _htmlEscape(statusList) + "</td>" +
|
|
11642
|
+
"<td>" + _loyaltyStatusPill(r.active, r.archived_at) + "</td>" +
|
|
11643
|
+
"<td><div class=\"actions-row\">" +
|
|
11644
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "\">Manage</a>" +
|
|
11645
|
+
(r.archived_at == null
|
|
11646
|
+
? "<form method=\"post\" action=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
|
|
11647
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>"
|
|
11648
|
+
: "") +
|
|
11649
|
+
"</div></td>" +
|
|
11650
|
+
"</tr>";
|
|
11651
|
+
}).join("");
|
|
11652
|
+
var table = rules.length
|
|
11653
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Trigger</th><th scope=\"col\" class=\"num\">Points/unit</th><th scope=\"col\" class=\"num\">Max/event</th><th scope=\"col\">Statuses</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
11654
|
+
: "<p class=\"empty\">No earn rules yet.</p>";
|
|
11655
|
+
|
|
11656
|
+
var triggerOpts = triggers.map(function (t) {
|
|
11657
|
+
return "<option value=\"" + _htmlEscape(t) + "\">" + _htmlEscape(t) + "</option>";
|
|
11658
|
+
}).join("");
|
|
11659
|
+
|
|
11660
|
+
var createForm =
|
|
11661
|
+
"<div class=\"panel mt mw-40\"><h3 class=\"subhead\">Create an earn rule</h3>" +
|
|
11662
|
+
"<p class=\"meta\">An earn rule grants points when a trigger fires. points/unit is the multiplier for per_dollar_spent / per_purchase and the flat amount for the bonus triggers. Customers earn only from active rules.</p>" +
|
|
11663
|
+
"<form method=\"post\" action=\"/admin/loyalty/earn-rules\">" +
|
|
11664
|
+
_setupField("Slug", "slug", "", "text", "Lowercase letters, digits, dashes (e.g. spend-1pt-per-dollar).", " maxlength=\"100\" required") +
|
|
11665
|
+
"<label class=\"form-field\"><span>Trigger</span><select name=\"trigger\">" + triggerOpts + "</select></label>" +
|
|
11666
|
+
_setupField("Points per unit", "points_per_unit", "", "number", "Positive integer.", " min=\"1\" step=\"1\" required class=\"input-code\"") +
|
|
11667
|
+
_setupField("Max per event", "max_per_event", "", "number", "Optional cap so a huge order doesn't mint a giant balance. Leave blank for uncapped.", " min=\"1\" step=\"1\" class=\"input-code\"") +
|
|
11668
|
+
_setupField("Customer statuses", "customer_status_in", "", "text", "Optional comma-separated list (e.g. active, vip). Blank means every customer is eligible.", " maxlength=\"256\"") +
|
|
11669
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\" value=\"on\" checked> Active — show this rule to customers and award on its trigger.</label>" +
|
|
11670
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create earn rule</button></div>" +
|
|
11671
|
+
"</form>" +
|
|
11672
|
+
"</div>";
|
|
11673
|
+
|
|
11674
|
+
var bodyHtml = "<section><h2>Earn rules</h2>" +
|
|
11675
|
+
"<p class=\"meta\"><a href=\"/admin/loyalty\">← Loyalty</a></p>" +
|
|
11676
|
+
created + saved + archived + notice + table + createForm + "</section>";
|
|
11677
|
+
return _renderAdminShell(opts.shop_name, "Earn rules", bodyHtml, "loyalty", opts.nav_available);
|
|
11678
|
+
}
|
|
11679
|
+
|
|
11680
|
+
// Earn-rule detail — the rule's stored fields and an edit form. trigger
|
|
11681
|
+
// is immutable on update (the primitive refuses a change), so the form
|
|
11682
|
+
// shows it read-only.
|
|
11683
|
+
function renderAdminLoyaltyEarnRule(opts) {
|
|
11684
|
+
opts = opts || {};
|
|
11685
|
+
var r = opts.rule;
|
|
11686
|
+
if (!r) {
|
|
11687
|
+
var nf = "<section><h2>Earn rule</h2><p class=\"empty\">Earn rule not found.</p>" +
|
|
11688
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/loyalty/earn-rules\">Back to earn rules</a></div></section>";
|
|
11689
|
+
return _renderAdminShell(opts.shop_name, "Earn rule", nf, "loyalty", opts.nav_available);
|
|
11690
|
+
}
|
|
11691
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
|
|
11692
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
11693
|
+
var enc = encodeURIComponent(r.slug);
|
|
11694
|
+
var statusList = (r.customer_status_in && r.customer_status_in.length) ? r.customer_status_in.join(", ") : "";
|
|
11695
|
+
var isArchived = r.archived_at != null;
|
|
11696
|
+
|
|
11697
|
+
var editForm = isArchived
|
|
11698
|
+
? "<div class=\"panel mw-40\"><p class=\"empty\">This rule is archived and can no longer be edited. Archived rules don't award points or show to customers.</p></div>"
|
|
11699
|
+
: "<div class=\"panel mw-40\"><h3 class=\"subhead\">Edit</h3>" +
|
|
11700
|
+
"<form method=\"post\" action=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "/edit\">" +
|
|
11701
|
+
_setupField("Points per unit", "points_per_unit", String(r.points_per_unit), "number", "Positive integer.", " min=\"1\" step=\"1\" class=\"input-code\"") +
|
|
11702
|
+
_setupField("Max per event", "max_per_event", r.max_per_event == null ? "" : String(r.max_per_event), "number", "Blank or 0 clears the cap.", " min=\"0\" step=\"1\" class=\"input-code\"") +
|
|
11703
|
+
_setupField("Customer statuses", "customer_status_in", statusList, "text", "Comma-separated; blank means all customers.", " maxlength=\"256\"") +
|
|
11704
|
+
"<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
|
|
11705
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\" value=\"on\"" + (r.active ? " checked" : "") + "> Active</label>" +
|
|
11706
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save rule</button></div>" +
|
|
11707
|
+
"</form>" +
|
|
11708
|
+
"</div>";
|
|
11709
|
+
|
|
11710
|
+
var archiveBlock = isArchived ? "" :
|
|
11711
|
+
"<div class=\"actions-row mt-1\">" +
|
|
11712
|
+
"<form method=\"post\" action=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
|
|
11713
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive rule</button></form>" +
|
|
11714
|
+
"</div>";
|
|
11715
|
+
|
|
11716
|
+
var head =
|
|
11717
|
+
"<p class=\"meta\"><a href=\"/admin/loyalty/earn-rules\">← Earn rules</a> · " +
|
|
11718
|
+
"<span class=\"status-pill paid\">" + _htmlEscape(r.trigger) + "</span> · " +
|
|
11719
|
+
_loyaltyStatusPill(r.active, r.archived_at) + "</p>";
|
|
11720
|
+
|
|
11721
|
+
var body = "<section><h2>" + _htmlEscape(r.slug) + "</h2>" + saved + notice + head + editForm + archiveBlock + "</section>";
|
|
11722
|
+
return _renderAdminShell(opts.shop_name, "Earn rule", body, "loyalty", opts.nav_available);
|
|
11723
|
+
}
|
|
11724
|
+
|
|
11725
|
+
// Rewards-catalog list + create form. A reward must be active (and not
|
|
11726
|
+
// archived) to appear in the customer's redemption catalog, so the create
|
|
11727
|
+
// form defaults active on and the list flags inactive / archived rows.
|
|
11728
|
+
function renderAdminLoyaltyRewards(opts) {
|
|
11729
|
+
opts = opts || {};
|
|
11730
|
+
var rewards = opts.rewards || [];
|
|
11731
|
+
var kinds = opts.kinds || loyaltyRedemptionModule.KINDS;
|
|
11732
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Reward saved.</div>" : "";
|
|
11733
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
|
|
11734
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Reward archived.</div>" : "";
|
|
11735
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
11736
|
+
|
|
11737
|
+
var bodyRows = rewards.map(function (rw) {
|
|
11738
|
+
var enc = encodeURIComponent(rw.slug);
|
|
11739
|
+
return "<tr>" +
|
|
11740
|
+
"<td><a href=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(rw.slug) + "</strong></a></td>" +
|
|
11741
|
+
"<td>" + _htmlEscape(rw.title) + "</td>" +
|
|
11742
|
+
"<td>" + _htmlEscape(rw.kind) + "</td>" +
|
|
11743
|
+
"<td>" + _loyaltyRewardValueLabel(rw) + "</td>" +
|
|
11744
|
+
"<td class=\"num\">" + _htmlEscape(String(rw.point_cost)) + "</td>" +
|
|
11745
|
+
"<td>" + _loyaltyStatusPill(rw.active, rw.archived_at) + "</td>" +
|
|
11746
|
+
"<td><div class=\"actions-row\">" +
|
|
11747
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "\">Manage</a>" +
|
|
11748
|
+
(rw.archived_at == null
|
|
11749
|
+
? "<form method=\"post\" action=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
|
|
11750
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>"
|
|
11751
|
+
: "") +
|
|
11752
|
+
"</div></td>" +
|
|
11753
|
+
"</tr>";
|
|
11754
|
+
}).join("");
|
|
11755
|
+
var table = rewards.length
|
|
11756
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Title</th><th scope=\"col\">Kind</th><th scope=\"col\">Value</th><th scope=\"col\" class=\"num\">Point cost</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
11757
|
+
: "<p class=\"empty\">No rewards yet.</p>";
|
|
11758
|
+
|
|
11759
|
+
var kindOpts = kinds.map(function (k) {
|
|
11760
|
+
return "<option value=\"" + _htmlEscape(k) + "\">" + _htmlEscape(k) + "</option>";
|
|
11761
|
+
}).join("");
|
|
11762
|
+
|
|
11763
|
+
var createForm =
|
|
11764
|
+
"<div class=\"panel mt mw-40\"><h3 class=\"subhead\">Create a reward</h3>" +
|
|
11765
|
+
"<p class=\"meta\">A reward debits points at redemption. The value field's meaning depends on the kind: discount_percent → a whole-number percent (1–100); discount_amount → minor units off; free_product → a product id; free_shipping → leave it blank. A reward must be active to appear to customers.</p>" +
|
|
11766
|
+
"<form method=\"post\" action=\"/admin/loyalty/rewards\">" +
|
|
11767
|
+
_setupField("Slug", "slug", "", "text", "Letters, digits, dashes, dots, underscores.", " maxlength=\"80\" required") +
|
|
11768
|
+
"<label class=\"form-field\"><span>Kind</span><select name=\"kind\">" + kindOpts + "</select></label>" +
|
|
11769
|
+
_setupField("Title", "title", "", "text", "Shown to customers in the catalog.", " maxlength=\"200\" required") +
|
|
11770
|
+
_setupField("Point cost", "point_cost", "", "number", "Positive integer — points spent to redeem.", " min=\"1\" step=\"1\" required class=\"input-code\"") +
|
|
11771
|
+
_setupField("Value (number)", "value_number", "", "number", "percent (1–100) or amount in minor units. Leave blank for free_product / free_shipping.", " min=\"1\" step=\"1\" class=\"input-code\"") +
|
|
11772
|
+
_setupField("Value (product id)", "value_text", "", "text", "Only for free_product — the product id the reward grants.", " maxlength=\"128\"") +
|
|
11773
|
+
_setupField("Max per customer", "max_per_customer", "", "number", "Optional lifetime cap per customer. Blank for unlimited.", " min=\"1\" step=\"1\" class=\"input-code\"") +
|
|
11774
|
+
_setupField("Expires (days)", "expires_days_after_redemption", "", "number", "Optional — an unconsumed redemption expires after N days. Blank never expires.", " min=\"1\" step=\"1\" class=\"input-code\"") +
|
|
11775
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\" value=\"on\" checked> Active — show this reward in the customer catalog.</label>" +
|
|
11776
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create reward</button></div>" +
|
|
11777
|
+
"</form>" +
|
|
11778
|
+
"</div>";
|
|
11779
|
+
|
|
11780
|
+
var bodyHtml = "<section><h2>Rewards catalog</h2>" +
|
|
11781
|
+
"<p class=\"meta\"><a href=\"/admin/loyalty\">← Loyalty</a></p>" +
|
|
11782
|
+
created + saved + archived + notice + table + createForm + "</section>";
|
|
11783
|
+
return _renderAdminShell(opts.shop_name, "Rewards catalog", bodyHtml, "loyalty", opts.nav_available);
|
|
11784
|
+
}
|
|
11785
|
+
|
|
11786
|
+
// Reward detail — the reward's stored fields and an edit form. kind is
|
|
11787
|
+
// immutable on update (the primitive validates value_json against the
|
|
11788
|
+
// stored kind), so the form shows it read-only and re-validates value on
|
|
11789
|
+
// change.
|
|
11790
|
+
function renderAdminLoyaltyReward(opts) {
|
|
11791
|
+
opts = opts || {};
|
|
11792
|
+
var rw = opts.reward;
|
|
11793
|
+
if (!rw) {
|
|
11794
|
+
var nf = "<section><h2>Reward</h2><p class=\"empty\">Reward not found.</p>" +
|
|
11795
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/loyalty/rewards\">Back to rewards</a></div></section>";
|
|
11796
|
+
return _renderAdminShell(opts.shop_name, "Reward", nf, "loyalty", opts.nav_available);
|
|
11797
|
+
}
|
|
11798
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
|
|
11799
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
11800
|
+
var enc = encodeURIComponent(rw.slug);
|
|
11801
|
+
var isArchived = rw.archived_at != null;
|
|
11802
|
+
var v = rw.value_json || {};
|
|
11803
|
+
var valueNumber = rw.kind === "discount_percent" ? (v.percent == null ? "" : String(v.percent))
|
|
11804
|
+
: rw.kind === "discount_amount" ? (v.amount_minor == null ? "" : String(v.amount_minor))
|
|
11805
|
+
: "";
|
|
11806
|
+
var valueText = rw.kind === "free_product" ? String(v.product_id || "") : "";
|
|
11807
|
+
|
|
11808
|
+
var editForm = isArchived
|
|
11809
|
+
? "<div class=\"panel mw-40\"><p class=\"empty\">This reward is archived. Archived rewards stay readable for past redemptions but never show to customers.</p></div>"
|
|
11810
|
+
: "<div class=\"panel mw-40\"><h3 class=\"subhead\">Edit</h3>" +
|
|
11811
|
+
"<form method=\"post\" action=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "/edit\">" +
|
|
11812
|
+
_setupField("Title", "title", rw.title, "text", "", " maxlength=\"200\"") +
|
|
11813
|
+
_setupField("Point cost", "point_cost", String(rw.point_cost), "number", "", " min=\"1\" step=\"1\" class=\"input-code\"") +
|
|
11814
|
+
(rw.kind === "free_product"
|
|
11815
|
+
? _setupField("Value (product id)", "value_text", valueText, "text", "Leave unchanged to keep the current product.", " maxlength=\"128\"")
|
|
11816
|
+
: rw.kind === "free_shipping"
|
|
11817
|
+
? "<p class=\"meta\">free_shipping carries no value field.</p>"
|
|
11818
|
+
: _setupField("Value (number)", "value_number", valueNumber, "number", "percent (1–100) or amount in minor units.", " min=\"1\" step=\"1\" class=\"input-code\"")) +
|
|
11819
|
+
_setupField("Max per customer", "max_per_customer", rw.max_per_customer == null ? "" : String(rw.max_per_customer), "number", "", " min=\"1\" step=\"1\" class=\"input-code\"") +
|
|
11820
|
+
_setupField("Expires (days)", "expires_days_after_redemption", rw.expires_days_after_redemption == null ? "" : String(rw.expires_days_after_redemption), "number", "", " min=\"1\" step=\"1\" class=\"input-code\"") +
|
|
11821
|
+
"<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
|
|
11822
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\" value=\"on\"" + (rw.active ? " checked" : "") + "> Active — show in the customer catalog.</label>" +
|
|
11823
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save reward</button></div>" +
|
|
11824
|
+
"</form>" +
|
|
11825
|
+
"</div>";
|
|
11826
|
+
|
|
11827
|
+
var archiveBlock = isArchived ? "" :
|
|
11828
|
+
"<div class=\"actions-row mt-1\">" +
|
|
11829
|
+
"<form method=\"post\" action=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
|
|
11830
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive reward</button></form>" +
|
|
11831
|
+
"</div>";
|
|
11832
|
+
|
|
11833
|
+
var head =
|
|
11834
|
+
"<p class=\"meta\"><a href=\"/admin/loyalty/rewards\">← Rewards catalog</a> · " +
|
|
11835
|
+
"<span class=\"status-pill paid\">" + _htmlEscape(rw.kind) + "</span> · " +
|
|
11836
|
+
_loyaltyStatusPill(rw.active, rw.archived_at) + " · " +
|
|
11837
|
+
"<code class=\"order-id\">" + _htmlEscape(String(rw.point_cost)) + " pts</code></p>";
|
|
11838
|
+
|
|
11839
|
+
var body = "<section><h2>" + _htmlEscape(rw.title) + "</h2>" + saved + notice + head + editForm + archiveBlock + "</section>";
|
|
11840
|
+
return _renderAdminShell(opts.shop_name, "Reward", body, "loyalty", opts.nav_available);
|
|
11841
|
+
}
|
|
11842
|
+
|
|
10963
11843
|
// Product detail / management screen — the console's full editor for a
|
|
10964
11844
|
// single catalog product: its fields (slug / title / description /
|
|
10965
11845
|
// status), its variants (create / edit / delete), each variant's price
|
|
@@ -11239,5 +12119,10 @@ module.exports = {
|
|
|
11239
12119
|
renderAdminDiscountAllocation: renderAdminDiscountAllocation,
|
|
11240
12120
|
renderAdminQuantityDiscounts: renderAdminQuantityDiscounts,
|
|
11241
12121
|
renderAdminQuantityDiscount: renderAdminQuantityDiscount,
|
|
12122
|
+
renderAdminLoyalty: renderAdminLoyalty,
|
|
12123
|
+
renderAdminLoyaltyEarnRules: renderAdminLoyaltyEarnRules,
|
|
12124
|
+
renderAdminLoyaltyEarnRule: renderAdminLoyaltyEarnRule,
|
|
12125
|
+
renderAdminLoyaltyRewards: renderAdminLoyaltyRewards,
|
|
12126
|
+
renderAdminLoyaltyReward: renderAdminLoyaltyReward,
|
|
11242
12127
|
renderAdminConfirm: renderAdminConfirm,
|
|
11243
12128
|
};
|
package/lib/asset-manifest.json
CHANGED
package/package.json
CHANGED