@blamejs/blamejs-shop 0.3.24 → 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 +2 -0
- package/lib/admin.js +435 -4
- package/lib/asset-manifest.json +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.25 (2026-05-30) — **Open an individual customer to see their orders and manage store credit, notes, and loyalty.** The customers list was view-only — there was no way to open a single customer. Each customer now has a detail screen showing their identity, recent orders (linked), store-credit balance with a grant/deduct control, loyalty balance and tier (with a link to adjust points), internal operator notes, and the segments they belong to. A store-credit grant or deduction requires a reason and is written to the store-credit ledger, an over-deduction is refused, and segment membership is shown read-only because it is computed from purchase behavior rather than set by hand. **Added:** *Customer detail screen* — Opening a customer from the roster now shows their record, recent orders, store-credit balance with a reason-gated grant/deduct (recorded in the store-credit ledger; an over-deduction is refused), their loyalty balance and tier with a link to adjust points, and an internal notes log you can add to. The segments a customer belongs to are listed read-only, since membership is derived from their behavior. Customer identity fields remain read-only by design.
|
|
12
|
+
|
|
11
13
|
- v0.3.24 (2026-05-30) — **Manage the loyalty program from the admin console.** The loyalty program was visible to customers at /account/loyalty — their points balance, tier, the ways to earn, and the rewards — but there was no way to configure it from the console, so it could not actually be run. A new Loyalty admin screen now manages the whole program: create and edit the rules that award points (and archive ones that should no longer apply), build and edit the rewards catalog (a reward must be active to appear to customers), and grant or deduct a specific customer's points balance. A points adjustment requires a reason and is written to the loyalty ledger, so every manual change is recorded. **Added:** *Loyalty program administration* — A Loyalty screen in the admin console manages earn rules (the events that award points and how many), the rewards catalog (what points can be redeemed for, with per-kind values), and per-customer point adjustments. Only active, non-archived rules and rewards appear to customers. A manual grant or deduction requires a reason and is recorded in the loyalty ledger alongside automatic earn and redeem activity, and an over-deduction or invalid adjustment is rejected without changing the balance. This completes a program that previously could be shown to shoppers but not configured.
|
|
12
14
|
|
|
13
15
|
- v0.3.23 (2026-05-30) — **Fix page corruption from a dollar sign in content, keep search pages out of the index, and add a working unsubscribe link.** Three storefront fixes. First, a content-corruption bug: a dollar sign in any page's dynamic content — a product description, a blog post, a content page, a cart or admin page — could mangle the rendered HTML, and a dollar immediately followed by a backtick could splice the page's head markup into the body. Every page assembled its body in a way that gave dollar sequences special meaning; all of them now insert content verbatim. Second, internal search result pages are now marked noindex so the many query and filter URLs are not indexed as thin, duplicate pages (their product links are still followed). Third, the unsubscribe link that marketing emails point to now has a route behind it, so a recipient can actually unsubscribe (and resubscribe) instead of hitting a missing page. **Added:** *Newsletter unsubscribe link works* — A /unsubscribe route now backs the one-click unsubscribe link in marketing emails: opening the link shows a confirmation page, confirming removes the address, and a resubscribe option is offered. Invalid, expired, and already-used links each show a clear page rather than an error. The backend for this already existed; it simply had no route. **Fixed:** *A dollar sign in content no longer corrupts the page* — Pages were assembled by substituting the body into a template in a way that treated dollar sequences (such as a dollar followed by a backtick) as special, so content containing them could corrupt the output or pull head markup into the body. Every render path now inserts dynamic content literally, so any text — prices, code samples, anything with a dollar sign — renders exactly as written. · *Search result pages are no longer indexed* — Internal search result pages now carry a noindex robots tag so the large number of query and facet URL combinations aren't indexed as thin or duplicate content; the product links on those pages are still followed.
|
package/lib/admin.js
CHANGED
|
@@ -497,6 +497,9 @@ function mount(router, deps) {
|
|
|
497
497
|
var productQa = deps.productQa || null; // Q&A moderation endpoints disabled when absent
|
|
498
498
|
var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
|
|
499
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
|
|
500
503
|
var orderTracking = deps.orderTracking || null; // shipment/tracking panel disabled when absent
|
|
501
504
|
var salesReports = deps.salesReports || null; // /admin/reports degrades to an unconfigured notice when absent
|
|
502
505
|
var printReceipts = deps.printReceipts || null; // order receipt document disabled when absent
|
|
@@ -2597,6 +2600,276 @@ function mount(router, deps) {
|
|
|
2597
2600
|
}));
|
|
2598
2601
|
},
|
|
2599
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
|
+
}
|
|
2600
2873
|
}
|
|
2601
2874
|
|
|
2602
2875
|
// ---- refunds --------------------------------------------------------
|
|
@@ -8750,17 +9023,21 @@ function renderAdminCustomers(opts) {
|
|
|
8750
9023
|
}
|
|
8751
9024
|
var method = methodCells.length ? methodCells.join(" ") : "<span class=\"meta\">—</span>";
|
|
8752
9025
|
var orders = orderCounts[c.id] || 0;
|
|
9026
|
+
var enc = encodeURIComponent(c.id);
|
|
8753
9027
|
return "<tr>" +
|
|
8754
|
-
"<td><strong>" + _htmlEscape(c.display_name) + "</strong></td>" +
|
|
9028
|
+
"<td><a href=\"/admin/customers/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(c.display_name) + "</strong></a></td>" +
|
|
8755
9029
|
"<td><code class=\"order-id\">" + _htmlEscape(String(c.id).slice(0, 8)) + "</code></td>" +
|
|
8756
9030
|
"<td>" + _htmlEscape(_fmtDate(c.created_at)) + "</td>" +
|
|
8757
9031
|
"<td>" + method + "</td>" +
|
|
8758
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>" +
|
|
8759
9034
|
"</tr>";
|
|
8760
9035
|
}).join("");
|
|
8761
9036
|
|
|
9037
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
9038
|
+
|
|
8762
9039
|
var table = customers.length
|
|
8763
|
-
? "<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>"
|
|
8764
9041
|
: "<p class=\"empty\">No customers yet.</p>";
|
|
8765
9042
|
|
|
8766
9043
|
// Cursor pager — a Next link when the page filled and more rows remain.
|
|
@@ -8769,12 +9046,165 @@ function renderAdminCustomers(opts) {
|
|
|
8769
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>"
|
|
8770
9047
|
: "";
|
|
8771
9048
|
|
|
8772
|
-
var body = "<section><h2>Customers</h2>" +
|
|
8773
|
-
"<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>" +
|
|
8774
9051
|
table + pager + "</section>";
|
|
8775
9052
|
return _renderAdminShell(opts.shop_name, "Customers", body, "customers", opts.nav_available);
|
|
8776
9053
|
}
|
|
8777
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
|
+
|
|
8778
9208
|
// The RMA states an operator can filter the returns queue by — drives the
|
|
8779
9209
|
// filter chips, lifecycle order then terminal.
|
|
8780
9210
|
var RETURN_STATUS_FILTERS = ["pending", "approved", "received", "refunded", "rejected"];
|
|
@@ -12099,6 +12529,7 @@ module.exports = {
|
|
|
12099
12529
|
renderAdminOrders: renderAdminOrders,
|
|
12100
12530
|
renderAdminOrder: renderAdminOrder,
|
|
12101
12531
|
renderAdminCustomers: renderAdminCustomers,
|
|
12532
|
+
renderAdminCustomerDetail: renderAdminCustomerDetail,
|
|
12102
12533
|
renderAdminReturns: renderAdminReturns,
|
|
12103
12534
|
renderAdminReturn: renderAdminReturn,
|
|
12104
12535
|
renderAdminReviews: renderAdminReviews,
|
package/lib/asset-manifest.json
CHANGED
package/package.json
CHANGED