@blamejs/blamejs-shop 0.3.22 → 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/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.
@@ -6924,6 +7455,21 @@ function _renderTemplate(template, vars) {
6924
7455
  return out;
6925
7456
  }
6926
7457
 
7458
+ // Splice a fully-rendered HTML fragment into `html` at the first
7459
+ // occurrence of a literal `RAW_*` token, inserting the fragment LITERALLY.
7460
+ //
7461
+ // `String.prototype.replace(token, replacementString)` gives the
7462
+ // replacement string special meaning to `$` sequences — `$$`, `$&`,
7463
+ // `` $` `` (the text before the match), `$'` (the text after the match),
7464
+ // `$N`. An admin page body that contains a dollar followed by a backtick
7465
+ // would otherwise splice the page chrome into the body, and any other
7466
+ // dollar sequence corrupts the output. Passing a REPLACER FUNCTION makes
7467
+ // `String.replace` insert the return value verbatim, with no dollar
7468
+ // interpretation. Mirrors the storefront's `_spliceRaw`.
7469
+ function _spliceRaw(html, token, fragment) {
7470
+ return html.replace(token, function () { return fragment == null ? "" : String(fragment); });
7471
+ }
7472
+
6927
7473
  function _sparkSvg(byDay, currency) {
6928
7474
  // SVG sparkline rendered server-side from revenue-by-day rows of
6929
7475
  // the dashboard's primary currency. Returns an empty placeholder
@@ -7069,6 +7615,7 @@ var ADMIN_NAV_ITEMS = [
7069
7615
  { key: "discounts", href: "/admin/discounts", label: "Discounts", requires: "autoDiscount" },
7070
7616
  { key: "discount-allocation", href: "/admin/discount-allocation", label: "Discount splits", requires: "discountAllocation" },
7071
7617
  { key: "quantity-discounts", href: "/admin/quantity-discounts", label: "Quantity breaks", requires: "quantityDiscounts" },
7618
+ { key: "loyalty", href: "/admin/loyalty", label: "Loyalty", requires: "loyalty" },
7072
7619
  { key: "tax", href: "/admin/tax-rates", label: "Tax", requires: "taxRates" },
7073
7620
  { key: "tax-filings", href: "/admin/tax-filings", label: "Tax filings", requires: "salesTaxFilings" },
7074
7621
  { key: "shipping", href: "/admin/shipping", label: "Shipping", requires: "shippingZones" },
@@ -7106,8 +7653,10 @@ function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
7106
7653
  nav: "RAW_NAV",
7107
7654
  body: "RAW_BODY",
7108
7655
  }).replace("RAW_ADMIN_CSS", _adminStylesheetLink())
7109
- .replace("RAW_NAV", _adminNav(active, available))
7110
- .replace("RAW_BODY", bodyHtml);
7656
+ .replace("RAW_NAV", _adminNav(active, available));
7657
+ // Splice the admin body literally so a `$`-bearing fragment can't trip
7658
+ // `String.replace`'s dollar substitution. See `_spliceRaw`.
7659
+ html = _spliceRaw(html, "RAW_BODY", bodyHtml);
7111
7660
  // Token every admin POST form with the per-request double-submit CSRF value
7112
7661
  // (seeded on the ALS by mount()'s sync middleware). Single funnel — every
7113
7662
  // authenticated admin page assembles here — so this is the one place the
@@ -10943,6 +11492,354 @@ function renderAdminQuantityDiscount(opts) {
10943
11492
  return _renderAdminShell(opts.shop_name, "Quantity break", body, "quantity-discounts", opts.nav_available);
10944
11493
  }
10945
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\">&larr; 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\">&larr; 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\">&larr; 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\">&larr; 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
+
10946
11843
  // Product detail / management screen — the console's full editor for a
10947
11844
  // single catalog product: its fields (slug / title / description /
10948
11845
  // status), its variants (create / edit / delete), each variant's price
@@ -11222,5 +12119,10 @@ module.exports = {
11222
12119
  renderAdminDiscountAllocation: renderAdminDiscountAllocation,
11223
12120
  renderAdminQuantityDiscounts: renderAdminQuantityDiscounts,
11224
12121
  renderAdminQuantityDiscount: renderAdminQuantityDiscount,
12122
+ renderAdminLoyalty: renderAdminLoyalty,
12123
+ renderAdminLoyaltyEarnRules: renderAdminLoyaltyEarnRules,
12124
+ renderAdminLoyaltyEarnRule: renderAdminLoyaltyEarnRule,
12125
+ renderAdminLoyaltyRewards: renderAdminLoyaltyRewards,
12126
+ renderAdminLoyaltyReward: renderAdminLoyaltyReward,
11225
12127
  renderAdminConfirm: renderAdminConfirm,
11226
12128
  };