@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 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,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.24",
2
+ "version": "0.3.25",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.24",
3
+ "version": "0.3.25",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {