@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/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\">&larr; 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,