@blamejs/blamejs-shop 0.3.23 → 0.3.25
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 +4 -0
- package/lib/admin.js +1321 -5
- package/lib/asset-manifest.json +1 -1
- package/package.json +1 -1
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
|
|
|
@@ -495,6 +497,9 @@ function mount(router, deps) {
|
|
|
495
497
|
var productQa = deps.productQa || null; // Q&A moderation endpoints disabled when absent
|
|
496
498
|
var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
|
|
497
499
|
var customers = deps.customers || null; // read-only customers console disabled when absent
|
|
500
|
+
var storeCredit = deps.storeCredit || null; // per-customer store-credit panel + grant/deduct disabled when absent
|
|
501
|
+
var customerNotes = deps.customerNotes || null; // per-customer CRM notes panel disabled when absent
|
|
502
|
+
var customerSegments = deps.customerSegments || null; // per-customer segment-membership panel disabled when absent
|
|
498
503
|
var orderTracking = deps.orderTracking || null; // shipment/tracking panel disabled when absent
|
|
499
504
|
var salesReports = deps.salesReports || null; // /admin/reports degrades to an unconfigured notice when absent
|
|
500
505
|
var printReceipts = deps.printReceipts || null; // order receipt document disabled when absent
|
|
@@ -510,7 +515,7 @@ function mount(router, deps) {
|
|
|
510
515
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
511
516
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
512
517
|
// 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 };
|
|
518
|
+
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
519
|
|
|
515
520
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
516
521
|
|
|
@@ -2595,6 +2600,276 @@ function mount(router, deps) {
|
|
|
2595
2600
|
}));
|
|
2596
2601
|
},
|
|
2597
2602
|
));
|
|
2603
|
+
|
|
2604
|
+
// ---- customer detail ----------------------------------------------
|
|
2605
|
+
// The per-customer operator screen. The roster (above) is read-only by
|
|
2606
|
+
// design — the storefront owns account mutation via the passkey / OIDC
|
|
2607
|
+
// ceremonies — so this screen READS the customer's identity fields +
|
|
2608
|
+
// recent orders, and WRITES only the operator-managed satellites:
|
|
2609
|
+
// store-credit (an audited wallet), CRM notes, and (read-only) segment
|
|
2610
|
+
// membership. It does NOT edit the customer profile: the only mutable
|
|
2611
|
+
// identity field is display_name, and the roster's stated design intent
|
|
2612
|
+
// is read-only identity, so the operator-managed satellites are the
|
|
2613
|
+
// write surface here. Each satellite panel renders only when its
|
|
2614
|
+
// primitive is wired; an unwired one degrades to a "not wired" notice
|
|
2615
|
+
// rather than vanishing.
|
|
2616
|
+
|
|
2617
|
+
// Free-text reason gate for a store-credit adjustment. A store-credit
|
|
2618
|
+
// grant / deduct is a money-adjacent action, so the reason is MANDATORY
|
|
2619
|
+
// — it rides into the ledger row's source_ref (grant) / reason (deduct)
|
|
2620
|
+
// column so every balance change is attributed. Throws a TypeError
|
|
2621
|
+
// (→ 400) on a missing / blank / over-long / control-byte reason so the
|
|
2622
|
+
// form surfaces a clean notice and writes nothing. 128 chars matches the
|
|
2623
|
+
// primitive's source_ref cap (storeCredit MAX_SOURCE_REF_LEN); the
|
|
2624
|
+
// primitive re-validates, so this gate fails fast with operator-facing
|
|
2625
|
+
// text rather than relying on the deeper throw.
|
|
2626
|
+
var STORE_CREDIT_REASON_MAX = 128;
|
|
2627
|
+
function _storeCreditReason(raw) {
|
|
2628
|
+
if (raw == null) throw new TypeError("admin: a reason is required for a store-credit adjustment");
|
|
2629
|
+
var s = String(raw).trim();
|
|
2630
|
+
if (!s.length) throw new TypeError("admin: a reason is required for a store-credit adjustment");
|
|
2631
|
+
if (s.length > STORE_CREDIT_REASON_MAX) throw new TypeError("admin: reason must be <= " + STORE_CREDIT_REASON_MAX + " chars");
|
|
2632
|
+
if (/[\x00-\x1f\x7f]/.test(s)) throw new TypeError("admin: reason must not contain control bytes");
|
|
2633
|
+
return s;
|
|
2634
|
+
}
|
|
2635
|
+
// Coerce the grant/deduct form into a positive minor-unit amount + a
|
|
2636
|
+
// direction. `direction` is "grant" or "deduct"; `amount_minor` is a
|
|
2637
|
+
// positive integer (the strict reader refuses "", floats, "12abc").
|
|
2638
|
+
function _storeCreditDirection(body) {
|
|
2639
|
+
var dir = typeof body.direction === "string" ? body.direction.trim() : "";
|
|
2640
|
+
if (dir !== "grant" && dir !== "deduct") {
|
|
2641
|
+
throw new TypeError("admin: direction must be 'grant' or 'deduct'");
|
|
2642
|
+
}
|
|
2643
|
+
return dir;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// Best-effort hydrate of a customer's satellites for the detail render.
|
|
2647
|
+
// A satellite whose primitive isn't wired (or whose table isn't migrated
|
|
2648
|
+
// on a given deploy) degrades that panel to a notice rather than 500-ing
|
|
2649
|
+
// the page — same discipline as the order-detail tracking panel.
|
|
2650
|
+
async function _customerDetailModel(customer, flags) {
|
|
2651
|
+
flags = flags || {};
|
|
2652
|
+
var currency = await _defaultCurrency();
|
|
2653
|
+
|
|
2654
|
+
var recentOrders = [];
|
|
2655
|
+
try {
|
|
2656
|
+
var ordersPage = await order.listForCustomer(customer.id, { limit: 10 });
|
|
2657
|
+
recentOrders = ordersPage.rows || [];
|
|
2658
|
+
} catch (_e) { recentOrders = []; }
|
|
2659
|
+
|
|
2660
|
+
var creditBalanceMinor = null;
|
|
2661
|
+
var creditHistory = [];
|
|
2662
|
+
if (storeCredit) {
|
|
2663
|
+
try { creditBalanceMinor = (await storeCredit.balance(customer.id)).balance_minor; }
|
|
2664
|
+
catch (_e) { creditBalanceMinor = null; }
|
|
2665
|
+
try { creditHistory = (await storeCredit.history({ customer_id: customer.id, limit: 10 })).rows || []; }
|
|
2666
|
+
catch (_e) { creditHistory = []; }
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
var loyaltyInfo = null;
|
|
2670
|
+
if (deps.loyalty) {
|
|
2671
|
+
try { loyaltyInfo = await deps.loyalty.balance(customer.id); }
|
|
2672
|
+
catch (_e) { loyaltyInfo = null; }
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
var notes = [];
|
|
2676
|
+
if (customerNotes) {
|
|
2677
|
+
try { notes = (await customerNotes.notesForCustomer({ customer_id: customer.id, limit: 20 })).rows || []; }
|
|
2678
|
+
catch (_e) { notes = []; }
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
var segments = null;
|
|
2682
|
+
if (customerSegments) {
|
|
2683
|
+
try { segments = await customerSegments.segmentsForCustomer(customer.id); }
|
|
2684
|
+
catch (_e) { segments = []; }
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
return Object.assign({
|
|
2688
|
+
shop_name: deps.shop_name,
|
|
2689
|
+
nav_available: navAvailable,
|
|
2690
|
+
customer: customer,
|
|
2691
|
+
currency: currency,
|
|
2692
|
+
recent_orders: recentOrders,
|
|
2693
|
+
can_store_credit: !!storeCredit,
|
|
2694
|
+
store_credit_minor: creditBalanceMinor,
|
|
2695
|
+
store_credit_history: creditHistory,
|
|
2696
|
+
loyalty: loyaltyInfo,
|
|
2697
|
+
loyalty_link: !!deps.loyalty,
|
|
2698
|
+
can_notes: !!customerNotes,
|
|
2699
|
+
notes: notes,
|
|
2700
|
+
can_segments: !!customerSegments,
|
|
2701
|
+
segments: segments,
|
|
2702
|
+
}, flags);
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
// Resolve the :id to a customer record. A malformed id throws inside the
|
|
2706
|
+
// defensive id reader (customers.get) — caught and treated as "no such
|
|
2707
|
+
// customer" so the route renders a clean 404, never a 500. An unknown
|
|
2708
|
+
// (well-formed) id returns null → the same 404.
|
|
2709
|
+
async function _resolveCustomer(id) {
|
|
2710
|
+
try { return await customers.get(id); }
|
|
2711
|
+
catch (e) { if (e instanceof TypeError) return null; throw e; }
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
router.get("/admin/customers/:id", _pageOrApi(true,
|
|
2715
|
+
R(async function (req, res) {
|
|
2716
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2717
|
+
if (!c) return _problem(res, 404, "customer-not-found");
|
|
2718
|
+
// Bearer JSON: the customer record + its satellites (no HTML).
|
|
2719
|
+
var model = await _customerDetailModel(c, {});
|
|
2720
|
+
_json(res, 200, {
|
|
2721
|
+
customer: c,
|
|
2722
|
+
recent_orders: model.recent_orders,
|
|
2723
|
+
store_credit_minor: model.store_credit_minor,
|
|
2724
|
+
store_credit_history: model.store_credit_history,
|
|
2725
|
+
loyalty: model.loyalty,
|
|
2726
|
+
notes: model.notes,
|
|
2727
|
+
segments: model.segments,
|
|
2728
|
+
});
|
|
2729
|
+
}),
|
|
2730
|
+
async function (req, res) {
|
|
2731
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2732
|
+
if (!c) return _sendHtml(res, 404, renderAdminCustomers({
|
|
2733
|
+
shop_name: deps.shop_name, nav_available: navAvailable, customers: [], notice: "Customer not found.",
|
|
2734
|
+
}));
|
|
2735
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
2736
|
+
var flags = {
|
|
2737
|
+
saved: url && url.searchParams.get("saved"),
|
|
2738
|
+
credit_notice: url && url.searchParams.get("credit_err") ? url.searchParams.get("credit_err") : null,
|
|
2739
|
+
note_notice: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
|
|
2740
|
+
};
|
|
2741
|
+
_sendHtml(res, 200, renderAdminCustomerDetail(await _customerDetailModel(c, flags)));
|
|
2742
|
+
},
|
|
2743
|
+
));
|
|
2744
|
+
|
|
2745
|
+
// ---- store-credit adjustment (grant / deduct) ----------------------
|
|
2746
|
+
// Scoped to the :id customer — the customer the adjustment targets is the
|
|
2747
|
+
// path id, never a form field, so an operator can't grant credit to a
|
|
2748
|
+
// different customer than the screen they're on. A grant composes
|
|
2749
|
+
// storeCredit.credit (source goodwill / source_ref reason); a deduct
|
|
2750
|
+
// composes storeCredit.expire (the operator-initiated burn that carries a
|
|
2751
|
+
// required reason). Over-deduction is refused at the route as a clean 409
|
|
2752
|
+
// BEFORE any write — the expire primitive caps silently at the balance,
|
|
2753
|
+
// so the route enforces the no-overdraft contract by reading the balance
|
|
2754
|
+
// first (nothing is written when the deduction exceeds the balance).
|
|
2755
|
+
if (storeCredit) {
|
|
2756
|
+
router.post("/admin/customers/:id/store-credit", _pageOrApi(false,
|
|
2757
|
+
W("customer.store_credit.adjust", async function (req, res) {
|
|
2758
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2759
|
+
if (!c) return _problem(res, 404, "customer-not-found");
|
|
2760
|
+
var body = req.body || {};
|
|
2761
|
+
var dir, amount, reason;
|
|
2762
|
+
try {
|
|
2763
|
+
dir = _storeCreditDirection(body);
|
|
2764
|
+
amount = _strictMinorInt(body.amount_minor, "admin", "amount_minor");
|
|
2765
|
+
if (amount <= 0) throw new TypeError("admin: amount_minor must be a positive integer (minor units)");
|
|
2766
|
+
reason = _storeCreditReason(body.reason);
|
|
2767
|
+
} catch (e) {
|
|
2768
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
2769
|
+
throw e;
|
|
2770
|
+
}
|
|
2771
|
+
var result;
|
|
2772
|
+
if (dir === "grant") {
|
|
2773
|
+
result = await storeCredit.credit({
|
|
2774
|
+
customer_id: c.id, amount_minor: amount, source: "goodwill", source_ref: reason,
|
|
2775
|
+
});
|
|
2776
|
+
} else {
|
|
2777
|
+
var bal = (await storeCredit.balance(c.id)).balance_minor;
|
|
2778
|
+
if (amount > bal) {
|
|
2779
|
+
return _problem(res, 409, "insufficient-balance", "Deduction exceeds the available store-credit balance.");
|
|
2780
|
+
}
|
|
2781
|
+
result = await storeCredit.expire({ customer_id: c.id, amount_minor: amount, reason: reason });
|
|
2782
|
+
}
|
|
2783
|
+
_json(res, 200, result);
|
|
2784
|
+
return { id: c.id };
|
|
2785
|
+
}),
|
|
2786
|
+
async function (req, res) {
|
|
2787
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2788
|
+
if (!c) return _sendHtml(res, 404, renderAdminCustomers({
|
|
2789
|
+
shop_name: deps.shop_name, nav_available: navAvailable, customers: [], notice: "Customer not found.",
|
|
2790
|
+
}));
|
|
2791
|
+
var body = req.body || {};
|
|
2792
|
+
var dir, amount, reason;
|
|
2793
|
+
try {
|
|
2794
|
+
dir = _storeCreditDirection(body);
|
|
2795
|
+
amount = _strictMinorInt(body.amount_minor, "admin", "amount_minor");
|
|
2796
|
+
if (amount <= 0) throw new TypeError("admin: amount_minor must be a positive integer (minor units)");
|
|
2797
|
+
reason = _storeCreditReason(body.reason);
|
|
2798
|
+
} catch (e) {
|
|
2799
|
+
var n = _safeNotice(e, "customer.store_credit.adjust");
|
|
2800
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
2801
|
+
"?credit_err=" + encodeURIComponent(n.message.replace(/^admin[.:]\s*/, "")));
|
|
2802
|
+
}
|
|
2803
|
+
if (dir === "deduct") {
|
|
2804
|
+
var bal = (await storeCredit.balance(c.id)).balance_minor;
|
|
2805
|
+
if (amount > bal) {
|
|
2806
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
2807
|
+
"?credit_err=" + encodeURIComponent("That deduction exceeds the available balance."));
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
try {
|
|
2811
|
+
if (dir === "grant") {
|
|
2812
|
+
await storeCredit.credit({ customer_id: c.id, amount_minor: amount, source: "goodwill", source_ref: reason });
|
|
2813
|
+
} else {
|
|
2814
|
+
await storeCredit.expire({ customer_id: c.id, amount_minor: amount, reason: reason });
|
|
2815
|
+
}
|
|
2816
|
+
} catch (e) {
|
|
2817
|
+
var n2 = _safeNotice(e, "customer.store_credit.adjust");
|
|
2818
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
2819
|
+
"?credit_err=" + encodeURIComponent(n2.message.replace(/^(?:admin|storeCredit)[.:]\s*/, "")));
|
|
2820
|
+
}
|
|
2821
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer.store_credit.adjust", outcome: "success", metadata: { id: c.id, direction: dir } });
|
|
2822
|
+
_redirect(res, "/admin/customers/" + encodeURIComponent(c.id) + "?saved=1");
|
|
2823
|
+
},
|
|
2824
|
+
));
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
// ---- customer notes (add) ------------------------------------------
|
|
2828
|
+
// Scoped to the :id customer — the note attaches to the path customer.
|
|
2829
|
+
// Composes customerNotes.addNote (the body is required + length-capped by
|
|
2830
|
+
// the primitive). The author is stamped "operator" — the console never
|
|
2831
|
+
// adds a system note. Bad input (empty / over-long body, bad kind) is a
|
|
2832
|
+
// clean 4xx with nothing written.
|
|
2833
|
+
if (customerNotes) {
|
|
2834
|
+
router.post("/admin/customers/:id/notes", _pageOrApi(false,
|
|
2835
|
+
W("customer.note.add", async function (req, res) {
|
|
2836
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2837
|
+
if (!c) return _problem(res, 404, "customer-not-found");
|
|
2838
|
+
var body = req.body || {};
|
|
2839
|
+
var note;
|
|
2840
|
+
try {
|
|
2841
|
+
note = await customerNotes.addNote({
|
|
2842
|
+
customer_id: c.id, author: "operator",
|
|
2843
|
+
body: body.body, kind: (body.kind != null && body.kind !== "") ? body.kind : undefined,
|
|
2844
|
+
});
|
|
2845
|
+
} catch (e) {
|
|
2846
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
2847
|
+
throw e;
|
|
2848
|
+
}
|
|
2849
|
+
_json(res, 201, note);
|
|
2850
|
+
return { id: c.id };
|
|
2851
|
+
}),
|
|
2852
|
+
async function (req, res) {
|
|
2853
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2854
|
+
if (!c) return _sendHtml(res, 404, renderAdminCustomers({
|
|
2855
|
+
shop_name: deps.shop_name, nav_available: navAvailable, customers: [], notice: "Customer not found.",
|
|
2856
|
+
}));
|
|
2857
|
+
var body = req.body || {};
|
|
2858
|
+
try {
|
|
2859
|
+
await customerNotes.addNote({
|
|
2860
|
+
customer_id: c.id, author: "operator",
|
|
2861
|
+
body: body.body, kind: (body.kind != null && body.kind !== "") ? body.kind : undefined,
|
|
2862
|
+
});
|
|
2863
|
+
} catch (e) {
|
|
2864
|
+
var n = _safeNotice(e, "customer.note.add");
|
|
2865
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
2866
|
+
"?note_err=" + encodeURIComponent(n.message.replace(/^(?:admin|customerNotes)[.:]\s*/, "")));
|
|
2867
|
+
}
|
|
2868
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer.note.add", outcome: "success", metadata: { id: c.id } });
|
|
2869
|
+
_redirect(res, "/admin/customers/" + encodeURIComponent(c.id) + "?saved=1");
|
|
2870
|
+
},
|
|
2871
|
+
));
|
|
2872
|
+
}
|
|
2598
2873
|
}
|
|
2599
2874
|
|
|
2600
2875
|
// ---- refunds --------------------------------------------------------
|
|
@@ -6628,6 +6903,535 @@ function mount(router, deps) {
|
|
|
6628
6903
|
));
|
|
6629
6904
|
}
|
|
6630
6905
|
|
|
6906
|
+
// ---- loyalty --------------------------------------------------------
|
|
6907
|
+
// Manage the loyalty program from the console: the earn rules that mint
|
|
6908
|
+
// points, the rewards catalog customers redeem against, and a direct
|
|
6909
|
+
// per-customer points adjustment (grant / deduct with a required reason,
|
|
6910
|
+
// recorded in the loyalty ledger). The customer-facing /account/loyalty
|
|
6911
|
+
// page shows ONLY active earn rules + active rewards, so the console
|
|
6912
|
+
// flags inactive rows and an archive removes them from the storefront.
|
|
6913
|
+
//
|
|
6914
|
+
// `loyalty` (the points ledger) is the core dep — the overview + the
|
|
6915
|
+
// adjustment action mount on it. Earn rules + the rewards catalog mount
|
|
6916
|
+
// additionally on their own primitives, so a deployment that wired only
|
|
6917
|
+
// the ledger still gets the adjustment surface (and the nav link).
|
|
6918
|
+
if (deps.loyalty) {
|
|
6919
|
+
var loyalty = deps.loyalty;
|
|
6920
|
+
var loyaltyEarnRules = deps.loyaltyEarnRules || null;
|
|
6921
|
+
var loyaltyRedemption = deps.loyaltyRedemption || null;
|
|
6922
|
+
|
|
6923
|
+
// Validate the operator's free-text adjustment reason at the route.
|
|
6924
|
+
// A points adjustment is a money-adjacent action, so the reason is
|
|
6925
|
+
// MANDATORY — it rides into the ledger row's `notes` column. Throws a
|
|
6926
|
+
// TypeError (→ 400) on a missing / blank / over-long / control-byte
|
|
6927
|
+
// reason so the browser form surfaces a clean notice and writes
|
|
6928
|
+
// nothing. The amount goes through the strict signed-integer reader
|
|
6929
|
+
// (refuses "", floats, "12abc") and must be non-zero.
|
|
6930
|
+
var LOYALTY_REASON_MAX = 256;
|
|
6931
|
+
function _loyaltyReason(raw) {
|
|
6932
|
+
if (raw == null) throw new TypeError("admin: a reason is required for a points adjustment");
|
|
6933
|
+
var s = String(raw).trim();
|
|
6934
|
+
if (!s.length) throw new TypeError("admin: a reason is required for a points adjustment");
|
|
6935
|
+
if (s.length > LOYALTY_REASON_MAX) throw new TypeError("admin: reason must be <= " + LOYALTY_REASON_MAX + " chars");
|
|
6936
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) throw new TypeError("admin: reason must not contain control bytes");
|
|
6937
|
+
return s;
|
|
6938
|
+
}
|
|
6939
|
+
// Coerce the grant/deduct form into a signed delta. `direction` is
|
|
6940
|
+
// "grant" (positive) or "deduct" (negative); `amount` is a positive
|
|
6941
|
+
// integer count of points. The two compose into the non-zero signed
|
|
6942
|
+
// delta loyalty.adjust expects, so the operator never types a sign.
|
|
6943
|
+
function _loyaltyDelta(body) {
|
|
6944
|
+
var amount = _strictMinorInt(body.amount, "admin", "amount");
|
|
6945
|
+
if (amount <= 0) throw new TypeError("admin: amount must be a positive integer");
|
|
6946
|
+
var dir = typeof body.direction === "string" ? body.direction.trim() : "";
|
|
6947
|
+
if (dir !== "grant" && dir !== "deduct") {
|
|
6948
|
+
throw new TypeError("admin: direction must be 'grant' or 'deduct'");
|
|
6949
|
+
}
|
|
6950
|
+
return dir === "deduct" ? -amount : amount;
|
|
6951
|
+
}
|
|
6952
|
+
|
|
6953
|
+
// Build the overview model: thresholds + ratios from the ledger, plus
|
|
6954
|
+
// (best-effort) the earn-rule + reward lists. A not-wired or
|
|
6955
|
+
// not-migrated sub-primitive degrades that panel rather than the page.
|
|
6956
|
+
async function _loyaltyOverview(flags) {
|
|
6957
|
+
flags = flags || {};
|
|
6958
|
+
var earnRules = null;
|
|
6959
|
+
if (loyaltyEarnRules) {
|
|
6960
|
+
try { earnRules = await loyaltyEarnRules.listRules({ limit: 200 }); }
|
|
6961
|
+
catch (_e) { earnRules = []; }
|
|
6962
|
+
}
|
|
6963
|
+
var rewards = null;
|
|
6964
|
+
if (loyaltyRedemption) {
|
|
6965
|
+
try { rewards = await loyaltyRedemption.listRewards({ limit: 200 }); }
|
|
6966
|
+
catch (_e) { rewards = []; }
|
|
6967
|
+
}
|
|
6968
|
+
return Object.assign({
|
|
6969
|
+
shop_name: deps.shop_name,
|
|
6970
|
+
nav_available: navAvailable,
|
|
6971
|
+
tiers: loyalty.TIERS,
|
|
6972
|
+
tier_thresholds: loyalty.TIER_THRESHOLDS,
|
|
6973
|
+
points_per_usd: loyalty.POINTS_PER_USD,
|
|
6974
|
+
redemption_points_per_usd: loyalty.REDEMPTION_POINTS_PER_USD,
|
|
6975
|
+
earn_triggers: loyaltyEarnRulesModule.TRIGGERS,
|
|
6976
|
+
reward_kinds: loyaltyRedemptionModule.KINDS,
|
|
6977
|
+
earn_rules: earnRules,
|
|
6978
|
+
rewards: rewards,
|
|
6979
|
+
can_manage_rules: !!loyaltyEarnRules,
|
|
6980
|
+
can_manage_rewards: !!loyaltyRedemption,
|
|
6981
|
+
}, flags);
|
|
6982
|
+
}
|
|
6983
|
+
|
|
6984
|
+
router.get("/admin/loyalty", _pageOrApi(true,
|
|
6985
|
+
R(async function (_req, res) {
|
|
6986
|
+
// Bearer JSON: the program's configuration snapshot.
|
|
6987
|
+
_json(res, 200, {
|
|
6988
|
+
tiers: loyalty.TIERS,
|
|
6989
|
+
tier_thresholds: loyalty.TIER_THRESHOLDS,
|
|
6990
|
+
points_per_usd: loyalty.POINTS_PER_USD,
|
|
6991
|
+
redemption_points_per_usd: loyalty.REDEMPTION_POINTS_PER_USD,
|
|
6992
|
+
earn_rules: loyaltyEarnRules ? await loyaltyEarnRules.listRules({ limit: 200 }) : null,
|
|
6993
|
+
rewards: loyaltyRedemption ? await loyaltyRedemption.listRewards({ limit: 200 }) : null,
|
|
6994
|
+
});
|
|
6995
|
+
}),
|
|
6996
|
+
async function (req, res) {
|
|
6997
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6998
|
+
var flags = {
|
|
6999
|
+
adjusted: url && url.searchParams.get("adjusted"),
|
|
7000
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
|
|
7001
|
+
};
|
|
7002
|
+
// A failed adjustment round-trips its message through a one-shot
|
|
7003
|
+
// query flag set on the redirect (kept short + already escaped on
|
|
7004
|
+
// render) so the operator sees WHY without a 5xx.
|
|
7005
|
+
if (url && url.searchParams.get("adjust_err")) {
|
|
7006
|
+
flags.adjust_notice = url.searchParams.get("adjust_err");
|
|
7007
|
+
}
|
|
7008
|
+
_sendHtml(res, 200, renderAdminLoyalty(await _loyaltyOverview(flags)));
|
|
7009
|
+
},
|
|
7010
|
+
));
|
|
7011
|
+
|
|
7012
|
+
// Points adjustment — grant or deduct a specific customer's balance
|
|
7013
|
+
// with a required reason. Composes loyalty.adjust, which writes the
|
|
7014
|
+
// signed delta to loyalty_transactions (the audited ledger) and
|
|
7015
|
+
// recomputes the tier. A bad customer id / amount / missing reason is
|
|
7016
|
+
// a clean 4xx with nothing written; an underflowing deduction surfaces
|
|
7017
|
+
// the primitive's LOYALTY_INSUFFICIENT_BALANCE as a notice.
|
|
7018
|
+
router.post("/admin/loyalty/adjust", _pageOrApi(false,
|
|
7019
|
+
W("loyalty.adjust", async function (req, res) {
|
|
7020
|
+
var body = req.body || {};
|
|
7021
|
+
var customerId = body.customer_id;
|
|
7022
|
+
var delta;
|
|
7023
|
+
var reason;
|
|
7024
|
+
try {
|
|
7025
|
+
delta = _loyaltyDelta(body);
|
|
7026
|
+
reason = _loyaltyReason(body.reason);
|
|
7027
|
+
} catch (e) {
|
|
7028
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
7029
|
+
throw e;
|
|
7030
|
+
}
|
|
7031
|
+
var result;
|
|
7032
|
+
try {
|
|
7033
|
+
result = await loyalty.adjust({
|
|
7034
|
+
customer_id: customerId,
|
|
7035
|
+
points: delta,
|
|
7036
|
+
source: "admin-adjustment",
|
|
7037
|
+
notes: reason,
|
|
7038
|
+
});
|
|
7039
|
+
} catch (e) {
|
|
7040
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
7041
|
+
if (e && e.code === "LOYALTY_INSUFFICIENT_BALANCE") {
|
|
7042
|
+
return _problem(res, 409, "insufficient-balance", "Adjustment would drop the balance below zero.");
|
|
7043
|
+
}
|
|
7044
|
+
throw e;
|
|
7045
|
+
}
|
|
7046
|
+
_json(res, 200, result);
|
|
7047
|
+
return { id: customerId };
|
|
7048
|
+
}),
|
|
7049
|
+
async function (req, res) {
|
|
7050
|
+
var body = req.body || {};
|
|
7051
|
+
var customerId = body.customer_id;
|
|
7052
|
+
var cidPrefill = typeof customerId === "string" ? customerId : "";
|
|
7053
|
+
// Re-render the overview with the adjustment form's error notice.
|
|
7054
|
+
// The message is classified by the shared _safeNotice funnel (so
|
|
7055
|
+
// the cookie/HTML path can never reveal more than the bearer JSON
|
|
7056
|
+
// path); the `admin:`/`loyalty:` namespace prefix is stripped for
|
|
7057
|
+
// the operator. An insufficient-balance refusal is the one
|
|
7058
|
+
// primitive code _safeNotice would otherwise genericize to a 500,
|
|
7059
|
+
// so it's mapped to a clean 4xx notice ahead of the funnel.
|
|
7060
|
+
async function _adjustFail(e) {
|
|
7061
|
+
if (e && e.code === "LOYALTY_INSUFFICIENT_BALANCE") {
|
|
7062
|
+
return _sendHtml(res, 409, renderAdminLoyalty(await _loyaltyOverview({
|
|
7063
|
+
adjust_notice: "That deduction would drop the balance below zero.",
|
|
7064
|
+
adjust_customer_id: cidPrefill,
|
|
7065
|
+
})));
|
|
7066
|
+
}
|
|
7067
|
+
var n = _safeNotice(e, "loyalty.adjust");
|
|
7068
|
+
return _sendHtml(res, n.status, renderAdminLoyalty(await _loyaltyOverview({
|
|
7069
|
+
adjust_notice: n.message.replace(/^(?:admin|loyalty)[.:]\s*/, ""),
|
|
7070
|
+
adjust_customer_id: cidPrefill,
|
|
7071
|
+
})));
|
|
7072
|
+
}
|
|
7073
|
+
var delta;
|
|
7074
|
+
var reason;
|
|
7075
|
+
try {
|
|
7076
|
+
delta = _loyaltyDelta(body);
|
|
7077
|
+
reason = _loyaltyReason(body.reason);
|
|
7078
|
+
} catch (e) {
|
|
7079
|
+
return _adjustFail(e);
|
|
7080
|
+
}
|
|
7081
|
+
try {
|
|
7082
|
+
await loyalty.adjust({
|
|
7083
|
+
customer_id: customerId,
|
|
7084
|
+
points: delta,
|
|
7085
|
+
source: "admin-adjustment",
|
|
7086
|
+
notes: reason,
|
|
7087
|
+
});
|
|
7088
|
+
} catch (e) {
|
|
7089
|
+
return _adjustFail(e);
|
|
7090
|
+
}
|
|
7091
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.adjust", outcome: "success" });
|
|
7092
|
+
_redirect(res, "/admin/loyalty?adjusted=1");
|
|
7093
|
+
},
|
|
7094
|
+
));
|
|
7095
|
+
|
|
7096
|
+
// ---- earn rules ---------------------------------------------------
|
|
7097
|
+
if (loyaltyEarnRules) {
|
|
7098
|
+
// Translate the create / edit form into a defineRule / updateRule
|
|
7099
|
+
// input. The status list is a comma-separated free-text field
|
|
7100
|
+
// (e.g. "active, vip"); a blank field means no restriction (null).
|
|
7101
|
+
function _loyaltyStatusList(raw) {
|
|
7102
|
+
if (raw == null) return null;
|
|
7103
|
+
var s = String(raw).trim();
|
|
7104
|
+
if (!s.length) return null;
|
|
7105
|
+
return s.split(",").map(function (x) { return x.trim(); }).filter(Boolean);
|
|
7106
|
+
}
|
|
7107
|
+
function _earnDefineInput(body) {
|
|
7108
|
+
var input = {
|
|
7109
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
7110
|
+
trigger: typeof body.trigger === "string" ? body.trigger.trim() : body.trigger,
|
|
7111
|
+
points_per_unit: _strictMinorInt(body.points_per_unit, "admin", "points_per_unit"),
|
|
7112
|
+
active: (body.active === "on" || body.active === "1" || body.active === true),
|
|
7113
|
+
};
|
|
7114
|
+
var maxRaw = body.max_per_event == null ? "" : String(body.max_per_event).trim();
|
|
7115
|
+
if (maxRaw !== "") input.max_per_event = _strictMinorInt(maxRaw, "admin", "max_per_event");
|
|
7116
|
+
var statusList = _loyaltyStatusList(body.customer_status_in);
|
|
7117
|
+
if (statusList) input.customer_status_in = statusList;
|
|
7118
|
+
return input;
|
|
7119
|
+
}
|
|
7120
|
+
|
|
7121
|
+
router.get("/admin/loyalty/earn-rules", _pageOrApi(true,
|
|
7122
|
+
R(async function (_req, res) {
|
|
7123
|
+
_json(res, 200, { rows: await loyaltyEarnRules.listRules({ limit: 200 }) });
|
|
7124
|
+
}),
|
|
7125
|
+
async function (req, res) {
|
|
7126
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
7127
|
+
_sendHtml(res, 200, renderAdminLoyaltyEarnRules({
|
|
7128
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
7129
|
+
triggers: loyaltyEarnRulesModule.TRIGGERS,
|
|
7130
|
+
rules: await loyaltyEarnRules.listRules({ limit: 200 }),
|
|
7131
|
+
created: url && url.searchParams.get("created"),
|
|
7132
|
+
saved: url && url.searchParams.get("saved"),
|
|
7133
|
+
archived: url && url.searchParams.get("archived_ok"),
|
|
7134
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the rule." : null,
|
|
7135
|
+
}));
|
|
7136
|
+
},
|
|
7137
|
+
));
|
|
7138
|
+
|
|
7139
|
+
router.post("/admin/loyalty/earn-rules", _pageOrApi(false,
|
|
7140
|
+
W("loyalty.earn_rule.create", async function (req, res) {
|
|
7141
|
+
var rule;
|
|
7142
|
+
try { rule = await loyaltyEarnRules.defineRule(_earnDefineInput(req.body || {})); }
|
|
7143
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7144
|
+
_json(res, 201, rule);
|
|
7145
|
+
return { id: rule.slug };
|
|
7146
|
+
}),
|
|
7147
|
+
async function (req, res) {
|
|
7148
|
+
try {
|
|
7149
|
+
await loyaltyEarnRules.defineRule(_earnDefineInput(req.body || {}));
|
|
7150
|
+
} catch (e) {
|
|
7151
|
+
var n = _safeNotice(e, "loyalty.earn_rule.create");
|
|
7152
|
+
return _sendHtml(res, n.status, renderAdminLoyaltyEarnRules({
|
|
7153
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
7154
|
+
triggers: loyaltyEarnRulesModule.TRIGGERS,
|
|
7155
|
+
rules: await loyaltyEarnRules.listRules({ limit: 200 }),
|
|
7156
|
+
notice: n.message.replace(/^loyaltyEarnRules[.:]\s*/, ""),
|
|
7157
|
+
}));
|
|
7158
|
+
}
|
|
7159
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.earn_rule.create", outcome: "success" });
|
|
7160
|
+
_redirect(res, "/admin/loyalty/earn-rules?created=1");
|
|
7161
|
+
},
|
|
7162
|
+
));
|
|
7163
|
+
|
|
7164
|
+
router.get("/admin/loyalty/earn-rules/:slug", _pageOrApi(true,
|
|
7165
|
+
R(async function (req, res) {
|
|
7166
|
+
var rule;
|
|
7167
|
+
try { rule = await loyaltyEarnRules.getRule(req.params.slug); }
|
|
7168
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7169
|
+
if (!rule) return _problem(res, 404, "loyalty-earn-rule-not-found");
|
|
7170
|
+
_json(res, 200, rule);
|
|
7171
|
+
}),
|
|
7172
|
+
async function (req, res) {
|
|
7173
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
7174
|
+
var rule;
|
|
7175
|
+
try { rule = await loyaltyEarnRules.getRule(req.params.slug); }
|
|
7176
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; rule = null; }
|
|
7177
|
+
if (!rule) return _sendHtml(res, 404, renderAdminLoyaltyEarnRule({
|
|
7178
|
+
shop_name: deps.shop_name, nav_available: navAvailable, rule: null,
|
|
7179
|
+
}));
|
|
7180
|
+
_sendHtml(res, 200, renderAdminLoyaltyEarnRule({
|
|
7181
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
7182
|
+
triggers: loyaltyEarnRulesModule.TRIGGERS, rule: rule,
|
|
7183
|
+
saved: url && url.searchParams.get("saved"),
|
|
7184
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
|
|
7185
|
+
}));
|
|
7186
|
+
},
|
|
7187
|
+
));
|
|
7188
|
+
|
|
7189
|
+
router.post("/admin/loyalty/earn-rules/:slug/edit", _pageOrApi(false,
|
|
7190
|
+
W("loyalty.earn_rule.update", async function (req, res) {
|
|
7191
|
+
var patch = _earnPatchFromForm(req.body || {});
|
|
7192
|
+
var rule;
|
|
7193
|
+
try { rule = await loyaltyEarnRules.updateRule(req.params.slug, patch); }
|
|
7194
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7195
|
+
if (!rule) return _problem(res, 404, "loyalty-earn-rule-not-found");
|
|
7196
|
+
_json(res, 200, rule);
|
|
7197
|
+
return { id: rule.slug };
|
|
7198
|
+
}),
|
|
7199
|
+
async function (req, res) {
|
|
7200
|
+
var slug = req.params.slug;
|
|
7201
|
+
var enc = encodeURIComponent(slug);
|
|
7202
|
+
try {
|
|
7203
|
+
var rule = await loyaltyEarnRules.updateRule(slug, _earnPatchFromForm(req.body || {}));
|
|
7204
|
+
if (!rule) return _redirect(res, "/admin/loyalty/earn-rules?err=1");
|
|
7205
|
+
} catch (e) {
|
|
7206
|
+
if (!(e instanceof TypeError)) throw e;
|
|
7207
|
+
return _redirect(res, "/admin/loyalty/earn-rules/" + enc + "?err=1");
|
|
7208
|
+
}
|
|
7209
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.earn_rule.update", outcome: "success", metadata: { slug: slug } });
|
|
7210
|
+
_redirect(res, "/admin/loyalty/earn-rules/" + enc + "?saved=1");
|
|
7211
|
+
},
|
|
7212
|
+
));
|
|
7213
|
+
|
|
7214
|
+
router.post("/admin/loyalty/earn-rules/:slug/archive", _pageOrApi(false,
|
|
7215
|
+
W("loyalty.earn_rule.archive", async function (req, res) {
|
|
7216
|
+
var rule;
|
|
7217
|
+
try { rule = await loyaltyEarnRules.archiveRule(req.params.slug); }
|
|
7218
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7219
|
+
if (!rule) return _problem(res, 404, "loyalty-earn-rule-not-found");
|
|
7220
|
+
_json(res, 200, rule);
|
|
7221
|
+
return { id: req.params.slug };
|
|
7222
|
+
}),
|
|
7223
|
+
async function (req, res) {
|
|
7224
|
+
var slug = req.params.slug;
|
|
7225
|
+
try { await loyaltyEarnRules.archiveRule(slug); }
|
|
7226
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/loyalty/earn-rules?err=1"); }
|
|
7227
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.earn_rule.archive", outcome: "success", metadata: { slug: slug } });
|
|
7228
|
+
_redirect(res, "/admin/loyalty/earn-rules?archived_ok=1");
|
|
7229
|
+
},
|
|
7230
|
+
));
|
|
7231
|
+
}
|
|
7232
|
+
|
|
7233
|
+
// ---- rewards catalog ----------------------------------------------
|
|
7234
|
+
if (loyaltyRedemption) {
|
|
7235
|
+
// The reward's value_json shape depends on its kind. The console
|
|
7236
|
+
// collects one numeric field (percent / amount_minor / product_id)
|
|
7237
|
+
// and the create path assembles the per-kind payload the primitive
|
|
7238
|
+
// validates. free_shipping carries an empty object.
|
|
7239
|
+
function _rewardValueJson(kind, body) {
|
|
7240
|
+
if (kind === "discount_percent") {
|
|
7241
|
+
return { percent: _strictMinorInt(body.value_number, "admin", "percent") };
|
|
7242
|
+
}
|
|
7243
|
+
if (kind === "discount_amount") {
|
|
7244
|
+
return { amount_minor: _strictMinorInt(body.value_number, "admin", "amount_minor") };
|
|
7245
|
+
}
|
|
7246
|
+
if (kind === "free_product") {
|
|
7247
|
+
var pid = typeof body.value_text === "string" ? body.value_text.trim() : "";
|
|
7248
|
+
return { product_id: pid };
|
|
7249
|
+
}
|
|
7250
|
+
// free_shipping (or an unknown kind the primitive will reject).
|
|
7251
|
+
return {};
|
|
7252
|
+
}
|
|
7253
|
+
function _rewardDefineInput(body) {
|
|
7254
|
+
var kind = typeof body.kind === "string" ? body.kind.trim() : body.kind;
|
|
7255
|
+
var input = {
|
|
7256
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
7257
|
+
kind: kind,
|
|
7258
|
+
title: typeof body.title === "string" ? body.title : body.title,
|
|
7259
|
+
point_cost: _strictMinorInt(body.point_cost, "admin", "point_cost"),
|
|
7260
|
+
value_json: _rewardValueJson(kind, body),
|
|
7261
|
+
active: (body.active === "on" || body.active === "1" || body.active === true),
|
|
7262
|
+
};
|
|
7263
|
+
var maxRaw = body.max_per_customer == null ? "" : String(body.max_per_customer).trim();
|
|
7264
|
+
if (maxRaw !== "") input.max_per_customer = _strictMinorInt(maxRaw, "admin", "max_per_customer");
|
|
7265
|
+
var expRaw = body.expires_days_after_redemption == null ? "" : String(body.expires_days_after_redemption).trim();
|
|
7266
|
+
if (expRaw !== "") input.expires_days_after_redemption = _strictMinorInt(expRaw, "admin", "expires_days_after_redemption");
|
|
7267
|
+
return input;
|
|
7268
|
+
}
|
|
7269
|
+
|
|
7270
|
+
router.get("/admin/loyalty/rewards", _pageOrApi(true,
|
|
7271
|
+
R(async function (_req, res) {
|
|
7272
|
+
_json(res, 200, { rows: await loyaltyRedemption.listRewards({ limit: 200 }) });
|
|
7273
|
+
}),
|
|
7274
|
+
async function (req, res) {
|
|
7275
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
7276
|
+
_sendHtml(res, 200, renderAdminLoyaltyRewards({
|
|
7277
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
7278
|
+
kinds: loyaltyRedemptionModule.KINDS,
|
|
7279
|
+
rewards: await loyaltyRedemption.listRewards({ limit: 200 }),
|
|
7280
|
+
created: url && url.searchParams.get("created"),
|
|
7281
|
+
saved: url && url.searchParams.get("saved"),
|
|
7282
|
+
archived: url && url.searchParams.get("archived_ok"),
|
|
7283
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the reward." : null,
|
|
7284
|
+
}));
|
|
7285
|
+
},
|
|
7286
|
+
));
|
|
7287
|
+
|
|
7288
|
+
router.post("/admin/loyalty/rewards", _pageOrApi(false,
|
|
7289
|
+
W("loyalty.reward.create", async function (req, res) {
|
|
7290
|
+
var reward;
|
|
7291
|
+
try { reward = await loyaltyRedemption.defineReward(_rewardDefineInput(req.body || {})); }
|
|
7292
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7293
|
+
_json(res, 201, reward);
|
|
7294
|
+
return { id: reward.slug };
|
|
7295
|
+
}),
|
|
7296
|
+
async function (req, res) {
|
|
7297
|
+
try {
|
|
7298
|
+
await loyaltyRedemption.defineReward(_rewardDefineInput(req.body || {}));
|
|
7299
|
+
} catch (e) {
|
|
7300
|
+
var n = _safeNotice(e, "loyalty.reward.create");
|
|
7301
|
+
return _sendHtml(res, n.status, renderAdminLoyaltyRewards({
|
|
7302
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
7303
|
+
kinds: loyaltyRedemptionModule.KINDS,
|
|
7304
|
+
rewards: await loyaltyRedemption.listRewards({ limit: 200 }),
|
|
7305
|
+
notice: n.message.replace(/^loyaltyRedemption[.:]\s*/, ""),
|
|
7306
|
+
}));
|
|
7307
|
+
}
|
|
7308
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.reward.create", outcome: "success" });
|
|
7309
|
+
_redirect(res, "/admin/loyalty/rewards?created=1");
|
|
7310
|
+
},
|
|
7311
|
+
));
|
|
7312
|
+
|
|
7313
|
+
router.get("/admin/loyalty/rewards/:slug", _pageOrApi(true,
|
|
7314
|
+
R(async function (req, res) {
|
|
7315
|
+
var reward;
|
|
7316
|
+
try { reward = await loyaltyRedemption.getReward(req.params.slug); }
|
|
7317
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7318
|
+
if (!reward) return _problem(res, 404, "loyalty-reward-not-found");
|
|
7319
|
+
_json(res, 200, reward);
|
|
7320
|
+
}),
|
|
7321
|
+
async function (req, res) {
|
|
7322
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
7323
|
+
var reward;
|
|
7324
|
+
try { reward = await loyaltyRedemption.getReward(req.params.slug); }
|
|
7325
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; reward = null; }
|
|
7326
|
+
if (!reward) return _sendHtml(res, 404, renderAdminLoyaltyReward({
|
|
7327
|
+
shop_name: deps.shop_name, nav_available: navAvailable, reward: null,
|
|
7328
|
+
}));
|
|
7329
|
+
_sendHtml(res, 200, renderAdminLoyaltyReward({
|
|
7330
|
+
shop_name: deps.shop_name, nav_available: navAvailable, reward: reward,
|
|
7331
|
+
saved: url && url.searchParams.get("saved"),
|
|
7332
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
|
|
7333
|
+
}));
|
|
7334
|
+
},
|
|
7335
|
+
));
|
|
7336
|
+
|
|
7337
|
+
router.post("/admin/loyalty/rewards/:slug/edit", _pageOrApi(false,
|
|
7338
|
+
W("loyalty.reward.update", async function (req, res) {
|
|
7339
|
+
var reward;
|
|
7340
|
+
try { reward = await loyaltyRedemption.updateReward(req.params.slug, await _rewardPatchFromForm(req.params.slug, req.body || {})); }
|
|
7341
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7342
|
+
_json(res, 200, reward);
|
|
7343
|
+
return { id: reward.slug };
|
|
7344
|
+
}),
|
|
7345
|
+
async function (req, res) {
|
|
7346
|
+
var slug = req.params.slug;
|
|
7347
|
+
var enc = encodeURIComponent(slug);
|
|
7348
|
+
try {
|
|
7349
|
+
await loyaltyRedemption.updateReward(slug, await _rewardPatchFromForm(slug, req.body || {}));
|
|
7350
|
+
} catch (e) {
|
|
7351
|
+
if (!(e instanceof TypeError)) throw e;
|
|
7352
|
+
return _redirect(res, "/admin/loyalty/rewards/" + enc + "?err=1");
|
|
7353
|
+
}
|
|
7354
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.reward.update", outcome: "success", metadata: { slug: slug } });
|
|
7355
|
+
_redirect(res, "/admin/loyalty/rewards/" + enc + "?saved=1");
|
|
7356
|
+
},
|
|
7357
|
+
));
|
|
7358
|
+
|
|
7359
|
+
router.post("/admin/loyalty/rewards/:slug/archive", _pageOrApi(false,
|
|
7360
|
+
W("loyalty.reward.archive", async function (req, res) {
|
|
7361
|
+
var reward;
|
|
7362
|
+
try { reward = await loyaltyRedemption.archiveReward(req.params.slug); }
|
|
7363
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
7364
|
+
_json(res, 200, reward);
|
|
7365
|
+
return { id: req.params.slug };
|
|
7366
|
+
}),
|
|
7367
|
+
async function (req, res) {
|
|
7368
|
+
var slug = req.params.slug;
|
|
7369
|
+
try { await loyaltyRedemption.archiveReward(slug); }
|
|
7370
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/loyalty/rewards?err=1"); }
|
|
7371
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".loyalty.reward.archive", outcome: "success", metadata: { slug: slug } });
|
|
7372
|
+
_redirect(res, "/admin/loyalty/rewards?archived_ok=1");
|
|
7373
|
+
},
|
|
7374
|
+
));
|
|
7375
|
+
|
|
7376
|
+
// Build the updateReward patch from the edit form. Only the columns
|
|
7377
|
+
// the operator changed are forwarded; value_json is re-validated
|
|
7378
|
+
// against the reward's STORED kind (kind is immutable on update), so
|
|
7379
|
+
// the helper reads the current reward to resolve it.
|
|
7380
|
+
async function _rewardPatchFromForm(slug, body) {
|
|
7381
|
+
var patch = {};
|
|
7382
|
+
if (typeof body.title === "string" && body.title.length) patch.title = body.title;
|
|
7383
|
+
if (body.point_cost != null && String(body.point_cost).trim() !== "") {
|
|
7384
|
+
patch.point_cost = _strictMinorInt(body.point_cost, "admin", "point_cost");
|
|
7385
|
+
}
|
|
7386
|
+
if (body.active_present === "1") {
|
|
7387
|
+
patch.active = (body.active === "on" || body.active === "1");
|
|
7388
|
+
}
|
|
7389
|
+
var maxRaw = body.max_per_customer == null ? "" : String(body.max_per_customer).trim();
|
|
7390
|
+
if (maxRaw !== "") patch.max_per_customer = _strictMinorInt(maxRaw, "admin", "max_per_customer");
|
|
7391
|
+
var expRaw = body.expires_days_after_redemption == null ? "" : String(body.expires_days_after_redemption).trim();
|
|
7392
|
+
if (expRaw !== "") patch.expires_days_after_redemption = _strictMinorInt(expRaw, "admin", "expires_days_after_redemption");
|
|
7393
|
+
// A supplied value field rewrites value_json against the stored
|
|
7394
|
+
// kind. Absent it, value_json is left untouched.
|
|
7395
|
+
if ((body.value_number != null && String(body.value_number).trim() !== "") ||
|
|
7396
|
+
(typeof body.value_text === "string" && body.value_text.trim() !== "")) {
|
|
7397
|
+
var current = await loyaltyRedemption.getReward(slug);
|
|
7398
|
+
if (current) patch.value_json = _rewardValueJson(current.kind, body);
|
|
7399
|
+
}
|
|
7400
|
+
return patch;
|
|
7401
|
+
}
|
|
7402
|
+
}
|
|
7403
|
+
}
|
|
7404
|
+
|
|
7405
|
+
// Patch builder for the loyalty earn-rule edit form. trigger is
|
|
7406
|
+
// immutable on update (the primitive refuses a change), so the form
|
|
7407
|
+
// never sends it; only points_per_unit / max_per_event /
|
|
7408
|
+
// customer_status_in / active are editable. Hoisted above the rewards
|
|
7409
|
+
// block's IIFE-free scope so both the bearer + browser edit paths share
|
|
7410
|
+
// it. Defined as a closure inside mount() so it can stay near its
|
|
7411
|
+
// callers without polluting the module surface.
|
|
7412
|
+
function _earnPatchFromForm(body) {
|
|
7413
|
+
var patch = {};
|
|
7414
|
+
if (body.points_per_unit != null && String(body.points_per_unit).trim() !== "") {
|
|
7415
|
+
patch.points_per_unit = _strictMinorInt(body.points_per_unit, "admin", "points_per_unit");
|
|
7416
|
+
}
|
|
7417
|
+
if (body.max_per_event != null) {
|
|
7418
|
+
var maxRaw = String(body.max_per_event).trim();
|
|
7419
|
+
// An explicit "0" / "" clears the cap (null); a positive value sets it.
|
|
7420
|
+
if (maxRaw === "" || maxRaw === "0") patch.max_per_event = null;
|
|
7421
|
+
else patch.max_per_event = _strictMinorInt(maxRaw, "admin", "max_per_event");
|
|
7422
|
+
}
|
|
7423
|
+
if (body.customer_status_in != null) {
|
|
7424
|
+
var s = String(body.customer_status_in).trim();
|
|
7425
|
+
patch.customer_status_in = s.length
|
|
7426
|
+
? s.split(",").map(function (x) { return x.trim(); }).filter(Boolean)
|
|
7427
|
+
: null;
|
|
7428
|
+
}
|
|
7429
|
+
if (body.active_present === "1") {
|
|
7430
|
+
patch.active = (body.active === "on" || body.active === "1");
|
|
7431
|
+
}
|
|
7432
|
+
return patch;
|
|
7433
|
+
}
|
|
7434
|
+
|
|
6631
7435
|
// Render the discounts screen — gathers rules + (optional) stacking
|
|
6632
7436
|
// policies, then hands them to the renderer with whatever banner flags
|
|
6633
7437
|
// the caller passed.
|
|
@@ -7084,6 +7888,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
7084
7888
|
{ key: "discounts", href: "/admin/discounts", label: "Discounts", requires: "autoDiscount" },
|
|
7085
7889
|
{ key: "discount-allocation", href: "/admin/discount-allocation", label: "Discount splits", requires: "discountAllocation" },
|
|
7086
7890
|
{ key: "quantity-discounts", href: "/admin/quantity-discounts", label: "Quantity breaks", requires: "quantityDiscounts" },
|
|
7891
|
+
{ key: "loyalty", href: "/admin/loyalty", label: "Loyalty", requires: "loyalty" },
|
|
7087
7892
|
{ key: "tax", href: "/admin/tax-rates", label: "Tax", requires: "taxRates" },
|
|
7088
7893
|
{ key: "tax-filings", href: "/admin/tax-filings", label: "Tax filings", requires: "salesTaxFilings" },
|
|
7089
7894
|
{ key: "shipping", href: "/admin/shipping", label: "Shipping", requires: "shippingZones" },
|
|
@@ -8218,17 +9023,21 @@ function renderAdminCustomers(opts) {
|
|
|
8218
9023
|
}
|
|
8219
9024
|
var method = methodCells.length ? methodCells.join(" ") : "<span class=\"meta\">—</span>";
|
|
8220
9025
|
var orders = orderCounts[c.id] || 0;
|
|
9026
|
+
var enc = encodeURIComponent(c.id);
|
|
8221
9027
|
return "<tr>" +
|
|
8222
|
-
"<td><strong>" + _htmlEscape(c.display_name) + "</strong></td>" +
|
|
9028
|
+
"<td><a href=\"/admin/customers/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(c.display_name) + "</strong></a></td>" +
|
|
8223
9029
|
"<td><code class=\"order-id\">" + _htmlEscape(String(c.id).slice(0, 8)) + "</code></td>" +
|
|
8224
9030
|
"<td>" + _htmlEscape(_fmtDate(c.created_at)) + "</td>" +
|
|
8225
9031
|
"<td>" + method + "</td>" +
|
|
8226
9032
|
"<td class=\"num\">" + _htmlEscape(String(orders)) + "</td>" +
|
|
9033
|
+
"<td><a class=\"btn btn--ghost btn--sm\" href=\"/admin/customers/" + _htmlEscape(enc) + "\">Manage</a></td>" +
|
|
8227
9034
|
"</tr>";
|
|
8228
9035
|
}).join("");
|
|
8229
9036
|
|
|
9037
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
9038
|
+
|
|
8230
9039
|
var table = customers.length
|
|
8231
|
-
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Name</th><th scope=\"col\">ID</th><th scope=\"col\">Joined</th><th scope=\"col\">Sign-in method</th><th scope=\"col\" class=\"num\">Orders</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
|
|
9040
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Name</th><th scope=\"col\">ID</th><th scope=\"col\">Joined</th><th scope=\"col\">Sign-in method</th><th scope=\"col\" class=\"num\">Orders</th><th scope=\"col\"><span class=\"sr-only\">Manage</span></th></tr></thead><tbody>" + rows + "</tbody></table></div>"
|
|
8232
9041
|
: "<p class=\"empty\">No customers yet.</p>";
|
|
8233
9042
|
|
|
8234
9043
|
// Cursor pager — a Next link when the page filled and more rows remain.
|
|
@@ -8237,12 +9046,165 @@ function renderAdminCustomers(opts) {
|
|
|
8237
9046
|
? "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/customers?cursor=" + _htmlEscape(encodeURIComponent(opts.next_cursor)) + "\">Next page <span aria-hidden=\"true\">→</span></a></div>"
|
|
8238
9047
|
: "";
|
|
8239
9048
|
|
|
8240
|
-
var body = "<section><h2>Customers</h2>" +
|
|
8241
|
-
"<p class=\"meta\">Accounts are passwordless — customers enrol a passkey or sign in with a federated provider. Email addresses aren't stored in the clear, so they're not shown here.</p>" +
|
|
9049
|
+
var body = "<section><h2>Customers</h2>" + notice +
|
|
9050
|
+
"<p class=\"meta\">Accounts are passwordless — customers enrol a passkey or sign in with a federated provider. Email addresses aren't stored in the clear, so they're not shown here. Open a customer to manage their store credit, loyalty, notes, and segment membership.</p>" +
|
|
8242
9051
|
table + pager + "</section>";
|
|
8243
9052
|
return _renderAdminShell(opts.shop_name, "Customers", body, "customers", opts.nav_available);
|
|
8244
9053
|
}
|
|
8245
9054
|
|
|
9055
|
+
// Per-customer operator detail screen. READS the customer's identity fields
|
|
9056
|
+
// + recent orders; WRITES only the operator-managed satellites (store-credit
|
|
9057
|
+
// + CRM notes). Segment membership is read-only — it is rule-derived (RFM
|
|
9058
|
+
// predicates recomputed by the scheduler), so there is no per-customer
|
|
9059
|
+
// manual assign / remove in the segments primitive; the panel shows the
|
|
9060
|
+
// segments the customer currently sits in and says so. Every panel renders
|
|
9061
|
+
// only when its primitive is wired.
|
|
9062
|
+
function renderAdminCustomerDetail(opts) {
|
|
9063
|
+
opts = opts || {};
|
|
9064
|
+
var c = opts.customer || {};
|
|
9065
|
+
var currency = opts.currency || "USD";
|
|
9066
|
+
var enc = encodeURIComponent(c.id);
|
|
9067
|
+
|
|
9068
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
|
|
9069
|
+
|
|
9070
|
+
// ---- identity (read-only) -----------------------------------------
|
|
9071
|
+
var identity = "<div class=\"panel\"><h3 class=\"subhead\">Customer</h3>" +
|
|
9072
|
+
"<dl class=\"kv-list\">" +
|
|
9073
|
+
"<div><dt>Name</dt><dd>" + _htmlEscape(c.display_name) + "</dd></div>" +
|
|
9074
|
+
"<div><dt>Customer id</dt><dd><code class=\"order-id\">" + _htmlEscape(String(c.id)) + "</code></dd></div>" +
|
|
9075
|
+
"<div><dt>Joined</dt><dd>" + _htmlEscape(_fmtDate(c.created_at)) + "</dd></div>" +
|
|
9076
|
+
"</dl>" +
|
|
9077
|
+
"<p class=\"meta\">Identity is read-only here — customers manage their own profile through the storefront (passkey / federated sign-in). Email addresses aren't stored in the clear.</p>" +
|
|
9078
|
+
"</div>";
|
|
9079
|
+
|
|
9080
|
+
// ---- recent orders -------------------------------------------------
|
|
9081
|
+
var orderRows = (opts.recent_orders || []).map(function (o) {
|
|
9082
|
+
return "<tr>" +
|
|
9083
|
+
"<td><a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(encodeURIComponent(o.id)) + "\">" + _htmlEscape(String(o.id).slice(0, 8)) + "</a></td>" +
|
|
9084
|
+
"<td><span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></td>" +
|
|
9085
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(o.grand_total_minor, o.currency)) + "</td>" +
|
|
9086
|
+
"<td>" + _htmlEscape(_fmtDate(o.created_at)) + "</td>" +
|
|
9087
|
+
"</tr>";
|
|
9088
|
+
}).join("");
|
|
9089
|
+
var ordersPanel = "<div class=\"panel\"><h3 class=\"subhead\">Recent orders</h3>" +
|
|
9090
|
+
((opts.recent_orders || []).length
|
|
9091
|
+
? "<table><thead><tr><th scope=\"col\">Order</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Total</th><th scope=\"col\">Placed</th></tr></thead><tbody>" + orderRows + "</tbody></table>"
|
|
9092
|
+
: "<p class=\"empty\">No orders yet.</p>") +
|
|
9093
|
+
"</div>";
|
|
9094
|
+
|
|
9095
|
+
// ---- store credit --------------------------------------------------
|
|
9096
|
+
var creditPanel;
|
|
9097
|
+
if (!opts.can_store_credit) {
|
|
9098
|
+
creditPanel = "<div class=\"panel\"><h3 class=\"subhead\">Store credit</h3>" +
|
|
9099
|
+
"<p class=\"empty\">Store credit isn't wired in this deployment.</p></div>";
|
|
9100
|
+
} else {
|
|
9101
|
+
var bal = opts.store_credit_minor != null ? opts.store_credit_minor : 0;
|
|
9102
|
+
var creditNotice = opts.credit_notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.credit_notice) + "</div>" : "";
|
|
9103
|
+
var histRows = (opts.store_credit_history || []).map(function (h) {
|
|
9104
|
+
var sign = h.kind === "credit" ? "+" : "−";
|
|
9105
|
+
var note = h.source_ref != null ? h.source_ref : (h.source != null ? h.source : "");
|
|
9106
|
+
return "<tr>" +
|
|
9107
|
+
"<td>" + _htmlEscape(h.kind) + "</td>" +
|
|
9108
|
+
"<td class=\"num\">" + _htmlEscape(sign + pricing.format(h.amount_minor, currency)) + "</td>" +
|
|
9109
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(h.balance_after_minor, currency)) + "</td>" +
|
|
9110
|
+
"<td>" + _htmlEscape(note) + "</td>" +
|
|
9111
|
+
"<td>" + _htmlEscape(_fmtDate(h.occurred_at)) + "</td>" +
|
|
9112
|
+
"</tr>";
|
|
9113
|
+
}).join("");
|
|
9114
|
+
var histTable = (opts.store_credit_history || []).length
|
|
9115
|
+
? "<table><thead><tr><th scope=\"col\">Kind</th><th scope=\"col\" class=\"num\">Amount</th><th scope=\"col\" class=\"num\">Balance after</th><th scope=\"col\">Reason</th><th scope=\"col\">When</th></tr></thead><tbody>" + histRows + "</tbody></table>"
|
|
9116
|
+
: "<p class=\"empty\">No store-credit activity yet.</p>";
|
|
9117
|
+
creditPanel = "<div class=\"panel\"><h3 class=\"subhead\">Store credit</h3>" +
|
|
9118
|
+
creditNotice +
|
|
9119
|
+
"<p class=\"stat-figure\">" + _htmlEscape(pricing.format(bal, currency)) + "</p>" +
|
|
9120
|
+
"<p class=\"meta\">Account-bound balance — applied at checkout. Every grant or deduct is recorded with your reason in the ledger below.</p>" +
|
|
9121
|
+
"<form method=\"post\" action=\"/admin/customers/" + _htmlEscape(enc) + "/store-credit\">" +
|
|
9122
|
+
"<fieldset class=\"box\"><legend class=\"legend-sm\">Direction</legend>" +
|
|
9123
|
+
"<label class=\"kv\"><input type=\"radio\" name=\"direction\" value=\"grant\" checked> Grant (add credit)</label>" +
|
|
9124
|
+
"<label class=\"kv\"><input type=\"radio\" name=\"direction\" value=\"deduct\"> Deduct (remove credit)</label>" +
|
|
9125
|
+
"</fieldset>" +
|
|
9126
|
+
"<label class=\"form-field\"><span>Amount (" + _htmlEscape(currency) + " minor units)</span>" +
|
|
9127
|
+
"<input type=\"number\" name=\"amount_minor\" min=\"1\" step=\"1\" required class=\"input-code\" placeholder=\"e.g. 500 = " + _htmlEscape(pricing.format(500, currency)) + "\"></label>" +
|
|
9128
|
+
"<label class=\"form-field\"><span>Reason</span>" +
|
|
9129
|
+
"<input type=\"text\" name=\"reason\" required maxlength=\"128\" placeholder=\"e.g. goodwill for the late delivery\">" +
|
|
9130
|
+
"<small>Required. Recorded with the adjustment in the ledger.</small></label>" +
|
|
9131
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Apply adjustment</button></div>" +
|
|
9132
|
+
"</form>" +
|
|
9133
|
+
histTable +
|
|
9134
|
+
"</div>";
|
|
9135
|
+
}
|
|
9136
|
+
|
|
9137
|
+
// ---- loyalty -------------------------------------------------------
|
|
9138
|
+
var loyaltyPanel = "";
|
|
9139
|
+
if (opts.loyalty_link) {
|
|
9140
|
+
var loy = opts.loyalty || { balance: 0, lifetime: 0, tier: "bronze" };
|
|
9141
|
+
loyaltyPanel = "<div class=\"panel\"><div class=\"actions-row\"><h3 class=\"subhead\">Loyalty</h3>" +
|
|
9142
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/loyalty\">Adjust points</a></div>" +
|
|
9143
|
+
"<dl class=\"kv-list\">" +
|
|
9144
|
+
"<div><dt>Balance</dt><dd>" + _htmlEscape(String(loy.balance)) + " pts</dd></div>" +
|
|
9145
|
+
"<div><dt>Lifetime</dt><dd>" + _htmlEscape(String(loy.lifetime)) + " pts</dd></div>" +
|
|
9146
|
+
"<div><dt>Tier</dt><dd>" + _htmlEscape(String(loy.tier)) + "</dd></div>" +
|
|
9147
|
+
"</dl>" +
|
|
9148
|
+
"<p class=\"meta\">Grant or deduct points from the Loyalty screen — paste this customer's id (above) into the adjustment form.</p>" +
|
|
9149
|
+
"</div>";
|
|
9150
|
+
}
|
|
9151
|
+
|
|
9152
|
+
// ---- notes ---------------------------------------------------------
|
|
9153
|
+
var notesPanel;
|
|
9154
|
+
if (!opts.can_notes) {
|
|
9155
|
+
notesPanel = "<div class=\"panel\"><h3 class=\"subhead\">Notes</h3>" +
|
|
9156
|
+
"<p class=\"empty\">Customer notes aren't wired in this deployment.</p></div>";
|
|
9157
|
+
} else {
|
|
9158
|
+
var noteNotice = opts.note_notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.note_notice) + "</div>" : "";
|
|
9159
|
+
var noteRows = (opts.notes || []).map(function (n) {
|
|
9160
|
+
return "<li><div class=\"note-meta\">" + _htmlEscape(n.kind) + " · " + _htmlEscape(_fmtDate(n.created_at)) + "</div>" +
|
|
9161
|
+
"<div class=\"note-body\">" + _htmlEscape(n.body) + "</div></li>";
|
|
9162
|
+
}).join("");
|
|
9163
|
+
notesPanel = "<div class=\"panel\"><h3 class=\"subhead\">Notes</h3>" +
|
|
9164
|
+
noteNotice +
|
|
9165
|
+
"<p class=\"meta\">Operator-only annotations on this customer. Never shown to the customer.</p>" +
|
|
9166
|
+
((opts.notes || []).length
|
|
9167
|
+
? "<ul class=\"note-list\">" + noteRows + "</ul>"
|
|
9168
|
+
: "<p class=\"empty\">No notes yet.</p>") +
|
|
9169
|
+
"<form method=\"post\" action=\"/admin/customers/" + _htmlEscape(enc) + "/notes\">" +
|
|
9170
|
+
"<label class=\"form-field\"><span>Kind</span>" +
|
|
9171
|
+
"<select name=\"kind\">" +
|
|
9172
|
+
"<option value=\"general\">general</option>" +
|
|
9173
|
+
"<option value=\"preference\">preference</option>" +
|
|
9174
|
+
"<option value=\"escalation\">escalation</option>" +
|
|
9175
|
+
"<option value=\"warning\">warning</option>" +
|
|
9176
|
+
"<option value=\"billing\">billing</option>" +
|
|
9177
|
+
"</select></label>" +
|
|
9178
|
+
"<label class=\"form-field\"><span>Note</span>" +
|
|
9179
|
+
"<textarea name=\"body\" required maxlength=\"8000\" rows=\"3\" placeholder=\"e.g. VIP — comp shipping where possible\"></textarea></label>" +
|
|
9180
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add note</button></div>" +
|
|
9181
|
+
"</form>" +
|
|
9182
|
+
"</div>";
|
|
9183
|
+
}
|
|
9184
|
+
|
|
9185
|
+
// ---- segments (read-only) ------------------------------------------
|
|
9186
|
+
var segmentsPanel = "";
|
|
9187
|
+
if (opts.can_segments) {
|
|
9188
|
+
var segChips = (opts.segments || []).map(function (s) {
|
|
9189
|
+
return "<span class=\"chip\">" + _htmlEscape(s.title || s.slug) + "</span>";
|
|
9190
|
+
}).join(" ");
|
|
9191
|
+
segmentsPanel = "<div class=\"panel\"><h3 class=\"subhead\">Segments</h3>" +
|
|
9192
|
+
((opts.segments || []).length
|
|
9193
|
+
? "<div class=\"chip-row\">" + segChips + "</div>"
|
|
9194
|
+
: "<p class=\"empty\">Not in any segment.</p>") +
|
|
9195
|
+
"<p class=\"meta\">Segment membership is rule-derived — the scheduler recomputes it from each segment's RFM predicates, so there is no manual assign / remove per customer. Define or edit segments under their own console.</p>" +
|
|
9196
|
+
"</div>";
|
|
9197
|
+
}
|
|
9198
|
+
|
|
9199
|
+
var body = "<section>" +
|
|
9200
|
+
"<div class=\"actions-row\"><h2>" + _htmlEscape(c.display_name) + "</h2>" +
|
|
9201
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/customers\"><span aria-hidden=\"true\">←</span> All customers</a></div>" +
|
|
9202
|
+
saved +
|
|
9203
|
+
identity + ordersPanel + creditPanel + loyaltyPanel + notesPanel + segmentsPanel +
|
|
9204
|
+
"</section>";
|
|
9205
|
+
return _renderAdminShell(opts.shop_name, c.display_name || "Customer", body, "customers", opts.nav_available);
|
|
9206
|
+
}
|
|
9207
|
+
|
|
8246
9208
|
// The RMA states an operator can filter the returns queue by — drives the
|
|
8247
9209
|
// filter chips, lifecycle order then terminal.
|
|
8248
9210
|
var RETURN_STATUS_FILTERS = ["pending", "approved", "received", "refunded", "rejected"];
|
|
@@ -10960,6 +11922,354 @@ function renderAdminQuantityDiscount(opts) {
|
|
|
10960
11922
|
return _renderAdminShell(opts.shop_name, "Quantity break", body, "quantity-discounts", opts.nav_available);
|
|
10961
11923
|
}
|
|
10962
11924
|
|
|
11925
|
+
// ---- loyalty render functions ------------------------------------------
|
|
11926
|
+
|
|
11927
|
+
// Status pill for an active / inactive / archived loyalty row — shared by
|
|
11928
|
+
// the earn-rule + reward lists.
|
|
11929
|
+
function _loyaltyStatusPill(active, archivedAt) {
|
|
11930
|
+
if (archivedAt != null) return "<span class=\"status-pill cancelled\">archived</span>";
|
|
11931
|
+
return active
|
|
11932
|
+
? "<span class=\"status-pill paid\">active</span>"
|
|
11933
|
+
: "<span class=\"status-pill pending\">inactive</span>";
|
|
11934
|
+
}
|
|
11935
|
+
|
|
11936
|
+
// Render a reward's value_json as a short human label per kind.
|
|
11937
|
+
function _loyaltyRewardValueLabel(reward) {
|
|
11938
|
+
var v = reward.value_json || {};
|
|
11939
|
+
if (reward.kind === "discount_percent") return _htmlEscape(String(v.percent)) + "% off";
|
|
11940
|
+
if (reward.kind === "discount_amount") return _htmlEscape(String(v.amount_minor)) + " minor off";
|
|
11941
|
+
if (reward.kind === "free_product") return "free product " + _htmlEscape(String(v.product_id || ""));
|
|
11942
|
+
if (reward.kind === "free_shipping") return "free shipping";
|
|
11943
|
+
return _htmlEscape(reward.kind);
|
|
11944
|
+
}
|
|
11945
|
+
|
|
11946
|
+
// Loyalty overview / settings — the program's tiers + conversion ratios,
|
|
11947
|
+
// a summary of earn rules + rewards (with links to manage each), and the
|
|
11948
|
+
// per-customer points-adjustment form (grant / deduct with a required
|
|
11949
|
+
// reason, recorded in the loyalty ledger). The customer's /account/loyalty
|
|
11950
|
+
// page shows only ACTIVE earn rules + ACTIVE rewards, so the summary flags
|
|
11951
|
+
// inactive rows.
|
|
11952
|
+
function renderAdminLoyalty(opts) {
|
|
11953
|
+
opts = opts || {};
|
|
11954
|
+
var adjusted = opts.adjusted ? "<div class=\"banner banner--ok\">Points adjusted. The ledger recorded the change.</div>" : "";
|
|
11955
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
11956
|
+
var adjustNotice = opts.adjust_notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.adjust_notice) + "</div>" : "";
|
|
11957
|
+
|
|
11958
|
+
var thresholds = opts.tier_thresholds || {};
|
|
11959
|
+
var tiers = opts.tiers || [];
|
|
11960
|
+
var tierRows = tiers.map(function (t) {
|
|
11961
|
+
return "<tr><td><strong>" + _htmlEscape(t) + "</strong></td>" +
|
|
11962
|
+
"<td class=\"num\">" + _htmlEscape(String(thresholds[t] == null ? "—" : thresholds[t])) + "</td></tr>";
|
|
11963
|
+
}).join("");
|
|
11964
|
+
var tierTable =
|
|
11965
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Tiers</h3>" +
|
|
11966
|
+
"<p class=\"meta\">Lifetime points (never decremented) place a customer in a tier. " +
|
|
11967
|
+
"Earning $1 grants " + _htmlEscape(String(opts.points_per_usd)) + " points; " +
|
|
11968
|
+
_htmlEscape(String(opts.redemption_points_per_usd)) + " points redeem for $1 of value.</p>" +
|
|
11969
|
+
"<table><thead><tr><th scope=\"col\">Tier</th><th scope=\"col\" class=\"num\">Lifetime points to reach</th></tr></thead>" +
|
|
11970
|
+
"<tbody>" + tierRows + "</tbody></table></div>";
|
|
11971
|
+
|
|
11972
|
+
// Earn-rule summary.
|
|
11973
|
+
var earnSummary;
|
|
11974
|
+
if (!opts.can_manage_rules) {
|
|
11975
|
+
earnSummary = "<div class=\"panel\"><h3 class=\"subhead\">Earn rules</h3>" +
|
|
11976
|
+
"<p class=\"empty\">Earn-rule management isn't wired in this deployment.</p></div>";
|
|
11977
|
+
} else {
|
|
11978
|
+
var earnRules = opts.earn_rules || [];
|
|
11979
|
+
var earnRows = earnRules.map(function (r) {
|
|
11980
|
+
var enc = encodeURIComponent(r.slug);
|
|
11981
|
+
return "<tr>" +
|
|
11982
|
+
"<td><a href=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(r.slug) + "</strong></a></td>" +
|
|
11983
|
+
"<td>" + _htmlEscape(r.trigger) + "</td>" +
|
|
11984
|
+
"<td class=\"num\">" + _htmlEscape(String(r.points_per_unit)) + "</td>" +
|
|
11985
|
+
"<td>" + _loyaltyStatusPill(r.active, r.archived_at) + "</td>" +
|
|
11986
|
+
"</tr>";
|
|
11987
|
+
}).join("");
|
|
11988
|
+
earnSummary = "<div class=\"panel\"><div class=\"actions-row\"><h3 class=\"subhead\">Earn rules</h3>" +
|
|
11989
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/loyalty/earn-rules\">Manage earn rules</a></div>" +
|
|
11990
|
+
(earnRules.length
|
|
11991
|
+
? "<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>"
|
|
11992
|
+
: "<p class=\"empty\">No earn rules yet. Customers only earn points from active rules.</p>") +
|
|
11993
|
+
"</div>";
|
|
11994
|
+
}
|
|
11995
|
+
|
|
11996
|
+
// Reward summary.
|
|
11997
|
+
var rewardSummary;
|
|
11998
|
+
if (!opts.can_manage_rewards) {
|
|
11999
|
+
rewardSummary = "<div class=\"panel\"><h3 class=\"subhead\">Rewards catalog</h3>" +
|
|
12000
|
+
"<p class=\"empty\">Rewards-catalog management isn't wired in this deployment.</p></div>";
|
|
12001
|
+
} else {
|
|
12002
|
+
var rewards = opts.rewards || [];
|
|
12003
|
+
var rewardRows = rewards.map(function (rw) {
|
|
12004
|
+
var enc = encodeURIComponent(rw.slug);
|
|
12005
|
+
return "<tr>" +
|
|
12006
|
+
"<td><a href=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(rw.slug) + "</strong></a></td>" +
|
|
12007
|
+
"<td>" + _htmlEscape(rw.title) + "</td>" +
|
|
12008
|
+
"<td class=\"num\">" + _htmlEscape(String(rw.point_cost)) + "</td>" +
|
|
12009
|
+
"<td>" + _loyaltyStatusPill(rw.active, rw.archived_at) + "</td>" +
|
|
12010
|
+
"</tr>";
|
|
12011
|
+
}).join("");
|
|
12012
|
+
rewardSummary = "<div class=\"panel\"><div class=\"actions-row\"><h3 class=\"subhead\">Rewards catalog</h3>" +
|
|
12013
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/loyalty/rewards\">Manage rewards</a></div>" +
|
|
12014
|
+
(rewards.length
|
|
12015
|
+
? "<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>"
|
|
12016
|
+
: "<p class=\"empty\">No rewards yet. Customers only see active rewards in their catalog.</p>") +
|
|
12017
|
+
"</div>";
|
|
12018
|
+
}
|
|
12019
|
+
|
|
12020
|
+
// Points-adjustment form. The customer id is pasted (the customers
|
|
12021
|
+
// roster shows each customer's id); the amount is a positive integer; a
|
|
12022
|
+
// grant / deduct radio sets the sign; the reason is required and lands
|
|
12023
|
+
// in the ledger row.
|
|
12024
|
+
var prefillCid = opts.adjust_customer_id ? _htmlEscape(opts.adjust_customer_id) : "";
|
|
12025
|
+
var adjustForm =
|
|
12026
|
+
"<div class=\"panel mw-40\"><h3 class=\"subhead\">Adjust a customer's points</h3>" +
|
|
12027
|
+
"<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>" +
|
|
12028
|
+
adjustNotice +
|
|
12029
|
+
"<form method=\"post\" action=\"/admin/loyalty/adjust\">" +
|
|
12030
|
+
"<label class=\"form-field\"><span>Customer id</span>" +
|
|
12031
|
+
"<input type=\"text\" name=\"customer_id\" value=\"" + prefillCid + "\" required maxlength=\"64\" class=\"input-code\" placeholder=\"customer UUID\">" +
|
|
12032
|
+
"<small>From the Customers roster — each row's id column.</small></label>" +
|
|
12033
|
+
"<fieldset class=\"box\"><legend class=\"legend-sm\">Direction</legend>" +
|
|
12034
|
+
"<label class=\"kv\"><input type=\"radio\" name=\"direction\" value=\"grant\" checked> Grant (add points)</label>" +
|
|
12035
|
+
"<label class=\"kv\"><input type=\"radio\" name=\"direction\" value=\"deduct\"> Deduct (remove points)</label>" +
|
|
12036
|
+
"</fieldset>" +
|
|
12037
|
+
"<label class=\"form-field\"><span>Amount (points)</span>" +
|
|
12038
|
+
"<input type=\"number\" name=\"amount\" min=\"1\" step=\"1\" required class=\"input-code\"></label>" +
|
|
12039
|
+
"<label class=\"form-field\"><span>Reason</span>" +
|
|
12040
|
+
"<input type=\"text\" name=\"reason\" required maxlength=\"256\" placeholder=\"e.g. service recovery for order #1234\">" +
|
|
12041
|
+
"<small>Required. Recorded with the adjustment in the ledger.</small></label>" +
|
|
12042
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Apply adjustment</button></div>" +
|
|
12043
|
+
"</form>" +
|
|
12044
|
+
"</div>";
|
|
12045
|
+
|
|
12046
|
+
var bodyHtml = "<section><h2>Loyalty</h2>" + adjusted + notice +
|
|
12047
|
+
tierTable + earnSummary + rewardSummary + adjustForm + "</section>";
|
|
12048
|
+
return _renderAdminShell(opts.shop_name, "Loyalty", bodyHtml, "loyalty", opts.nav_available);
|
|
12049
|
+
}
|
|
12050
|
+
|
|
12051
|
+
// Earn-rules list + create form. Customers earn points only from ACTIVE
|
|
12052
|
+
// rules, so the create form defaults `active` on and the list flags
|
|
12053
|
+
// inactive / archived rows.
|
|
12054
|
+
function renderAdminLoyaltyEarnRules(opts) {
|
|
12055
|
+
opts = opts || {};
|
|
12056
|
+
var rules = opts.rules || [];
|
|
12057
|
+
var triggers = opts.triggers || loyaltyEarnRulesModule.TRIGGERS;
|
|
12058
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Earn rule saved.</div>" : "";
|
|
12059
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
|
|
12060
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Earn rule archived.</div>" : "";
|
|
12061
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
12062
|
+
|
|
12063
|
+
var bodyRows = rules.map(function (r) {
|
|
12064
|
+
var enc = encodeURIComponent(r.slug);
|
|
12065
|
+
var statusList = (r.customer_status_in && r.customer_status_in.length) ? r.customer_status_in.join(", ") : "all";
|
|
12066
|
+
return "<tr>" +
|
|
12067
|
+
"<td><a href=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(r.slug) + "</strong></a></td>" +
|
|
12068
|
+
"<td>" + _htmlEscape(r.trigger) + "</td>" +
|
|
12069
|
+
"<td class=\"num\">" + _htmlEscape(String(r.points_per_unit)) + "</td>" +
|
|
12070
|
+
"<td class=\"num\">" + _htmlEscape(r.max_per_event == null ? "—" : String(r.max_per_event)) + "</td>" +
|
|
12071
|
+
"<td>" + _htmlEscape(statusList) + "</td>" +
|
|
12072
|
+
"<td>" + _loyaltyStatusPill(r.active, r.archived_at) + "</td>" +
|
|
12073
|
+
"<td><div class=\"actions-row\">" +
|
|
12074
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "\">Manage</a>" +
|
|
12075
|
+
(r.archived_at == null
|
|
12076
|
+
? "<form method=\"post\" action=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
|
|
12077
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>"
|
|
12078
|
+
: "") +
|
|
12079
|
+
"</div></td>" +
|
|
12080
|
+
"</tr>";
|
|
12081
|
+
}).join("");
|
|
12082
|
+
var table = rules.length
|
|
12083
|
+
? "<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>"
|
|
12084
|
+
: "<p class=\"empty\">No earn rules yet.</p>";
|
|
12085
|
+
|
|
12086
|
+
var triggerOpts = triggers.map(function (t) {
|
|
12087
|
+
return "<option value=\"" + _htmlEscape(t) + "\">" + _htmlEscape(t) + "</option>";
|
|
12088
|
+
}).join("");
|
|
12089
|
+
|
|
12090
|
+
var createForm =
|
|
12091
|
+
"<div class=\"panel mt mw-40\"><h3 class=\"subhead\">Create an earn rule</h3>" +
|
|
12092
|
+
"<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>" +
|
|
12093
|
+
"<form method=\"post\" action=\"/admin/loyalty/earn-rules\">" +
|
|
12094
|
+
_setupField("Slug", "slug", "", "text", "Lowercase letters, digits, dashes (e.g. spend-1pt-per-dollar).", " maxlength=\"100\" required") +
|
|
12095
|
+
"<label class=\"form-field\"><span>Trigger</span><select name=\"trigger\">" + triggerOpts + "</select></label>" +
|
|
12096
|
+
_setupField("Points per unit", "points_per_unit", "", "number", "Positive integer.", " min=\"1\" step=\"1\" required class=\"input-code\"") +
|
|
12097
|
+
_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\"") +
|
|
12098
|
+
_setupField("Customer statuses", "customer_status_in", "", "text", "Optional comma-separated list (e.g. active, vip). Blank means every customer is eligible.", " maxlength=\"256\"") +
|
|
12099
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\" value=\"on\" checked> Active — show this rule to customers and award on its trigger.</label>" +
|
|
12100
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create earn rule</button></div>" +
|
|
12101
|
+
"</form>" +
|
|
12102
|
+
"</div>";
|
|
12103
|
+
|
|
12104
|
+
var bodyHtml = "<section><h2>Earn rules</h2>" +
|
|
12105
|
+
"<p class=\"meta\"><a href=\"/admin/loyalty\">← Loyalty</a></p>" +
|
|
12106
|
+
created + saved + archived + notice + table + createForm + "</section>";
|
|
12107
|
+
return _renderAdminShell(opts.shop_name, "Earn rules", bodyHtml, "loyalty", opts.nav_available);
|
|
12108
|
+
}
|
|
12109
|
+
|
|
12110
|
+
// Earn-rule detail — the rule's stored fields and an edit form. trigger
|
|
12111
|
+
// is immutable on update (the primitive refuses a change), so the form
|
|
12112
|
+
// shows it read-only.
|
|
12113
|
+
function renderAdminLoyaltyEarnRule(opts) {
|
|
12114
|
+
opts = opts || {};
|
|
12115
|
+
var r = opts.rule;
|
|
12116
|
+
if (!r) {
|
|
12117
|
+
var nf = "<section><h2>Earn rule</h2><p class=\"empty\">Earn rule not found.</p>" +
|
|
12118
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/loyalty/earn-rules\">Back to earn rules</a></div></section>";
|
|
12119
|
+
return _renderAdminShell(opts.shop_name, "Earn rule", nf, "loyalty", opts.nav_available);
|
|
12120
|
+
}
|
|
12121
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
|
|
12122
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
12123
|
+
var enc = encodeURIComponent(r.slug);
|
|
12124
|
+
var statusList = (r.customer_status_in && r.customer_status_in.length) ? r.customer_status_in.join(", ") : "";
|
|
12125
|
+
var isArchived = r.archived_at != null;
|
|
12126
|
+
|
|
12127
|
+
var editForm = isArchived
|
|
12128
|
+
? "<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>"
|
|
12129
|
+
: "<div class=\"panel mw-40\"><h3 class=\"subhead\">Edit</h3>" +
|
|
12130
|
+
"<form method=\"post\" action=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "/edit\">" +
|
|
12131
|
+
_setupField("Points per unit", "points_per_unit", String(r.points_per_unit), "number", "Positive integer.", " min=\"1\" step=\"1\" class=\"input-code\"") +
|
|
12132
|
+
_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\"") +
|
|
12133
|
+
_setupField("Customer statuses", "customer_status_in", statusList, "text", "Comma-separated; blank means all customers.", " maxlength=\"256\"") +
|
|
12134
|
+
"<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
|
|
12135
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\" value=\"on\"" + (r.active ? " checked" : "") + "> Active</label>" +
|
|
12136
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save rule</button></div>" +
|
|
12137
|
+
"</form>" +
|
|
12138
|
+
"</div>";
|
|
12139
|
+
|
|
12140
|
+
var archiveBlock = isArchived ? "" :
|
|
12141
|
+
"<div class=\"actions-row mt-1\">" +
|
|
12142
|
+
"<form method=\"post\" action=\"/admin/loyalty/earn-rules/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
|
|
12143
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive rule</button></form>" +
|
|
12144
|
+
"</div>";
|
|
12145
|
+
|
|
12146
|
+
var head =
|
|
12147
|
+
"<p class=\"meta\"><a href=\"/admin/loyalty/earn-rules\">← Earn rules</a> · " +
|
|
12148
|
+
"<span class=\"status-pill paid\">" + _htmlEscape(r.trigger) + "</span> · " +
|
|
12149
|
+
_loyaltyStatusPill(r.active, r.archived_at) + "</p>";
|
|
12150
|
+
|
|
12151
|
+
var body = "<section><h2>" + _htmlEscape(r.slug) + "</h2>" + saved + notice + head + editForm + archiveBlock + "</section>";
|
|
12152
|
+
return _renderAdminShell(opts.shop_name, "Earn rule", body, "loyalty", opts.nav_available);
|
|
12153
|
+
}
|
|
12154
|
+
|
|
12155
|
+
// Rewards-catalog list + create form. A reward must be active (and not
|
|
12156
|
+
// archived) to appear in the customer's redemption catalog, so the create
|
|
12157
|
+
// form defaults active on and the list flags inactive / archived rows.
|
|
12158
|
+
function renderAdminLoyaltyRewards(opts) {
|
|
12159
|
+
opts = opts || {};
|
|
12160
|
+
var rewards = opts.rewards || [];
|
|
12161
|
+
var kinds = opts.kinds || loyaltyRedemptionModule.KINDS;
|
|
12162
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Reward saved.</div>" : "";
|
|
12163
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
|
|
12164
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Reward archived.</div>" : "";
|
|
12165
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
12166
|
+
|
|
12167
|
+
var bodyRows = rewards.map(function (rw) {
|
|
12168
|
+
var enc = encodeURIComponent(rw.slug);
|
|
12169
|
+
return "<tr>" +
|
|
12170
|
+
"<td><a href=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(rw.slug) + "</strong></a></td>" +
|
|
12171
|
+
"<td>" + _htmlEscape(rw.title) + "</td>" +
|
|
12172
|
+
"<td>" + _htmlEscape(rw.kind) + "</td>" +
|
|
12173
|
+
"<td>" + _loyaltyRewardValueLabel(rw) + "</td>" +
|
|
12174
|
+
"<td class=\"num\">" + _htmlEscape(String(rw.point_cost)) + "</td>" +
|
|
12175
|
+
"<td>" + _loyaltyStatusPill(rw.active, rw.archived_at) + "</td>" +
|
|
12176
|
+
"<td><div class=\"actions-row\">" +
|
|
12177
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "\">Manage</a>" +
|
|
12178
|
+
(rw.archived_at == null
|
|
12179
|
+
? "<form method=\"post\" action=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
|
|
12180
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>"
|
|
12181
|
+
: "") +
|
|
12182
|
+
"</div></td>" +
|
|
12183
|
+
"</tr>";
|
|
12184
|
+
}).join("");
|
|
12185
|
+
var table = rewards.length
|
|
12186
|
+
? "<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>"
|
|
12187
|
+
: "<p class=\"empty\">No rewards yet.</p>";
|
|
12188
|
+
|
|
12189
|
+
var kindOpts = kinds.map(function (k) {
|
|
12190
|
+
return "<option value=\"" + _htmlEscape(k) + "\">" + _htmlEscape(k) + "</option>";
|
|
12191
|
+
}).join("");
|
|
12192
|
+
|
|
12193
|
+
var createForm =
|
|
12194
|
+
"<div class=\"panel mt mw-40\"><h3 class=\"subhead\">Create a reward</h3>" +
|
|
12195
|
+
"<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>" +
|
|
12196
|
+
"<form method=\"post\" action=\"/admin/loyalty/rewards\">" +
|
|
12197
|
+
_setupField("Slug", "slug", "", "text", "Letters, digits, dashes, dots, underscores.", " maxlength=\"80\" required") +
|
|
12198
|
+
"<label class=\"form-field\"><span>Kind</span><select name=\"kind\">" + kindOpts + "</select></label>" +
|
|
12199
|
+
_setupField("Title", "title", "", "text", "Shown to customers in the catalog.", " maxlength=\"200\" required") +
|
|
12200
|
+
_setupField("Point cost", "point_cost", "", "number", "Positive integer — points spent to redeem.", " min=\"1\" step=\"1\" required class=\"input-code\"") +
|
|
12201
|
+
_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\"") +
|
|
12202
|
+
_setupField("Value (product id)", "value_text", "", "text", "Only for free_product — the product id the reward grants.", " maxlength=\"128\"") +
|
|
12203
|
+
_setupField("Max per customer", "max_per_customer", "", "number", "Optional lifetime cap per customer. Blank for unlimited.", " min=\"1\" step=\"1\" class=\"input-code\"") +
|
|
12204
|
+
_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\"") +
|
|
12205
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\" value=\"on\" checked> Active — show this reward in the customer catalog.</label>" +
|
|
12206
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create reward</button></div>" +
|
|
12207
|
+
"</form>" +
|
|
12208
|
+
"</div>";
|
|
12209
|
+
|
|
12210
|
+
var bodyHtml = "<section><h2>Rewards catalog</h2>" +
|
|
12211
|
+
"<p class=\"meta\"><a href=\"/admin/loyalty\">← Loyalty</a></p>" +
|
|
12212
|
+
created + saved + archived + notice + table + createForm + "</section>";
|
|
12213
|
+
return _renderAdminShell(opts.shop_name, "Rewards catalog", bodyHtml, "loyalty", opts.nav_available);
|
|
12214
|
+
}
|
|
12215
|
+
|
|
12216
|
+
// Reward detail — the reward's stored fields and an edit form. kind is
|
|
12217
|
+
// immutable on update (the primitive validates value_json against the
|
|
12218
|
+
// stored kind), so the form shows it read-only and re-validates value on
|
|
12219
|
+
// change.
|
|
12220
|
+
function renderAdminLoyaltyReward(opts) {
|
|
12221
|
+
opts = opts || {};
|
|
12222
|
+
var rw = opts.reward;
|
|
12223
|
+
if (!rw) {
|
|
12224
|
+
var nf = "<section><h2>Reward</h2><p class=\"empty\">Reward not found.</p>" +
|
|
12225
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/loyalty/rewards\">Back to rewards</a></div></section>";
|
|
12226
|
+
return _renderAdminShell(opts.shop_name, "Reward", nf, "loyalty", opts.nav_available);
|
|
12227
|
+
}
|
|
12228
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
|
|
12229
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
12230
|
+
var enc = encodeURIComponent(rw.slug);
|
|
12231
|
+
var isArchived = rw.archived_at != null;
|
|
12232
|
+
var v = rw.value_json || {};
|
|
12233
|
+
var valueNumber = rw.kind === "discount_percent" ? (v.percent == null ? "" : String(v.percent))
|
|
12234
|
+
: rw.kind === "discount_amount" ? (v.amount_minor == null ? "" : String(v.amount_minor))
|
|
12235
|
+
: "";
|
|
12236
|
+
var valueText = rw.kind === "free_product" ? String(v.product_id || "") : "";
|
|
12237
|
+
|
|
12238
|
+
var editForm = isArchived
|
|
12239
|
+
? "<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>"
|
|
12240
|
+
: "<div class=\"panel mw-40\"><h3 class=\"subhead\">Edit</h3>" +
|
|
12241
|
+
"<form method=\"post\" action=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "/edit\">" +
|
|
12242
|
+
_setupField("Title", "title", rw.title, "text", "", " maxlength=\"200\"") +
|
|
12243
|
+
_setupField("Point cost", "point_cost", String(rw.point_cost), "number", "", " min=\"1\" step=\"1\" class=\"input-code\"") +
|
|
12244
|
+
(rw.kind === "free_product"
|
|
12245
|
+
? _setupField("Value (product id)", "value_text", valueText, "text", "Leave unchanged to keep the current product.", " maxlength=\"128\"")
|
|
12246
|
+
: rw.kind === "free_shipping"
|
|
12247
|
+
? "<p class=\"meta\">free_shipping carries no value field.</p>"
|
|
12248
|
+
: _setupField("Value (number)", "value_number", valueNumber, "number", "percent (1–100) or amount in minor units.", " min=\"1\" step=\"1\" class=\"input-code\"")) +
|
|
12249
|
+
_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\"") +
|
|
12250
|
+
_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\"") +
|
|
12251
|
+
"<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
|
|
12252
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\" value=\"on\"" + (rw.active ? " checked" : "") + "> Active — show in the customer catalog.</label>" +
|
|
12253
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save reward</button></div>" +
|
|
12254
|
+
"</form>" +
|
|
12255
|
+
"</div>";
|
|
12256
|
+
|
|
12257
|
+
var archiveBlock = isArchived ? "" :
|
|
12258
|
+
"<div class=\"actions-row mt-1\">" +
|
|
12259
|
+
"<form method=\"post\" action=\"/admin/loyalty/rewards/" + _htmlEscape(enc) + "/archive\" class=\"form-inline\">" +
|
|
12260
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive reward</button></form>" +
|
|
12261
|
+
"</div>";
|
|
12262
|
+
|
|
12263
|
+
var head =
|
|
12264
|
+
"<p class=\"meta\"><a href=\"/admin/loyalty/rewards\">← Rewards catalog</a> · " +
|
|
12265
|
+
"<span class=\"status-pill paid\">" + _htmlEscape(rw.kind) + "</span> · " +
|
|
12266
|
+
_loyaltyStatusPill(rw.active, rw.archived_at) + " · " +
|
|
12267
|
+
"<code class=\"order-id\">" + _htmlEscape(String(rw.point_cost)) + " pts</code></p>";
|
|
12268
|
+
|
|
12269
|
+
var body = "<section><h2>" + _htmlEscape(rw.title) + "</h2>" + saved + notice + head + editForm + archiveBlock + "</section>";
|
|
12270
|
+
return _renderAdminShell(opts.shop_name, "Reward", body, "loyalty", opts.nav_available);
|
|
12271
|
+
}
|
|
12272
|
+
|
|
10963
12273
|
// Product detail / management screen — the console's full editor for a
|
|
10964
12274
|
// single catalog product: its fields (slug / title / description /
|
|
10965
12275
|
// status), its variants (create / edit / delete), each variant's price
|
|
@@ -11219,6 +12529,7 @@ module.exports = {
|
|
|
11219
12529
|
renderAdminOrders: renderAdminOrders,
|
|
11220
12530
|
renderAdminOrder: renderAdminOrder,
|
|
11221
12531
|
renderAdminCustomers: renderAdminCustomers,
|
|
12532
|
+
renderAdminCustomerDetail: renderAdminCustomerDetail,
|
|
11222
12533
|
renderAdminReturns: renderAdminReturns,
|
|
11223
12534
|
renderAdminReturn: renderAdminReturn,
|
|
11224
12535
|
renderAdminReviews: renderAdminReviews,
|
|
@@ -11239,5 +12550,10 @@ module.exports = {
|
|
|
11239
12550
|
renderAdminDiscountAllocation: renderAdminDiscountAllocation,
|
|
11240
12551
|
renderAdminQuantityDiscounts: renderAdminQuantityDiscounts,
|
|
11241
12552
|
renderAdminQuantityDiscount: renderAdminQuantityDiscount,
|
|
12553
|
+
renderAdminLoyalty: renderAdminLoyalty,
|
|
12554
|
+
renderAdminLoyaltyEarnRules: renderAdminLoyaltyEarnRules,
|
|
12555
|
+
renderAdminLoyaltyEarnRule: renderAdminLoyaltyEarnRule,
|
|
12556
|
+
renderAdminLoyaltyRewards: renderAdminLoyaltyRewards,
|
|
12557
|
+
renderAdminLoyaltyReward: renderAdminLoyaltyReward,
|
|
11242
12558
|
renderAdminConfirm: renderAdminConfirm,
|
|
11243
12559
|
};
|