@blamejs/blamejs-shop 0.3.24 → 0.3.26
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 +811 -5
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +416 -0
- package/lib/support-tickets.js +54 -0
- package/package.json +1 -1
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
|
|
@@ -505,6 +508,7 @@ function mount(router, deps) {
|
|
|
505
508
|
var shippingLabels = deps.shippingLabels || null; // per-shipment carrier-label record disabled when absent
|
|
506
509
|
var splitShipments = deps.splitShipments || null; // order split-shipment planner disabled when absent
|
|
507
510
|
var salesTaxFilings = deps.salesTaxFilings || null; // sales-tax-filing remittance console disabled when absent
|
|
511
|
+
var supportTickets = deps.supportTickets || null; // support-ticket queue + thread console disabled when absent
|
|
508
512
|
|
|
509
513
|
// Which optional console sections are wired — gates their nav links so a
|
|
510
514
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
@@ -512,7 +516,7 @@ function mount(router, deps) {
|
|
|
512
516
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
513
517
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
514
518
|
// notice when the salesReports primitive isn't wired.
|
|
515
|
-
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
|
|
519
|
+
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, supportTickets: !!supportTickets };
|
|
516
520
|
|
|
517
521
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
518
522
|
|
|
@@ -2597,6 +2601,276 @@ function mount(router, deps) {
|
|
|
2597
2601
|
}));
|
|
2598
2602
|
},
|
|
2599
2603
|
));
|
|
2604
|
+
|
|
2605
|
+
// ---- customer detail ----------------------------------------------
|
|
2606
|
+
// The per-customer operator screen. The roster (above) is read-only by
|
|
2607
|
+
// design — the storefront owns account mutation via the passkey / OIDC
|
|
2608
|
+
// ceremonies — so this screen READS the customer's identity fields +
|
|
2609
|
+
// recent orders, and WRITES only the operator-managed satellites:
|
|
2610
|
+
// store-credit (an audited wallet), CRM notes, and (read-only) segment
|
|
2611
|
+
// membership. It does NOT edit the customer profile: the only mutable
|
|
2612
|
+
// identity field is display_name, and the roster's stated design intent
|
|
2613
|
+
// is read-only identity, so the operator-managed satellites are the
|
|
2614
|
+
// write surface here. Each satellite panel renders only when its
|
|
2615
|
+
// primitive is wired; an unwired one degrades to a "not wired" notice
|
|
2616
|
+
// rather than vanishing.
|
|
2617
|
+
|
|
2618
|
+
// Free-text reason gate for a store-credit adjustment. A store-credit
|
|
2619
|
+
// grant / deduct is a money-adjacent action, so the reason is MANDATORY
|
|
2620
|
+
// — it rides into the ledger row's source_ref (grant) / reason (deduct)
|
|
2621
|
+
// column so every balance change is attributed. Throws a TypeError
|
|
2622
|
+
// (→ 400) on a missing / blank / over-long / control-byte reason so the
|
|
2623
|
+
// form surfaces a clean notice and writes nothing. 128 chars matches the
|
|
2624
|
+
// primitive's source_ref cap (storeCredit MAX_SOURCE_REF_LEN); the
|
|
2625
|
+
// primitive re-validates, so this gate fails fast with operator-facing
|
|
2626
|
+
// text rather than relying on the deeper throw.
|
|
2627
|
+
var STORE_CREDIT_REASON_MAX = 128;
|
|
2628
|
+
function _storeCreditReason(raw) {
|
|
2629
|
+
if (raw == null) throw new TypeError("admin: a reason is required for a store-credit adjustment");
|
|
2630
|
+
var s = String(raw).trim();
|
|
2631
|
+
if (!s.length) throw new TypeError("admin: a reason is required for a store-credit adjustment");
|
|
2632
|
+
if (s.length > STORE_CREDIT_REASON_MAX) throw new TypeError("admin: reason must be <= " + STORE_CREDIT_REASON_MAX + " chars");
|
|
2633
|
+
if (/[\x00-\x1f\x7f]/.test(s)) throw new TypeError("admin: reason must not contain control bytes");
|
|
2634
|
+
return s;
|
|
2635
|
+
}
|
|
2636
|
+
// Coerce the grant/deduct form into a positive minor-unit amount + a
|
|
2637
|
+
// direction. `direction` is "grant" or "deduct"; `amount_minor` is a
|
|
2638
|
+
// positive integer (the strict reader refuses "", floats, "12abc").
|
|
2639
|
+
function _storeCreditDirection(body) {
|
|
2640
|
+
var dir = typeof body.direction === "string" ? body.direction.trim() : "";
|
|
2641
|
+
if (dir !== "grant" && dir !== "deduct") {
|
|
2642
|
+
throw new TypeError("admin: direction must be 'grant' or 'deduct'");
|
|
2643
|
+
}
|
|
2644
|
+
return dir;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// Best-effort hydrate of a customer's satellites for the detail render.
|
|
2648
|
+
// A satellite whose primitive isn't wired (or whose table isn't migrated
|
|
2649
|
+
// on a given deploy) degrades that panel to a notice rather than 500-ing
|
|
2650
|
+
// the page — same discipline as the order-detail tracking panel.
|
|
2651
|
+
async function _customerDetailModel(customer, flags) {
|
|
2652
|
+
flags = flags || {};
|
|
2653
|
+
var currency = await _defaultCurrency();
|
|
2654
|
+
|
|
2655
|
+
var recentOrders = [];
|
|
2656
|
+
try {
|
|
2657
|
+
var ordersPage = await order.listForCustomer(customer.id, { limit: 10 });
|
|
2658
|
+
recentOrders = ordersPage.rows || [];
|
|
2659
|
+
} catch (_e) { recentOrders = []; }
|
|
2660
|
+
|
|
2661
|
+
var creditBalanceMinor = null;
|
|
2662
|
+
var creditHistory = [];
|
|
2663
|
+
if (storeCredit) {
|
|
2664
|
+
try { creditBalanceMinor = (await storeCredit.balance(customer.id)).balance_minor; }
|
|
2665
|
+
catch (_e) { creditBalanceMinor = null; }
|
|
2666
|
+
try { creditHistory = (await storeCredit.history({ customer_id: customer.id, limit: 10 })).rows || []; }
|
|
2667
|
+
catch (_e) { creditHistory = []; }
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
var loyaltyInfo = null;
|
|
2671
|
+
if (deps.loyalty) {
|
|
2672
|
+
try { loyaltyInfo = await deps.loyalty.balance(customer.id); }
|
|
2673
|
+
catch (_e) { loyaltyInfo = null; }
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
var notes = [];
|
|
2677
|
+
if (customerNotes) {
|
|
2678
|
+
try { notes = (await customerNotes.notesForCustomer({ customer_id: customer.id, limit: 20 })).rows || []; }
|
|
2679
|
+
catch (_e) { notes = []; }
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
var segments = null;
|
|
2683
|
+
if (customerSegments) {
|
|
2684
|
+
try { segments = await customerSegments.segmentsForCustomer(customer.id); }
|
|
2685
|
+
catch (_e) { segments = []; }
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
return Object.assign({
|
|
2689
|
+
shop_name: deps.shop_name,
|
|
2690
|
+
nav_available: navAvailable,
|
|
2691
|
+
customer: customer,
|
|
2692
|
+
currency: currency,
|
|
2693
|
+
recent_orders: recentOrders,
|
|
2694
|
+
can_store_credit: !!storeCredit,
|
|
2695
|
+
store_credit_minor: creditBalanceMinor,
|
|
2696
|
+
store_credit_history: creditHistory,
|
|
2697
|
+
loyalty: loyaltyInfo,
|
|
2698
|
+
loyalty_link: !!deps.loyalty,
|
|
2699
|
+
can_notes: !!customerNotes,
|
|
2700
|
+
notes: notes,
|
|
2701
|
+
can_segments: !!customerSegments,
|
|
2702
|
+
segments: segments,
|
|
2703
|
+
}, flags);
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
// Resolve the :id to a customer record. A malformed id throws inside the
|
|
2707
|
+
// defensive id reader (customers.get) — caught and treated as "no such
|
|
2708
|
+
// customer" so the route renders a clean 404, never a 500. An unknown
|
|
2709
|
+
// (well-formed) id returns null → the same 404.
|
|
2710
|
+
async function _resolveCustomer(id) {
|
|
2711
|
+
try { return await customers.get(id); }
|
|
2712
|
+
catch (e) { if (e instanceof TypeError) return null; throw e; }
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
router.get("/admin/customers/:id", _pageOrApi(true,
|
|
2716
|
+
R(async function (req, res) {
|
|
2717
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2718
|
+
if (!c) return _problem(res, 404, "customer-not-found");
|
|
2719
|
+
// Bearer JSON: the customer record + its satellites (no HTML).
|
|
2720
|
+
var model = await _customerDetailModel(c, {});
|
|
2721
|
+
_json(res, 200, {
|
|
2722
|
+
customer: c,
|
|
2723
|
+
recent_orders: model.recent_orders,
|
|
2724
|
+
store_credit_minor: model.store_credit_minor,
|
|
2725
|
+
store_credit_history: model.store_credit_history,
|
|
2726
|
+
loyalty: model.loyalty,
|
|
2727
|
+
notes: model.notes,
|
|
2728
|
+
segments: model.segments,
|
|
2729
|
+
});
|
|
2730
|
+
}),
|
|
2731
|
+
async function (req, res) {
|
|
2732
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2733
|
+
if (!c) return _sendHtml(res, 404, renderAdminCustomers({
|
|
2734
|
+
shop_name: deps.shop_name, nav_available: navAvailable, customers: [], notice: "Customer not found.",
|
|
2735
|
+
}));
|
|
2736
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
2737
|
+
var flags = {
|
|
2738
|
+
saved: url && url.searchParams.get("saved"),
|
|
2739
|
+
credit_notice: url && url.searchParams.get("credit_err") ? url.searchParams.get("credit_err") : null,
|
|
2740
|
+
note_notice: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
|
|
2741
|
+
};
|
|
2742
|
+
_sendHtml(res, 200, renderAdminCustomerDetail(await _customerDetailModel(c, flags)));
|
|
2743
|
+
},
|
|
2744
|
+
));
|
|
2745
|
+
|
|
2746
|
+
// ---- store-credit adjustment (grant / deduct) ----------------------
|
|
2747
|
+
// Scoped to the :id customer — the customer the adjustment targets is the
|
|
2748
|
+
// path id, never a form field, so an operator can't grant credit to a
|
|
2749
|
+
// different customer than the screen they're on. A grant composes
|
|
2750
|
+
// storeCredit.credit (source goodwill / source_ref reason); a deduct
|
|
2751
|
+
// composes storeCredit.expire (the operator-initiated burn that carries a
|
|
2752
|
+
// required reason). Over-deduction is refused at the route as a clean 409
|
|
2753
|
+
// BEFORE any write — the expire primitive caps silently at the balance,
|
|
2754
|
+
// so the route enforces the no-overdraft contract by reading the balance
|
|
2755
|
+
// first (nothing is written when the deduction exceeds the balance).
|
|
2756
|
+
if (storeCredit) {
|
|
2757
|
+
router.post("/admin/customers/:id/store-credit", _pageOrApi(false,
|
|
2758
|
+
W("customer.store_credit.adjust", async function (req, res) {
|
|
2759
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2760
|
+
if (!c) return _problem(res, 404, "customer-not-found");
|
|
2761
|
+
var body = req.body || {};
|
|
2762
|
+
var dir, amount, reason;
|
|
2763
|
+
try {
|
|
2764
|
+
dir = _storeCreditDirection(body);
|
|
2765
|
+
amount = _strictMinorInt(body.amount_minor, "admin", "amount_minor");
|
|
2766
|
+
if (amount <= 0) throw new TypeError("admin: amount_minor must be a positive integer (minor units)");
|
|
2767
|
+
reason = _storeCreditReason(body.reason);
|
|
2768
|
+
} catch (e) {
|
|
2769
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
2770
|
+
throw e;
|
|
2771
|
+
}
|
|
2772
|
+
var result;
|
|
2773
|
+
if (dir === "grant") {
|
|
2774
|
+
result = await storeCredit.credit({
|
|
2775
|
+
customer_id: c.id, amount_minor: amount, source: "goodwill", source_ref: reason,
|
|
2776
|
+
});
|
|
2777
|
+
} else {
|
|
2778
|
+
var bal = (await storeCredit.balance(c.id)).balance_minor;
|
|
2779
|
+
if (amount > bal) {
|
|
2780
|
+
return _problem(res, 409, "insufficient-balance", "Deduction exceeds the available store-credit balance.");
|
|
2781
|
+
}
|
|
2782
|
+
result = await storeCredit.expire({ customer_id: c.id, amount_minor: amount, reason: reason });
|
|
2783
|
+
}
|
|
2784
|
+
_json(res, 200, result);
|
|
2785
|
+
return { id: c.id };
|
|
2786
|
+
}),
|
|
2787
|
+
async function (req, res) {
|
|
2788
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2789
|
+
if (!c) return _sendHtml(res, 404, renderAdminCustomers({
|
|
2790
|
+
shop_name: deps.shop_name, nav_available: navAvailable, customers: [], notice: "Customer not found.",
|
|
2791
|
+
}));
|
|
2792
|
+
var body = req.body || {};
|
|
2793
|
+
var dir, amount, reason;
|
|
2794
|
+
try {
|
|
2795
|
+
dir = _storeCreditDirection(body);
|
|
2796
|
+
amount = _strictMinorInt(body.amount_minor, "admin", "amount_minor");
|
|
2797
|
+
if (amount <= 0) throw new TypeError("admin: amount_minor must be a positive integer (minor units)");
|
|
2798
|
+
reason = _storeCreditReason(body.reason);
|
|
2799
|
+
} catch (e) {
|
|
2800
|
+
var n = _safeNotice(e, "customer.store_credit.adjust");
|
|
2801
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
2802
|
+
"?credit_err=" + encodeURIComponent(n.message.replace(/^admin[.:]\s*/, "")));
|
|
2803
|
+
}
|
|
2804
|
+
if (dir === "deduct") {
|
|
2805
|
+
var bal = (await storeCredit.balance(c.id)).balance_minor;
|
|
2806
|
+
if (amount > bal) {
|
|
2807
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
2808
|
+
"?credit_err=" + encodeURIComponent("That deduction exceeds the available balance."));
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
try {
|
|
2812
|
+
if (dir === "grant") {
|
|
2813
|
+
await storeCredit.credit({ customer_id: c.id, amount_minor: amount, source: "goodwill", source_ref: reason });
|
|
2814
|
+
} else {
|
|
2815
|
+
await storeCredit.expire({ customer_id: c.id, amount_minor: amount, reason: reason });
|
|
2816
|
+
}
|
|
2817
|
+
} catch (e) {
|
|
2818
|
+
var n2 = _safeNotice(e, "customer.store_credit.adjust");
|
|
2819
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
2820
|
+
"?credit_err=" + encodeURIComponent(n2.message.replace(/^(?:admin|storeCredit)[.:]\s*/, "")));
|
|
2821
|
+
}
|
|
2822
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer.store_credit.adjust", outcome: "success", metadata: { id: c.id, direction: dir } });
|
|
2823
|
+
_redirect(res, "/admin/customers/" + encodeURIComponent(c.id) + "?saved=1");
|
|
2824
|
+
},
|
|
2825
|
+
));
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// ---- customer notes (add) ------------------------------------------
|
|
2829
|
+
// Scoped to the :id customer — the note attaches to the path customer.
|
|
2830
|
+
// Composes customerNotes.addNote (the body is required + length-capped by
|
|
2831
|
+
// the primitive). The author is stamped "operator" — the console never
|
|
2832
|
+
// adds a system note. Bad input (empty / over-long body, bad kind) is a
|
|
2833
|
+
// clean 4xx with nothing written.
|
|
2834
|
+
if (customerNotes) {
|
|
2835
|
+
router.post("/admin/customers/:id/notes", _pageOrApi(false,
|
|
2836
|
+
W("customer.note.add", async function (req, res) {
|
|
2837
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2838
|
+
if (!c) return _problem(res, 404, "customer-not-found");
|
|
2839
|
+
var body = req.body || {};
|
|
2840
|
+
var note;
|
|
2841
|
+
try {
|
|
2842
|
+
note = await customerNotes.addNote({
|
|
2843
|
+
customer_id: c.id, author: "operator",
|
|
2844
|
+
body: body.body, kind: (body.kind != null && body.kind !== "") ? body.kind : undefined,
|
|
2845
|
+
});
|
|
2846
|
+
} catch (e) {
|
|
2847
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
2848
|
+
throw e;
|
|
2849
|
+
}
|
|
2850
|
+
_json(res, 201, note);
|
|
2851
|
+
return { id: c.id };
|
|
2852
|
+
}),
|
|
2853
|
+
async function (req, res) {
|
|
2854
|
+
var c = await _resolveCustomer(req.params.id);
|
|
2855
|
+
if (!c) return _sendHtml(res, 404, renderAdminCustomers({
|
|
2856
|
+
shop_name: deps.shop_name, nav_available: navAvailable, customers: [], notice: "Customer not found.",
|
|
2857
|
+
}));
|
|
2858
|
+
var body = req.body || {};
|
|
2859
|
+
try {
|
|
2860
|
+
await customerNotes.addNote({
|
|
2861
|
+
customer_id: c.id, author: "operator",
|
|
2862
|
+
body: body.body, kind: (body.kind != null && body.kind !== "") ? body.kind : undefined,
|
|
2863
|
+
});
|
|
2864
|
+
} catch (e) {
|
|
2865
|
+
var n = _safeNotice(e, "customer.note.add");
|
|
2866
|
+
return _redirect(res, "/admin/customers/" + encodeURIComponent(c.id) +
|
|
2867
|
+
"?note_err=" + encodeURIComponent(n.message.replace(/^(?:admin|customerNotes)[.:]\s*/, "")));
|
|
2868
|
+
}
|
|
2869
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".customer.note.add", outcome: "success", metadata: { id: c.id } });
|
|
2870
|
+
_redirect(res, "/admin/customers/" + encodeURIComponent(c.id) + "?saved=1");
|
|
2871
|
+
},
|
|
2872
|
+
));
|
|
2873
|
+
}
|
|
2600
2874
|
}
|
|
2601
2875
|
|
|
2602
2876
|
// ---- refunds --------------------------------------------------------
|
|
@@ -3305,6 +3579,220 @@ function mount(router, deps) {
|
|
|
3305
3579
|
));
|
|
3306
3580
|
}
|
|
3307
3581
|
|
|
3582
|
+
// ---- support tickets ------------------------------------------------
|
|
3583
|
+
// The operator queue over the customer-service ticketing surface: a
|
|
3584
|
+
// status-filterable list (plus an unassigned-triage view via the
|
|
3585
|
+
// primitive's listUnassigned), a ticket detail with the full thread +
|
|
3586
|
+
// requester + assignee + tags, an operator reply, assign, tag, and the
|
|
3587
|
+
// FSM status transitions the module exposes (in_progress / waiting /
|
|
3588
|
+
// resolved / closed / reopened). Content-negotiated like the other
|
|
3589
|
+
// screens: bearer → JSON; signed-in browser → the HTML console.
|
|
3590
|
+
if (supportTickets) {
|
|
3591
|
+
var support = supportTickets;
|
|
3592
|
+
|
|
3593
|
+
// Resolve the ?status= chip into a list. The special "unassigned"
|
|
3594
|
+
// chip routes through listUnassigned; every real status routes through
|
|
3595
|
+
// the primitive's status-filtered list. A bad/absent chip falls back
|
|
3596
|
+
// to the whole board.
|
|
3597
|
+
async function _supportRows(filter) {
|
|
3598
|
+
if (filter === "unassigned") {
|
|
3599
|
+
return { rows: await support.listUnassigned({}), filter: "unassigned" };
|
|
3600
|
+
}
|
|
3601
|
+
if (filter && support.ALLOWED_STATUSES.indexOf(filter) !== -1) {
|
|
3602
|
+
return { rows: await support.list({ status_filter: filter, limit: support.MAX_LIST_LIMIT }), filter: filter };
|
|
3603
|
+
}
|
|
3604
|
+
return { rows: await support.list({ limit: support.MAX_LIST_LIMIT }), filter: "all" };
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
router.get("/admin/support", _pageOrApi(true,
|
|
3608
|
+
R(async function (req, res) {
|
|
3609
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3610
|
+
var filter = (url && url.searchParams.get("status")) || null;
|
|
3611
|
+
var resolved = await _supportRows(filter);
|
|
3612
|
+
_json(res, 200, { rows: resolved.rows, status: resolved.filter });
|
|
3613
|
+
}),
|
|
3614
|
+
async function (req, res) {
|
|
3615
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3616
|
+
var filter = (url && url.searchParams.get("status")) || null;
|
|
3617
|
+
var resolved = await _supportRows(filter);
|
|
3618
|
+
_sendHtml(res, 200, renderAdminSupport({
|
|
3619
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
3620
|
+
tickets: resolved.rows, status: resolved.filter,
|
|
3621
|
+
}));
|
|
3622
|
+
},
|
|
3623
|
+
));
|
|
3624
|
+
|
|
3625
|
+
// Resolve a ticket by :id, surfacing a malformed / unknown id as a
|
|
3626
|
+
// 404 (the route is a defensive request-shape reader, never a 500).
|
|
3627
|
+
// Returns null after sending the 404 so the caller short-circuits.
|
|
3628
|
+
async function _supportTicketOr404(req, res, isJson) {
|
|
3629
|
+
var ticket;
|
|
3630
|
+
try { ticket = await support.get(req.params.id); }
|
|
3631
|
+
catch (e) {
|
|
3632
|
+
if (e instanceof TypeError) {
|
|
3633
|
+
if (isJson) { _problem(res, 404, "support-ticket-not-found"); return null; }
|
|
3634
|
+
_sendHtml(res, 404, renderAdminSupport({
|
|
3635
|
+
shop_name: deps.shop_name, nav_available: navAvailable, tickets: [], status: "all", notice: "Ticket not found.",
|
|
3636
|
+
}));
|
|
3637
|
+
return null;
|
|
3638
|
+
}
|
|
3639
|
+
throw e;
|
|
3640
|
+
}
|
|
3641
|
+
if (!ticket) {
|
|
3642
|
+
if (isJson) { _problem(res, 404, "support-ticket-not-found"); return null; }
|
|
3643
|
+
_sendHtml(res, 404, renderAdminSupport({
|
|
3644
|
+
shop_name: deps.shop_name, nav_available: navAvailable, tickets: [], status: "all", notice: "Ticket not found.",
|
|
3645
|
+
}));
|
|
3646
|
+
return null;
|
|
3647
|
+
}
|
|
3648
|
+
return ticket;
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
router.get("/admin/support/:id", _pageOrApi(true,
|
|
3652
|
+
R(async function (req, res) {
|
|
3653
|
+
var ticket = await _supportTicketOr404(req, res, true); if (!ticket) return;
|
|
3654
|
+
var thread = await support.thread(ticket.id);
|
|
3655
|
+
_json(res, 200, thread);
|
|
3656
|
+
}),
|
|
3657
|
+
async function (req, res) {
|
|
3658
|
+
var ticket = await _supportTicketOr404(req, res, false); if (!ticket) return;
|
|
3659
|
+
var thread = await support.thread(ticket.id);
|
|
3660
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3661
|
+
_sendHtml(res, 200, renderAdminSupportTicket({
|
|
3662
|
+
shop_name: deps.shop_name,
|
|
3663
|
+
nav_available: navAvailable,
|
|
3664
|
+
ticket: thread.ticket,
|
|
3665
|
+
messages: thread.messages,
|
|
3666
|
+
transitions: support.ALLOWED_STATUSES,
|
|
3667
|
+
moved: url && url.searchParams.get("moved"),
|
|
3668
|
+
notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this ticket." : null,
|
|
3669
|
+
}));
|
|
3670
|
+
},
|
|
3671
|
+
));
|
|
3672
|
+
|
|
3673
|
+
// The browser side of a per-ticket action: run `opFn(id, body)`, then
|
|
3674
|
+
// PRG back to the detail. A bad shape (TypeError), an FSM refusal, a
|
|
3675
|
+
// closed-ticket reply, or a not-found row becomes an ?err=1 notice on
|
|
3676
|
+
// the detail, never a 500; anything else propagates to the wrapper.
|
|
3677
|
+
function _supportClientError(e) {
|
|
3678
|
+
if (!e) return false;
|
|
3679
|
+
if (e instanceof TypeError) return true;
|
|
3680
|
+
return e.code === "SUPPORT_TICKET_NOT_FOUND" ||
|
|
3681
|
+
e.code === "SUPPORT_TICKET_CLOSED" ||
|
|
3682
|
+
e.code === "SUPPORT_TICKET_TRANSITION_REFUSED";
|
|
3683
|
+
}
|
|
3684
|
+
function _supportAction(jsonHandler, auditEvent, opFn) {
|
|
3685
|
+
return _pageOrApi(false, jsonHandler, async function (req, res) {
|
|
3686
|
+
var id = req.params.id;
|
|
3687
|
+
try { await opFn(id, req.body || {}); }
|
|
3688
|
+
catch (e) {
|
|
3689
|
+
if (_supportClientError(e)) {
|
|
3690
|
+
return _redirect(res, "/admin/support/" + encodeURIComponent(id) + "?err=1");
|
|
3691
|
+
}
|
|
3692
|
+
throw e;
|
|
3693
|
+
}
|
|
3694
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
|
|
3695
|
+
_redirect(res, "/admin/support/" + encodeURIComponent(id) + "?moved=1");
|
|
3696
|
+
});
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
// Map a support primitive error to an API problem. A bad shape is a
|
|
3700
|
+
// 400, a not-found a 404, a closed-ticket reply / refused transition a
|
|
3701
|
+
// 409 — same classification the browser path uses, surfaced as
|
|
3702
|
+
// problem-details for the JSON contract.
|
|
3703
|
+
function _supportApiError(res, e) {
|
|
3704
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
3705
|
+
if (e && e.code === "SUPPORT_TICKET_NOT_FOUND") return _problem(res, 404, "support-ticket-not-found");
|
|
3706
|
+
if (e && e.code === "SUPPORT_TICKET_CLOSED") return _problem(res, 409, "conflict", e.message);
|
|
3707
|
+
if (e && e.code === "SUPPORT_TICKET_TRANSITION_REFUSED") return _problem(res, 409, "conflict", e.message);
|
|
3708
|
+
return null;
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
// Operator reply — author "operator". An internal=1 note is supported
|
|
3712
|
+
// (operator-only; never shown to the customer) via the form's
|
|
3713
|
+
// checkbox.
|
|
3714
|
+
router.post("/admin/support/:id/reply", _supportAction(
|
|
3715
|
+
W("support.reply", async function (req, res) {
|
|
3716
|
+
var body = req.body || {};
|
|
3717
|
+
var ticket;
|
|
3718
|
+
try {
|
|
3719
|
+
ticket = await support.reply({
|
|
3720
|
+
ticket_id: req.params.id,
|
|
3721
|
+
author: "operator",
|
|
3722
|
+
body: body.body,
|
|
3723
|
+
internal: body.internal === "1" || body.internal === true,
|
|
3724
|
+
});
|
|
3725
|
+
} catch (e) {
|
|
3726
|
+
var mapped = _supportApiError(res, e); if (mapped !== null) return mapped;
|
|
3727
|
+
throw e;
|
|
3728
|
+
}
|
|
3729
|
+
_json(res, 200, ticket);
|
|
3730
|
+
return ticket;
|
|
3731
|
+
}),
|
|
3732
|
+
"support.reply",
|
|
3733
|
+
function (id, body) {
|
|
3734
|
+
return support.reply({
|
|
3735
|
+
ticket_id: id, author: "operator", body: body.body,
|
|
3736
|
+
internal: body.internal === "1" || body.internal === true,
|
|
3737
|
+
});
|
|
3738
|
+
},
|
|
3739
|
+
));
|
|
3740
|
+
|
|
3741
|
+
// Assign the ticket to an operator (operator_id is a UUID — the
|
|
3742
|
+
// console seeds it from a hidden field carrying the signed-in
|
|
3743
|
+
// operator's id, or an explicit input).
|
|
3744
|
+
router.post("/admin/support/:id/assign", _supportAction(
|
|
3745
|
+
W("support.assign", async function (req, res) {
|
|
3746
|
+
var body = req.body || {};
|
|
3747
|
+
var ticket;
|
|
3748
|
+
try { ticket = await support.assign({ ticket_id: req.params.id, operator_id: body.operator_id }); }
|
|
3749
|
+
catch (e) { var mapped = _supportApiError(res, e); if (mapped !== null) return mapped; throw e; }
|
|
3750
|
+
_json(res, 200, ticket);
|
|
3751
|
+
return ticket;
|
|
3752
|
+
}),
|
|
3753
|
+
"support.assign",
|
|
3754
|
+
function (id, body) { return support.assign({ ticket_id: id, operator_id: body.operator_id }); },
|
|
3755
|
+
));
|
|
3756
|
+
|
|
3757
|
+
// Add a free-form tag.
|
|
3758
|
+
router.post("/admin/support/:id/tag", _supportAction(
|
|
3759
|
+
W("support.tag", async function (req, res) {
|
|
3760
|
+
var body = req.body || {};
|
|
3761
|
+
var ticket;
|
|
3762
|
+
try { ticket = await support.addTag({ ticket_id: req.params.id, tag: body.tag }); }
|
|
3763
|
+
catch (e) { var mapped = _supportApiError(res, e); if (mapped !== null) return mapped; throw e; }
|
|
3764
|
+
_json(res, 200, ticket);
|
|
3765
|
+
return ticket;
|
|
3766
|
+
}),
|
|
3767
|
+
"support.tag",
|
|
3768
|
+
function (id, body) { return support.addTag({ ticket_id: id, tag: body.tag }); },
|
|
3769
|
+
));
|
|
3770
|
+
|
|
3771
|
+
// FSM status transitions. Each posts to /admin/support/:id/<to_status>
|
|
3772
|
+
// for the edges the module's graph exposes; an illegal edge from the
|
|
3773
|
+
// current state is refused by the primitive (→ ?err=1 notice). The
|
|
3774
|
+
// `reopen` URL maps to the module's `reopened` status (the customer-
|
|
3775
|
+
// facing verb for a resolved ticket re-entering the workflow).
|
|
3776
|
+
function _supportTransition(verb, toStatus) {
|
|
3777
|
+
router.post("/admin/support/:id/" + verb, _supportAction(
|
|
3778
|
+
W("support." + verb, async function (req, res) {
|
|
3779
|
+
var ticket;
|
|
3780
|
+
try { ticket = await support.transition({ ticket_id: req.params.id, to_status: toStatus, reason: (req.body && req.body.reason) || undefined }); }
|
|
3781
|
+
catch (e) { var mapped = _supportApiError(res, e); if (mapped !== null) return mapped; throw e; }
|
|
3782
|
+
_json(res, 200, ticket);
|
|
3783
|
+
return ticket;
|
|
3784
|
+
}),
|
|
3785
|
+
"support." + verb,
|
|
3786
|
+
function (id, body) { return support.transition({ ticket_id: id, to_status: toStatus, reason: (body && body.reason) || undefined }); },
|
|
3787
|
+
));
|
|
3788
|
+
}
|
|
3789
|
+
_supportTransition("in-progress", "in_progress");
|
|
3790
|
+
_supportTransition("waiting-customer", "waiting_customer");
|
|
3791
|
+
_supportTransition("resolved", "resolved");
|
|
3792
|
+
_supportTransition("closed", "closed");
|
|
3793
|
+
_supportTransition("reopen", "reopened");
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3308
3796
|
// ---- config ---------------------------------------------------------
|
|
3309
3797
|
|
|
3310
3798
|
var config = deps.config || null;
|
|
@@ -7608,6 +8096,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
7608
8096
|
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
7609
8097
|
{ key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
|
|
7610
8098
|
{ key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
|
|
8099
|
+
{ key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
|
|
7611
8100
|
{ key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
|
|
7612
8101
|
{ key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
|
|
7613
8102
|
{ key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
|
|
@@ -8750,17 +9239,21 @@ function renderAdminCustomers(opts) {
|
|
|
8750
9239
|
}
|
|
8751
9240
|
var method = methodCells.length ? methodCells.join(" ") : "<span class=\"meta\">—</span>";
|
|
8752
9241
|
var orders = orderCounts[c.id] || 0;
|
|
9242
|
+
var enc = encodeURIComponent(c.id);
|
|
8753
9243
|
return "<tr>" +
|
|
8754
|
-
"<td><strong>" + _htmlEscape(c.display_name) + "</strong></td>" +
|
|
9244
|
+
"<td><a href=\"/admin/customers/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(c.display_name) + "</strong></a></td>" +
|
|
8755
9245
|
"<td><code class=\"order-id\">" + _htmlEscape(String(c.id).slice(0, 8)) + "</code></td>" +
|
|
8756
9246
|
"<td>" + _htmlEscape(_fmtDate(c.created_at)) + "</td>" +
|
|
8757
9247
|
"<td>" + method + "</td>" +
|
|
8758
9248
|
"<td class=\"num\">" + _htmlEscape(String(orders)) + "</td>" +
|
|
9249
|
+
"<td><a class=\"btn btn--ghost btn--sm\" href=\"/admin/customers/" + _htmlEscape(enc) + "\">Manage</a></td>" +
|
|
8759
9250
|
"</tr>";
|
|
8760
9251
|
}).join("");
|
|
8761
9252
|
|
|
9253
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
9254
|
+
|
|
8762
9255
|
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>"
|
|
9256
|
+
? "<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
9257
|
: "<p class=\"empty\">No customers yet.</p>";
|
|
8765
9258
|
|
|
8766
9259
|
// Cursor pager — a Next link when the page filled and more rows remain.
|
|
@@ -8769,12 +9262,165 @@ function renderAdminCustomers(opts) {
|
|
|
8769
9262
|
? "<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
9263
|
: "";
|
|
8771
9264
|
|
|
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>" +
|
|
9265
|
+
var body = "<section><h2>Customers</h2>" + notice +
|
|
9266
|
+
"<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
9267
|
table + pager + "</section>";
|
|
8775
9268
|
return _renderAdminShell(opts.shop_name, "Customers", body, "customers", opts.nav_available);
|
|
8776
9269
|
}
|
|
8777
9270
|
|
|
9271
|
+
// Per-customer operator detail screen. READS the customer's identity fields
|
|
9272
|
+
// + recent orders; WRITES only the operator-managed satellites (store-credit
|
|
9273
|
+
// + CRM notes). Segment membership is read-only — it is rule-derived (RFM
|
|
9274
|
+
// predicates recomputed by the scheduler), so there is no per-customer
|
|
9275
|
+
// manual assign / remove in the segments primitive; the panel shows the
|
|
9276
|
+
// segments the customer currently sits in and says so. Every panel renders
|
|
9277
|
+
// only when its primitive is wired.
|
|
9278
|
+
function renderAdminCustomerDetail(opts) {
|
|
9279
|
+
opts = opts || {};
|
|
9280
|
+
var c = opts.customer || {};
|
|
9281
|
+
var currency = opts.currency || "USD";
|
|
9282
|
+
var enc = encodeURIComponent(c.id);
|
|
9283
|
+
|
|
9284
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
|
|
9285
|
+
|
|
9286
|
+
// ---- identity (read-only) -----------------------------------------
|
|
9287
|
+
var identity = "<div class=\"panel\"><h3 class=\"subhead\">Customer</h3>" +
|
|
9288
|
+
"<dl class=\"kv-list\">" +
|
|
9289
|
+
"<div><dt>Name</dt><dd>" + _htmlEscape(c.display_name) + "</dd></div>" +
|
|
9290
|
+
"<div><dt>Customer id</dt><dd><code class=\"order-id\">" + _htmlEscape(String(c.id)) + "</code></dd></div>" +
|
|
9291
|
+
"<div><dt>Joined</dt><dd>" + _htmlEscape(_fmtDate(c.created_at)) + "</dd></div>" +
|
|
9292
|
+
"</dl>" +
|
|
9293
|
+
"<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>" +
|
|
9294
|
+
"</div>";
|
|
9295
|
+
|
|
9296
|
+
// ---- recent orders -------------------------------------------------
|
|
9297
|
+
var orderRows = (opts.recent_orders || []).map(function (o) {
|
|
9298
|
+
return "<tr>" +
|
|
9299
|
+
"<td><a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(encodeURIComponent(o.id)) + "\">" + _htmlEscape(String(o.id).slice(0, 8)) + "</a></td>" +
|
|
9300
|
+
"<td><span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></td>" +
|
|
9301
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(o.grand_total_minor, o.currency)) + "</td>" +
|
|
9302
|
+
"<td>" + _htmlEscape(_fmtDate(o.created_at)) + "</td>" +
|
|
9303
|
+
"</tr>";
|
|
9304
|
+
}).join("");
|
|
9305
|
+
var ordersPanel = "<div class=\"panel\"><h3 class=\"subhead\">Recent orders</h3>" +
|
|
9306
|
+
((opts.recent_orders || []).length
|
|
9307
|
+
? "<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>"
|
|
9308
|
+
: "<p class=\"empty\">No orders yet.</p>") +
|
|
9309
|
+
"</div>";
|
|
9310
|
+
|
|
9311
|
+
// ---- store credit --------------------------------------------------
|
|
9312
|
+
var creditPanel;
|
|
9313
|
+
if (!opts.can_store_credit) {
|
|
9314
|
+
creditPanel = "<div class=\"panel\"><h3 class=\"subhead\">Store credit</h3>" +
|
|
9315
|
+
"<p class=\"empty\">Store credit isn't wired in this deployment.</p></div>";
|
|
9316
|
+
} else {
|
|
9317
|
+
var bal = opts.store_credit_minor != null ? opts.store_credit_minor : 0;
|
|
9318
|
+
var creditNotice = opts.credit_notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.credit_notice) + "</div>" : "";
|
|
9319
|
+
var histRows = (opts.store_credit_history || []).map(function (h) {
|
|
9320
|
+
var sign = h.kind === "credit" ? "+" : "−";
|
|
9321
|
+
var note = h.source_ref != null ? h.source_ref : (h.source != null ? h.source : "");
|
|
9322
|
+
return "<tr>" +
|
|
9323
|
+
"<td>" + _htmlEscape(h.kind) + "</td>" +
|
|
9324
|
+
"<td class=\"num\">" + _htmlEscape(sign + pricing.format(h.amount_minor, currency)) + "</td>" +
|
|
9325
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(h.balance_after_minor, currency)) + "</td>" +
|
|
9326
|
+
"<td>" + _htmlEscape(note) + "</td>" +
|
|
9327
|
+
"<td>" + _htmlEscape(_fmtDate(h.occurred_at)) + "</td>" +
|
|
9328
|
+
"</tr>";
|
|
9329
|
+
}).join("");
|
|
9330
|
+
var histTable = (opts.store_credit_history || []).length
|
|
9331
|
+
? "<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>"
|
|
9332
|
+
: "<p class=\"empty\">No store-credit activity yet.</p>";
|
|
9333
|
+
creditPanel = "<div class=\"panel\"><h3 class=\"subhead\">Store credit</h3>" +
|
|
9334
|
+
creditNotice +
|
|
9335
|
+
"<p class=\"stat-figure\">" + _htmlEscape(pricing.format(bal, currency)) + "</p>" +
|
|
9336
|
+
"<p class=\"meta\">Account-bound balance — applied at checkout. Every grant or deduct is recorded with your reason in the ledger below.</p>" +
|
|
9337
|
+
"<form method=\"post\" action=\"/admin/customers/" + _htmlEscape(enc) + "/store-credit\">" +
|
|
9338
|
+
"<fieldset class=\"box\"><legend class=\"legend-sm\">Direction</legend>" +
|
|
9339
|
+
"<label class=\"kv\"><input type=\"radio\" name=\"direction\" value=\"grant\" checked> Grant (add credit)</label>" +
|
|
9340
|
+
"<label class=\"kv\"><input type=\"radio\" name=\"direction\" value=\"deduct\"> Deduct (remove credit)</label>" +
|
|
9341
|
+
"</fieldset>" +
|
|
9342
|
+
"<label class=\"form-field\"><span>Amount (" + _htmlEscape(currency) + " minor units)</span>" +
|
|
9343
|
+
"<input type=\"number\" name=\"amount_minor\" min=\"1\" step=\"1\" required class=\"input-code\" placeholder=\"e.g. 500 = " + _htmlEscape(pricing.format(500, currency)) + "\"></label>" +
|
|
9344
|
+
"<label class=\"form-field\"><span>Reason</span>" +
|
|
9345
|
+
"<input type=\"text\" name=\"reason\" required maxlength=\"128\" placeholder=\"e.g. goodwill for the late delivery\">" +
|
|
9346
|
+
"<small>Required. Recorded with the adjustment in the ledger.</small></label>" +
|
|
9347
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Apply adjustment</button></div>" +
|
|
9348
|
+
"</form>" +
|
|
9349
|
+
histTable +
|
|
9350
|
+
"</div>";
|
|
9351
|
+
}
|
|
9352
|
+
|
|
9353
|
+
// ---- loyalty -------------------------------------------------------
|
|
9354
|
+
var loyaltyPanel = "";
|
|
9355
|
+
if (opts.loyalty_link) {
|
|
9356
|
+
var loy = opts.loyalty || { balance: 0, lifetime: 0, tier: "bronze" };
|
|
9357
|
+
loyaltyPanel = "<div class=\"panel\"><div class=\"actions-row\"><h3 class=\"subhead\">Loyalty</h3>" +
|
|
9358
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/loyalty\">Adjust points</a></div>" +
|
|
9359
|
+
"<dl class=\"kv-list\">" +
|
|
9360
|
+
"<div><dt>Balance</dt><dd>" + _htmlEscape(String(loy.balance)) + " pts</dd></div>" +
|
|
9361
|
+
"<div><dt>Lifetime</dt><dd>" + _htmlEscape(String(loy.lifetime)) + " pts</dd></div>" +
|
|
9362
|
+
"<div><dt>Tier</dt><dd>" + _htmlEscape(String(loy.tier)) + "</dd></div>" +
|
|
9363
|
+
"</dl>" +
|
|
9364
|
+
"<p class=\"meta\">Grant or deduct points from the Loyalty screen — paste this customer's id (above) into the adjustment form.</p>" +
|
|
9365
|
+
"</div>";
|
|
9366
|
+
}
|
|
9367
|
+
|
|
9368
|
+
// ---- notes ---------------------------------------------------------
|
|
9369
|
+
var notesPanel;
|
|
9370
|
+
if (!opts.can_notes) {
|
|
9371
|
+
notesPanel = "<div class=\"panel\"><h3 class=\"subhead\">Notes</h3>" +
|
|
9372
|
+
"<p class=\"empty\">Customer notes aren't wired in this deployment.</p></div>";
|
|
9373
|
+
} else {
|
|
9374
|
+
var noteNotice = opts.note_notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.note_notice) + "</div>" : "";
|
|
9375
|
+
var noteRows = (opts.notes || []).map(function (n) {
|
|
9376
|
+
return "<li><div class=\"note-meta\">" + _htmlEscape(n.kind) + " · " + _htmlEscape(_fmtDate(n.created_at)) + "</div>" +
|
|
9377
|
+
"<div class=\"note-body\">" + _htmlEscape(n.body) + "</div></li>";
|
|
9378
|
+
}).join("");
|
|
9379
|
+
notesPanel = "<div class=\"panel\"><h3 class=\"subhead\">Notes</h3>" +
|
|
9380
|
+
noteNotice +
|
|
9381
|
+
"<p class=\"meta\">Operator-only annotations on this customer. Never shown to the customer.</p>" +
|
|
9382
|
+
((opts.notes || []).length
|
|
9383
|
+
? "<ul class=\"note-list\">" + noteRows + "</ul>"
|
|
9384
|
+
: "<p class=\"empty\">No notes yet.</p>") +
|
|
9385
|
+
"<form method=\"post\" action=\"/admin/customers/" + _htmlEscape(enc) + "/notes\">" +
|
|
9386
|
+
"<label class=\"form-field\"><span>Kind</span>" +
|
|
9387
|
+
"<select name=\"kind\">" +
|
|
9388
|
+
"<option value=\"general\">general</option>" +
|
|
9389
|
+
"<option value=\"preference\">preference</option>" +
|
|
9390
|
+
"<option value=\"escalation\">escalation</option>" +
|
|
9391
|
+
"<option value=\"warning\">warning</option>" +
|
|
9392
|
+
"<option value=\"billing\">billing</option>" +
|
|
9393
|
+
"</select></label>" +
|
|
9394
|
+
"<label class=\"form-field\"><span>Note</span>" +
|
|
9395
|
+
"<textarea name=\"body\" required maxlength=\"8000\" rows=\"3\" placeholder=\"e.g. VIP — comp shipping where possible\"></textarea></label>" +
|
|
9396
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add note</button></div>" +
|
|
9397
|
+
"</form>" +
|
|
9398
|
+
"</div>";
|
|
9399
|
+
}
|
|
9400
|
+
|
|
9401
|
+
// ---- segments (read-only) ------------------------------------------
|
|
9402
|
+
var segmentsPanel = "";
|
|
9403
|
+
if (opts.can_segments) {
|
|
9404
|
+
var segChips = (opts.segments || []).map(function (s) {
|
|
9405
|
+
return "<span class=\"chip\">" + _htmlEscape(s.title || s.slug) + "</span>";
|
|
9406
|
+
}).join(" ");
|
|
9407
|
+
segmentsPanel = "<div class=\"panel\"><h3 class=\"subhead\">Segments</h3>" +
|
|
9408
|
+
((opts.segments || []).length
|
|
9409
|
+
? "<div class=\"chip-row\">" + segChips + "</div>"
|
|
9410
|
+
: "<p class=\"empty\">Not in any segment.</p>") +
|
|
9411
|
+
"<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>" +
|
|
9412
|
+
"</div>";
|
|
9413
|
+
}
|
|
9414
|
+
|
|
9415
|
+
var body = "<section>" +
|
|
9416
|
+
"<div class=\"actions-row\"><h2>" + _htmlEscape(c.display_name) + "</h2>" +
|
|
9417
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/customers\"><span aria-hidden=\"true\">←</span> All customers</a></div>" +
|
|
9418
|
+
saved +
|
|
9419
|
+
identity + ordersPanel + creditPanel + loyaltyPanel + notesPanel + segmentsPanel +
|
|
9420
|
+
"</section>";
|
|
9421
|
+
return _renderAdminShell(opts.shop_name, c.display_name || "Customer", body, "customers", opts.nav_available);
|
|
9422
|
+
}
|
|
9423
|
+
|
|
8778
9424
|
// The RMA states an operator can filter the returns queue by — drives the
|
|
8779
9425
|
// filter chips, lifecycle order then terminal.
|
|
8780
9426
|
var RETURN_STATUS_FILTERS = ["pending", "approved", "received", "refunded", "rejected"];
|
|
@@ -8930,6 +9576,165 @@ function renderAdminReturn(opts) {
|
|
|
8930
9576
|
return _renderAdminShell(opts.shop_name, "Return " + (r.rma_code || r.id.slice(0, 8)), body, "returns", opts.nav_available);
|
|
8931
9577
|
}
|
|
8932
9578
|
|
|
9579
|
+
// The support-ticket queue chips: every FSM status the module exposes,
|
|
9580
|
+
// plus "all" (the whole board) and an "unassigned" triage view.
|
|
9581
|
+
var SUPPORT_STATUS_FILTERS = [
|
|
9582
|
+
"all", "unassigned", "new", "in_progress", "waiting_customer", "resolved", "closed", "reopened",
|
|
9583
|
+
];
|
|
9584
|
+
|
|
9585
|
+
function _supportPillClass(status) {
|
|
9586
|
+
if (status === "resolved") return "paid";
|
|
9587
|
+
if (status === "closed") return "cancelled";
|
|
9588
|
+
if (status === "new" || status === "reopened") return "pending";
|
|
9589
|
+
return ""; // in_progress / waiting_customer use the default pill
|
|
9590
|
+
}
|
|
9591
|
+
|
|
9592
|
+
function _supportPriorityClass(priority) {
|
|
9593
|
+
return priority === "urgent" || priority === "high" ? "cancelled"
|
|
9594
|
+
: priority === "low" ? "" : "pending";
|
|
9595
|
+
}
|
|
9596
|
+
|
|
9597
|
+
// Operator queue. A status / unassigned chip set filters the board; each
|
|
9598
|
+
// row links to the ticket detail. Renders the requester (email is stored
|
|
9599
|
+
// hash-only, so the row shows the short hash), the status + priority
|
|
9600
|
+
// pills, the assignee state, and when it was opened.
|
|
9601
|
+
function renderAdminSupport(opts) {
|
|
9602
|
+
opts = opts || {};
|
|
9603
|
+
var tickets = opts.tickets || [];
|
|
9604
|
+
var active = opts.status || "all";
|
|
9605
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
9606
|
+
|
|
9607
|
+
var chips = "<div class=\"order-filters\">" +
|
|
9608
|
+
SUPPORT_STATUS_FILTERS.map(function (s) {
|
|
9609
|
+
return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/support?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
|
|
9610
|
+
}).join("") +
|
|
9611
|
+
"</div>";
|
|
9612
|
+
|
|
9613
|
+
var rows = tickets.map(function (t) {
|
|
9614
|
+
var assignee = t.assigned_operator_id ? _htmlEscape(String(t.assigned_operator_id).slice(0, 8)) : "<span class=\"meta\">unassigned</span>";
|
|
9615
|
+
return "<tr>" +
|
|
9616
|
+
"<td><a class=\"order-id\" href=\"/admin/support/" + _htmlEscape(t.id) + "\">" + _htmlEscape(t.subject) + "</a></td>" +
|
|
9617
|
+
"<td>" + _htmlEscape(t.category) + "</td>" +
|
|
9618
|
+
"<td><span class=\"status-pill " + _supportPillClass(t.status) + "\">" + _htmlEscape(t.status) + "</span></td>" +
|
|
9619
|
+
"<td><span class=\"status-pill " + _supportPriorityClass(t.priority) + "\">" + _htmlEscape(t.priority) + "</span></td>" +
|
|
9620
|
+
"<td>" + assignee + "</td>" +
|
|
9621
|
+
"<td>" + _htmlEscape(_fmtDate(t.opened_at)) + "</td>" +
|
|
9622
|
+
"</tr>";
|
|
9623
|
+
}).join("");
|
|
9624
|
+
|
|
9625
|
+
var table = tickets.length
|
|
9626
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Subject</th><th scope=\"col\">Category</th><th scope=\"col\">Status</th><th scope=\"col\">Priority</th><th scope=\"col\">Assignee</th><th scope=\"col\">Opened</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
|
|
9627
|
+
: "<p class=\"empty\">No “" + _htmlEscape(active) + "” tickets.</p>";
|
|
9628
|
+
|
|
9629
|
+
var body = "<section><h2>Support</h2>" + notice + chips + table + "</section>";
|
|
9630
|
+
return _renderAdminShell(opts.shop_name, "Support", body, "support", opts.nav_available);
|
|
9631
|
+
}
|
|
9632
|
+
|
|
9633
|
+
// Ticket detail — the full thread (customer + operator + system + internal
|
|
9634
|
+
// notes), the requester / status / assignee / tags, and the action forms:
|
|
9635
|
+
// operator reply (with an internal-note toggle), assign, tag, and the FSM
|
|
9636
|
+
// status transitions legal from the current state.
|
|
9637
|
+
function renderAdminSupportTicket(opts) {
|
|
9638
|
+
opts = opts || {};
|
|
9639
|
+
var t = opts.ticket;
|
|
9640
|
+
var messages = opts.messages || [];
|
|
9641
|
+
var moved = opts.moved ? "<div class=\"banner banner--ok\">Ticket updated.</div>" : "";
|
|
9642
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
9643
|
+
|
|
9644
|
+
var thread = messages.map(function (m) {
|
|
9645
|
+
var who = m.author === "operator" ? "Operator" : m.author === "system" ? "System" : "Customer";
|
|
9646
|
+
var internalTag = Number(m.internal) === 1 ? " <span class=\"status-pill pending\">internal</span>" : "";
|
|
9647
|
+
return "<li class=\"support-msg support-msg--" + _htmlEscape(String(m.author)) + "\">" +
|
|
9648
|
+
"<p class=\"meta\">" + _htmlEscape(who) + internalTag + " · " + _htmlEscape(_fmtDate(m.created_at)) + "</p>" +
|
|
9649
|
+
"<p class=\"support-msg__body\">" + _htmlEscape(String(m.body)).replace(/\n/g, "<br>") + "</p>" +
|
|
9650
|
+
"</li>";
|
|
9651
|
+
}).join("");
|
|
9652
|
+
var threadHtml = messages.length
|
|
9653
|
+
? "<ul class=\"support-thread\">" + thread + "</ul>"
|
|
9654
|
+
: "<p class=\"empty\">No messages.</p>";
|
|
9655
|
+
|
|
9656
|
+
var tags = (t.tags || []).map(function (tag) {
|
|
9657
|
+
return "<span class=\"status-pill\">" + _htmlEscape(tag) + "</span>";
|
|
9658
|
+
}).join(" ") || "<span class=\"meta\">none</span>";
|
|
9659
|
+
|
|
9660
|
+
function _field(label, value) {
|
|
9661
|
+
return "<p><span class=\"meta\">" + _htmlEscape(label) + "</span><br>" + (value ? _htmlEscape(String(value)) : "<span class=\"meta\">—</span>") + "</p>";
|
|
9662
|
+
}
|
|
9663
|
+
|
|
9664
|
+
// Legal FSM edges from the current status drive which transition
|
|
9665
|
+
// buttons render. Mirrors the module's TRANSITIONS graph.
|
|
9666
|
+
var EDGES = {
|
|
9667
|
+
"new": ["in-progress", "closed"],
|
|
9668
|
+
"in_progress": ["waiting-customer", "resolved", "closed"],
|
|
9669
|
+
"waiting_customer": ["in-progress", "resolved", "closed"],
|
|
9670
|
+
"resolved": ["reopen", "closed"],
|
|
9671
|
+
"reopened": ["in-progress", "closed"],
|
|
9672
|
+
"closed": [],
|
|
9673
|
+
};
|
|
9674
|
+
var TRANSITION_LABELS = {
|
|
9675
|
+
"in-progress": "Mark in progress", "waiting-customer": "Wait on customer",
|
|
9676
|
+
"resolved": "Resolve", "closed": "Close", "reopen": "Reopen",
|
|
9677
|
+
};
|
|
9678
|
+
var transitionForms = (EDGES[t.status] || []).map(function (verb) {
|
|
9679
|
+
return "<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/" + verb + "\" class=\"form-inline\">" +
|
|
9680
|
+
"<button class=\"btn" + (verb === "closed" ? " btn--danger" : "") + "\" type=\"submit\">" + _htmlEscape(TRANSITION_LABELS[verb] || verb) + "</button>" +
|
|
9681
|
+
"</form>";
|
|
9682
|
+
}).join("");
|
|
9683
|
+
var transitions = transitionForms
|
|
9684
|
+
? "<div class=\"order-actions\">" + transitionForms + "</div>"
|
|
9685
|
+
: "<span class=\"meta\">This ticket is closed — no further status changes.</span>";
|
|
9686
|
+
|
|
9687
|
+
var replyForm = t.status === "closed"
|
|
9688
|
+
? "<span class=\"meta\">Closed tickets can't take a reply.</span>"
|
|
9689
|
+
: "<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/reply\" class=\"return-action\">" +
|
|
9690
|
+
"<h4>Reply</h4>" +
|
|
9691
|
+
"<label class=\"form-field\"><span>Message</span><textarea name=\"body\" rows=\"4\" maxlength=\"8000\" required></textarea></label>" +
|
|
9692
|
+
"<label class=\"form-field form-field--inline\"><input type=\"checkbox\" name=\"internal\" value=\"1\"> <span>Internal note (not shown to the customer)</span></label>" +
|
|
9693
|
+
"<button class=\"btn\" type=\"submit\">Send reply</button>" +
|
|
9694
|
+
"</form>";
|
|
9695
|
+
|
|
9696
|
+
var assignForm =
|
|
9697
|
+
"<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/assign\" class=\"return-action\">" +
|
|
9698
|
+
"<h4>Assign</h4>" +
|
|
9699
|
+
_setupField("Operator id (UUID)", "operator_id", t.assigned_operator_id || "", "text", "The operator to own this ticket.", " required") +
|
|
9700
|
+
"<button class=\"btn\" type=\"submit\">Assign</button>" +
|
|
9701
|
+
"</form>";
|
|
9702
|
+
|
|
9703
|
+
var tagForm =
|
|
9704
|
+
"<form method=\"post\" action=\"/admin/support/" + _htmlEscape(t.id) + "/tag\" class=\"return-action\">" +
|
|
9705
|
+
"<h4>Add tag</h4>" +
|
|
9706
|
+
_setupField("Tag", "tag", "", "text", "", " maxlength=\"32\" required") +
|
|
9707
|
+
"<button class=\"btn\" type=\"submit\">Add tag</button>" +
|
|
9708
|
+
"</form>";
|
|
9709
|
+
|
|
9710
|
+
var body =
|
|
9711
|
+
"<section class=\"mw-48\">" +
|
|
9712
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/support\">← Support</a></div>" +
|
|
9713
|
+
"<h2>" + _htmlEscape(t.subject) + " " +
|
|
9714
|
+
"<span class=\"status-pill " + _supportPillClass(t.status) + "\">" + _htmlEscape(t.status) + "</span></h2>" +
|
|
9715
|
+
"<p class=\"meta\">Opened " + _htmlEscape(_fmtDate(t.opened_at)) +
|
|
9716
|
+
" · priority " + _htmlEscape(t.priority) +
|
|
9717
|
+
(t.order_id ? " · order <a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(t.order_id) + "\">" + _htmlEscape(String(t.order_id).slice(0, 8)) + "</a>" : "") +
|
|
9718
|
+
"</p>" +
|
|
9719
|
+
moved + notice +
|
|
9720
|
+
"<div class=\"two-col\">" +
|
|
9721
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Conversation</h3>" + threadHtml + "</div>" +
|
|
9722
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Details</h3>" +
|
|
9723
|
+
_field("Category", t.category) +
|
|
9724
|
+
_field("Requester", t.customer_email_hash ? String(t.customer_email_hash).slice(0, 12) + "…" : null) +
|
|
9725
|
+
_field("Customer id", t.customer_id ? String(t.customer_id).slice(0, 8) : null) +
|
|
9726
|
+
_field("Assignee", t.assigned_operator_id ? String(t.assigned_operator_id).slice(0, 8) : null) +
|
|
9727
|
+
"<p><span class=\"meta\">Tags</span><br>" + tags + "</p>" +
|
|
9728
|
+
"</div>" +
|
|
9729
|
+
"</div>" +
|
|
9730
|
+
"<div class=\"panel mt\"><h3 class=\"subhead\">Status</h3>" + transitions + "</div>" +
|
|
9731
|
+
"<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
|
|
9732
|
+
"<div class=\"return-actions\">" + replyForm + assignForm + tagForm + "</div>" +
|
|
9733
|
+
"</div>" +
|
|
9734
|
+
"</section>";
|
|
9735
|
+
return _renderAdminShell(opts.shop_name, "Ticket", body, "support", opts.nav_available);
|
|
9736
|
+
}
|
|
9737
|
+
|
|
8933
9738
|
// The review states an operator can filter the moderation queue by.
|
|
8934
9739
|
var REVIEW_STATUS_FILTERS = ["pending", "published", "rejected"];
|
|
8935
9740
|
|
|
@@ -12099,6 +12904,7 @@ module.exports = {
|
|
|
12099
12904
|
renderAdminOrders: renderAdminOrders,
|
|
12100
12905
|
renderAdminOrder: renderAdminOrder,
|
|
12101
12906
|
renderAdminCustomers: renderAdminCustomers,
|
|
12907
|
+
renderAdminCustomerDetail: renderAdminCustomerDetail,
|
|
12102
12908
|
renderAdminReturns: renderAdminReturns,
|
|
12103
12909
|
renderAdminReturn: renderAdminReturn,
|
|
12104
12910
|
renderAdminReviews: renderAdminReviews,
|