@blamejs/blamejs-shop 0.3.25 → 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 +2 -0
- package/lib/admin.js +376 -1
- 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/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.26 (2026-05-31) — **Customers can raise support tickets and operators work them from a queue.** The support-ticket system had a complete backend — threaded messages, assignment, tags, and a status workflow — but no way in or out: a shopper could not open a ticket and an operator could not see one. Both sides are now wired. A logged-in customer can raise a ticket from their account (with an optional link to one of their own orders), see the thread, and reply while it is open. Operators get a Support queue in the console with an unassigned-triage view, the full thread for each ticket, and controls to reply (with an internal-note option that the customer never sees), assign, tag, and move the ticket through its status workflow. A customer only ever sees their own tickets, and replying to a closed ticket is refused. **Added:** *Customer support tickets* — A Support section in the account area lets a signed-in customer open a ticket (subject, message, category, and an optional reference to one of their own orders), list their tickets with status, read the full message thread, and reply while the ticket is open. Internal operator notes are never shown to the customer, a customer can only see and reply to their own tickets, and a reply to a closed ticket is refused. · *Support queue in the admin console* — A Support screen lists inbound tickets by status with an unassigned-triage view. Opening a ticket shows the full thread, the requester, the current status, the assignee, and tags. From there an operator can reply (optionally as an internal note), assign the ticket, tag it, and move it through its status workflow — in progress, waiting on the customer, resolved, closed, or reopened.
|
|
12
|
+
|
|
11
13
|
- v0.3.25 (2026-05-30) — **Open an individual customer to see their orders and manage store credit, notes, and loyalty.** The customers list was view-only — there was no way to open a single customer. Each customer now has a detail screen showing their identity, recent orders (linked), store-credit balance with a grant/deduct control, loyalty balance and tier (with a link to adjust points), internal operator notes, and the segments they belong to. A store-credit grant or deduction requires a reason and is written to the store-credit ledger, an over-deduction is refused, and segment membership is shown read-only because it is computed from purchase behavior rather than set by hand. **Added:** *Customer detail screen* — Opening a customer from the roster now shows their record, recent orders, store-credit balance with a reason-gated grant/deduct (recorded in the store-credit ledger; an over-deduction is refused), their loyalty balance and tier with a link to adjust points, and an internal notes log you can add to. The segments a customer belongs to are listed read-only, since membership is derived from their behavior. Customer identity fields remain read-only by design.
|
|
12
14
|
|
|
13
15
|
- v0.3.24 (2026-05-30) — **Manage the loyalty program from the admin console.** The loyalty program was visible to customers at /account/loyalty — their points balance, tier, the ways to earn, and the rewards — but there was no way to configure it from the console, so it could not actually be run. A new Loyalty admin screen now manages the whole program: create and edit the rules that award points (and archive ones that should no longer apply), build and edit the rewards catalog (a reward must be active to appear to customers), and grant or deduct a specific customer's points balance. A points adjustment requires a reason and is written to the loyalty ledger, so every manual change is recorded. **Added:** *Loyalty program administration* — A Loyalty screen in the admin console manages earn rules (the events that award points and how many), the rewards catalog (what points can be redeemed for, with per-kind values), and per-customer point adjustments. Only active, non-archived rules and rewards appear to customers. A manual grant or deduction requires a reason and is recorded in the loyalty ledger alongside automatic earn and redeem activity, and an over-deduction or invalid adjustment is rejected without changing the balance. This completes a program that previously could be shown to shoppers but not configured.
|
package/lib/admin.js
CHANGED
|
@@ -508,6 +508,7 @@ function mount(router, deps) {
|
|
|
508
508
|
var shippingLabels = deps.shippingLabels || null; // per-shipment carrier-label record disabled when absent
|
|
509
509
|
var splitShipments = deps.splitShipments || null; // order split-shipment planner disabled when absent
|
|
510
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
|
|
511
512
|
|
|
512
513
|
// Which optional console sections are wired — gates their nav links so a
|
|
513
514
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
@@ -515,7 +516,7 @@ function mount(router, deps) {
|
|
|
515
516
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
516
517
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
517
518
|
// notice when the salesReports primitive isn't wired.
|
|
518
|
-
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
|
|
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 };
|
|
519
520
|
|
|
520
521
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
521
522
|
|
|
@@ -3578,6 +3579,220 @@ function mount(router, deps) {
|
|
|
3578
3579
|
));
|
|
3579
3580
|
}
|
|
3580
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
|
+
|
|
3581
3796
|
// ---- config ---------------------------------------------------------
|
|
3582
3797
|
|
|
3583
3798
|
var config = deps.config || null;
|
|
@@ -7881,6 +8096,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
7881
8096
|
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
7882
8097
|
{ key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
|
|
7883
8098
|
{ key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
|
|
8099
|
+
{ key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
|
|
7884
8100
|
{ key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
|
|
7885
8101
|
{ key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
|
|
7886
8102
|
{ key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
|
|
@@ -9360,6 +9576,165 @@ function renderAdminReturn(opts) {
|
|
|
9360
9576
|
return _renderAdminShell(opts.shop_name, "Return " + (r.rma_code || r.id.slice(0, 8)), body, "returns", opts.nav_available);
|
|
9361
9577
|
}
|
|
9362
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
|
+
|
|
9363
9738
|
// The review states an operator can filter the moderation queue by.
|
|
9364
9739
|
var REVIEW_STATUS_FILTERS = ["pending", "published", "rejected"];
|
|
9365
9740
|
|
package/lib/asset-manifest.json
CHANGED
package/lib/storefront.js
CHANGED
|
@@ -3158,6 +3158,215 @@ function renderReturns(opts) {
|
|
|
3158
3158
|
});
|
|
3159
3159
|
}
|
|
3160
3160
|
|
|
3161
|
+
// Support ticket — the categories a shopper can pick when raising one,
|
|
3162
|
+
// and the human label for each. The values mirror the support-tickets
|
|
3163
|
+
// module's ALLOWED_CATEGORIES; the backend validates the submitted value
|
|
3164
|
+
// against its own allow-list, so a forged value is refused there.
|
|
3165
|
+
var SUPPORT_CATEGORIES = [
|
|
3166
|
+
["pre_sale", "Pre-sale question"],
|
|
3167
|
+
["order_issue", "Problem with an order"],
|
|
3168
|
+
["shipping", "Shipping / delivery"],
|
|
3169
|
+
["billing", "Billing"],
|
|
3170
|
+
["refund", "Refund"],
|
|
3171
|
+
["account", "My account"],
|
|
3172
|
+
["complaint", "Complaint"],
|
|
3173
|
+
["feature_request", "Feature request"],
|
|
3174
|
+
["other", "Something else"],
|
|
3175
|
+
];
|
|
3176
|
+
|
|
3177
|
+
// Customer-facing status word for a ticket. The module's status enum is
|
|
3178
|
+
// operator vocabulary (new / in_progress / waiting_customer / resolved /
|
|
3179
|
+
// closed / reopened); these are the shopper-facing phrasings.
|
|
3180
|
+
var SUPPORT_STATUS_LABELS = {
|
|
3181
|
+
"new": "Received",
|
|
3182
|
+
"in_progress": "In progress",
|
|
3183
|
+
"waiting_customer": "Awaiting your reply",
|
|
3184
|
+
"resolved": "Resolved",
|
|
3185
|
+
"closed": "Closed",
|
|
3186
|
+
"reopened": "Reopened",
|
|
3187
|
+
};
|
|
3188
|
+
|
|
3189
|
+
function _supportStatusBadge(status) {
|
|
3190
|
+
var esc = b.template.escapeHtml;
|
|
3191
|
+
var label = SUPPORT_STATUS_LABELS[status] || status;
|
|
3192
|
+
return "<span class=\"return-status return-status--" + esc(String(status)) + "\">" +
|
|
3193
|
+
esc(String(label)) + "</span>";
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
function _supportCategoryLabel(value) {
|
|
3197
|
+
for (var i = 0; i < SUPPORT_CATEGORIES.length; i += 1) {
|
|
3198
|
+
if (SUPPORT_CATEGORIES[i][0] === value) return SUPPORT_CATEGORIES[i][1];
|
|
3199
|
+
}
|
|
3200
|
+
return value;
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
// The signed-in customer's support-ticket list. `opts.tickets` is the
|
|
3204
|
+
// ownership-scoped set (already filtered to this customer); each links to
|
|
3205
|
+
// its thread view.
|
|
3206
|
+
function renderSupportList(opts) {
|
|
3207
|
+
var esc = b.template.escapeHtml;
|
|
3208
|
+
var tickets = opts.tickets || [];
|
|
3209
|
+
var rowsHtml = "";
|
|
3210
|
+
for (var i = 0; i < tickets.length; i += 1) {
|
|
3211
|
+
var t = tickets[i];
|
|
3212
|
+
var date = t.opened_at ? new Date(Number(t.opened_at)).toISOString().slice(0, 10) : "";
|
|
3213
|
+
rowsHtml +=
|
|
3214
|
+
"<li class=\"return-card\">" +
|
|
3215
|
+
"<div class=\"return-card__head\">" +
|
|
3216
|
+
"<a class=\"return-card__rma\" href=\"/account/support/" + esc(String(t.id)) + "\">" + esc(String(t.subject)) + "</a>" +
|
|
3217
|
+
_supportStatusBadge(t.status) +
|
|
3218
|
+
"</div>" +
|
|
3219
|
+
"<p class=\"return-card__meta\">" + esc(_supportCategoryLabel(t.category)) +
|
|
3220
|
+
(date ? " · <time datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
|
|
3221
|
+
"</p>" +
|
|
3222
|
+
"</li>";
|
|
3223
|
+
}
|
|
3224
|
+
var inner = rowsHtml
|
|
3225
|
+
? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
|
|
3226
|
+
: "<div class=\"account-empty\">" +
|
|
3227
|
+
"<p class=\"account-empty__lede\">No support tickets yet. Raise one and we'll get back to you.</p>" +
|
|
3228
|
+
"</div>";
|
|
3229
|
+
var notice = "";
|
|
3230
|
+
if (opts.created) {
|
|
3231
|
+
notice = "<p class=\"form-notice form-notice--ok\" role=\"status\">Your ticket has been raised — we'll reply here.</p>";
|
|
3232
|
+
}
|
|
3233
|
+
var body =
|
|
3234
|
+
"<section class=\"account-returns\">" +
|
|
3235
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
3236
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
3237
|
+
"<li aria-current=\"page\">Support</li>" +
|
|
3238
|
+
"</ol></nav>" +
|
|
3239
|
+
"<header class=\"account-recently-viewed__head\">" +
|
|
3240
|
+
"<h1 class=\"account-returns__title\">Support</h1>" +
|
|
3241
|
+
"<a class=\"btn-primary\" href=\"/account/support/new\">Raise a ticket</a>" +
|
|
3242
|
+
"</header>" +
|
|
3243
|
+
notice +
|
|
3244
|
+
inner +
|
|
3245
|
+
"</section>";
|
|
3246
|
+
return _wrap({
|
|
3247
|
+
title: "Support",
|
|
3248
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
3249
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
3250
|
+
theme_css: opts.theme_css,
|
|
3251
|
+
body: body,
|
|
3252
|
+
});
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
// The new-ticket form. `opts.orders` is the signed-in customer's recent
|
|
3256
|
+
// orders so they can attach one (optional). `opts.values` re-fills the
|
|
3257
|
+
// form after a validation bounce; `opts.notice` is the error to show.
|
|
3258
|
+
function renderSupportNew(opts) {
|
|
3259
|
+
var esc = b.template.escapeHtml;
|
|
3260
|
+
var v = opts.values || {};
|
|
3261
|
+
var orders = opts.orders || [];
|
|
3262
|
+
var categoryOpts = SUPPORT_CATEGORIES.map(function (c) {
|
|
3263
|
+
var sel = v.category === c[0] ? " selected" : "";
|
|
3264
|
+
return "<option value=\"" + esc(c[0]) + "\"" + sel + ">" + esc(c[1]) + "</option>";
|
|
3265
|
+
}).join("");
|
|
3266
|
+
var orderOptions = "<option value=\"\">No specific order</option>" +
|
|
3267
|
+
orders.map(function (o) {
|
|
3268
|
+
var sel = v.order_id === o.id ? " selected" : "";
|
|
3269
|
+
var when = o.created_at ? new Date(Number(o.created_at)).toISOString().slice(0, 10) : "";
|
|
3270
|
+
return "<option value=\"" + esc(String(o.id)) + "\"" + sel + ">" +
|
|
3271
|
+
esc(String(o.id).slice(0, 8)) + (when ? " · " + esc(when) : "") + "</option>";
|
|
3272
|
+
}).join("");
|
|
3273
|
+
var orderField = orders.length
|
|
3274
|
+
? "<label class=\"form-field\"><span class=\"form-field__label\">Related order (optional)</span>" +
|
|
3275
|
+
"<select name=\"order_id\">" + orderOptions + "</select></label>"
|
|
3276
|
+
: "";
|
|
3277
|
+
var notice = opts.notice
|
|
3278
|
+
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
3279
|
+
: "";
|
|
3280
|
+
var body =
|
|
3281
|
+
"<section class=\"return-form-page\">" +
|
|
3282
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
3283
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
3284
|
+
"<li><a href=\"/account/support\">Support</a></li>" +
|
|
3285
|
+
"<li aria-current=\"page\">Raise a ticket</li>" +
|
|
3286
|
+
"</ol></nav>" +
|
|
3287
|
+
"<h1 class=\"return-form-page__title\">Raise a support ticket</h1>" +
|
|
3288
|
+
notice +
|
|
3289
|
+
"<form class=\"return-form form-stack\" method=\"post\" action=\"/account/support\">" +
|
|
3290
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">Email for our reply</span>" +
|
|
3291
|
+
"<input type=\"email\" name=\"customer_email\" value=\"" + esc(v.customer_email == null ? "" : String(v.customer_email)) + "\" required autocomplete=\"email\" maxlength=\"254\"></label>" +
|
|
3292
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">Category</span>" +
|
|
3293
|
+
"<select name=\"category\" required>" + categoryOpts + "</select></label>" +
|
|
3294
|
+
orderField +
|
|
3295
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">Subject</span>" +
|
|
3296
|
+
"<input type=\"text\" name=\"subject\" value=\"" + esc(v.subject == null ? "" : String(v.subject)) + "\" required maxlength=\"200\"></label>" +
|
|
3297
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">How can we help?</span>" +
|
|
3298
|
+
"<textarea name=\"body\" required maxlength=\"8000\" rows=\"6\">" + esc(v.body == null ? "" : String(v.body)) + "</textarea></label>" +
|
|
3299
|
+
"<button type=\"submit\" class=\"btn-primary\">Send</button>" +
|
|
3300
|
+
"</form>" +
|
|
3301
|
+
"</section>";
|
|
3302
|
+
return _wrap({
|
|
3303
|
+
title: "Raise a ticket",
|
|
3304
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
3305
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
3306
|
+
theme_css: opts.theme_css,
|
|
3307
|
+
body: body,
|
|
3308
|
+
});
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
// One ticket's thread view. `opts.ticket` is the row, `opts.messages` the
|
|
3312
|
+
// thread in created_at ASC. Internal operator notes (internal=1) are
|
|
3313
|
+
// filtered out by the route before rendering — they never reach the
|
|
3314
|
+
// customer. A non-closed ticket shows the reply box.
|
|
3315
|
+
function renderSupportTicket(opts) {
|
|
3316
|
+
var esc = b.template.escapeHtml;
|
|
3317
|
+
var t = opts.ticket;
|
|
3318
|
+
var messages = opts.messages || [];
|
|
3319
|
+
var msgHtml = "";
|
|
3320
|
+
for (var i = 0; i < messages.length; i += 1) {
|
|
3321
|
+
var m = messages[i];
|
|
3322
|
+
var who = m.author === "operator" ? "Support" : m.author === "system" ? "System" : "You";
|
|
3323
|
+
var when = m.created_at ? new Date(Number(m.created_at)).toISOString().slice(0, 16).replace("T", " ") : "";
|
|
3324
|
+
msgHtml +=
|
|
3325
|
+
"<li class=\"support-msg support-msg--" + esc(String(m.author)) + "\">" +
|
|
3326
|
+
"<p class=\"support-msg__who\">" + esc(who) +
|
|
3327
|
+
(when ? " <time class=\"support-msg__when\" datetime=\"" + esc(when) + "\">" + esc(when) + "</time>" : "") + "</p>" +
|
|
3328
|
+
"<p class=\"support-msg__body\">" + esc(String(m.body)).replace(/\n/g, "<br>") + "</p>" +
|
|
3329
|
+
"</li>";
|
|
3330
|
+
}
|
|
3331
|
+
var notice = opts.notice
|
|
3332
|
+
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
3333
|
+
: "";
|
|
3334
|
+
var replyOk = opts.replied
|
|
3335
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">Your reply has been added.</p>"
|
|
3336
|
+
: "";
|
|
3337
|
+
var closed = t.status === "closed";
|
|
3338
|
+
var replyForm = closed
|
|
3339
|
+
? "<p class=\"meta\">This ticket is closed. Raise a new one if you still need help.</p>"
|
|
3340
|
+
: "<form class=\"return-form form-stack\" method=\"post\" action=\"/account/support/" + esc(String(t.id)) + "/reply\">" +
|
|
3341
|
+
"<label class=\"form-field\"><span class=\"form-field__label\">Add a reply</span>" +
|
|
3342
|
+
"<textarea name=\"body\" required maxlength=\"8000\" rows=\"5\"></textarea></label>" +
|
|
3343
|
+
"<button type=\"submit\" class=\"btn-primary\">Send reply</button>" +
|
|
3344
|
+
"</form>";
|
|
3345
|
+
var body =
|
|
3346
|
+
"<section class=\"account-returns\">" +
|
|
3347
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
3348
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
3349
|
+
"<li><a href=\"/account/support\">Support</a></li>" +
|
|
3350
|
+
"<li aria-current=\"page\">Ticket</li>" +
|
|
3351
|
+
"</ol></nav>" +
|
|
3352
|
+
"<header class=\"account-recently-viewed__head\">" +
|
|
3353
|
+
"<h1 class=\"account-returns__title\">" + esc(String(t.subject)) + "</h1>" +
|
|
3354
|
+
_supportStatusBadge(t.status) +
|
|
3355
|
+
"</header>" +
|
|
3356
|
+
"<p class=\"return-card__meta\">" + esc(_supportCategoryLabel(t.category)) + "</p>" +
|
|
3357
|
+
replyOk + notice +
|
|
3358
|
+
"<ul class=\"support-thread\">" + msgHtml + "</ul>" +
|
|
3359
|
+
replyForm +
|
|
3360
|
+
"</section>";
|
|
3361
|
+
return _wrap({
|
|
3362
|
+
title: String(t.subject),
|
|
3363
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
3364
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
3365
|
+
theme_css: opts.theme_css,
|
|
3366
|
+
body: body,
|
|
3367
|
+
});
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3161
3370
|
// Loyalty transaction-type pill — reuses the `pdp__badge` class the
|
|
3162
3371
|
// theme already styles. The type is one of the ledger's closed enum
|
|
3163
3372
|
// (earn / redeem / expire / adjust / tier-bonus).
|
|
@@ -5978,6 +6187,7 @@ var ACCOUNT_DASH_PAGE =
|
|
|
5978
6187
|
" <a class=\"btn-secondary\" href=\"/account/recently-viewed\">Recently viewed</a>\n" +
|
|
5979
6188
|
" <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
|
|
5980
6189
|
" <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
|
|
6190
|
+
" <a class=\"btn-secondary\" href=\"/account/support\">Support</a>\n" +
|
|
5981
6191
|
" <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
|
|
5982
6192
|
" <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
|
|
5983
6193
|
" <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
|
|
@@ -10531,6 +10741,212 @@ function mount(router, deps) {
|
|
|
10531
10741
|
});
|
|
10532
10742
|
}
|
|
10533
10743
|
|
|
10744
|
+
// Support tickets — the signed-in customer raises a ticket, lists
|
|
10745
|
+
// their own, reads a thread, and replies. EVERY route is login-gated
|
|
10746
|
+
// AND scoped to the session customer's id: the support primitive
|
|
10747
|
+
// stores `customer_id` on each ticket, so a ticket whose customer_id
|
|
10748
|
+
// doesn't match the signed-in shopper is a 404 (never a cross-customer
|
|
10749
|
+
// reveal — the IDOR defense). The raw email is never on disk, so the
|
|
10750
|
+
// intake form collects a reply-to address (the backend validates +
|
|
10751
|
+
// hashes it); the list / view / reply paths key on the session
|
|
10752
|
+
// customer_id alone, independent of any email.
|
|
10753
|
+
if (deps.supportTickets) {
|
|
10754
|
+
var support = deps.supportTickets;
|
|
10755
|
+
|
|
10756
|
+
function _supportAuth(req, res) {
|
|
10757
|
+
var auth;
|
|
10758
|
+
try { auth = _currentCustomer(req); }
|
|
10759
|
+
catch (e) {
|
|
10760
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
10761
|
+
throw e;
|
|
10762
|
+
}
|
|
10763
|
+
if (!auth) {
|
|
10764
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
10765
|
+
res.end ? res.end() : res.send("");
|
|
10766
|
+
return null;
|
|
10767
|
+
}
|
|
10768
|
+
return auth;
|
|
10769
|
+
}
|
|
10770
|
+
|
|
10771
|
+
// Load the ticket named in :id and confirm it belongs to the
|
|
10772
|
+
// signed-in customer. A malformed id (guardUuid TypeError from
|
|
10773
|
+
// support.get), a missing ticket, or a ticket owned by someone else
|
|
10774
|
+
// ALL return 404 — never a 500, never a leak of another customer's
|
|
10775
|
+
// ticket. This single helper is the ownership gate every per-ticket
|
|
10776
|
+
// route funnels through.
|
|
10777
|
+
async function _ownedTicket(req, res, auth) {
|
|
10778
|
+
var ticket;
|
|
10779
|
+
try { ticket = await support.get(req.params && req.params.id); }
|
|
10780
|
+
catch (e) {
|
|
10781
|
+
if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
10782
|
+
throw e;
|
|
10783
|
+
}
|
|
10784
|
+
if (!ticket || ticket.customer_id !== auth.customer_id) {
|
|
10785
|
+
_send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
10786
|
+
return null;
|
|
10787
|
+
}
|
|
10788
|
+
return ticket;
|
|
10789
|
+
}
|
|
10790
|
+
|
|
10791
|
+
// The customer's own ticket list, scoped to their session customer_id.
|
|
10792
|
+
router.get("/account/support", async function (req, res) {
|
|
10793
|
+
var auth = _supportAuth(req, res); if (!auth) return;
|
|
10794
|
+
var tickets = await support.listByCustomerId(auth.customer_id, { limit: 100 });
|
|
10795
|
+
var cartCount = await _cartCountForReq(req);
|
|
10796
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
10797
|
+
_send(res, 200, renderSupportList({
|
|
10798
|
+
tickets: tickets,
|
|
10799
|
+
created: url && url.searchParams.get("ok") === "1",
|
|
10800
|
+
shop_name: shopName,
|
|
10801
|
+
cart_count: cartCount,
|
|
10802
|
+
}));
|
|
10803
|
+
});
|
|
10804
|
+
|
|
10805
|
+
// The new-ticket form. Offers the customer's recent orders as an
|
|
10806
|
+
// optional attachment.
|
|
10807
|
+
router.get("/account/support/new", async function (req, res) {
|
|
10808
|
+
var auth = _supportAuth(req, res); if (!auth) return;
|
|
10809
|
+
var orders = [];
|
|
10810
|
+
if (deps.order) {
|
|
10811
|
+
try {
|
|
10812
|
+
var page = await deps.order.listForCustomer(auth.customer_id, { limit: 20 });
|
|
10813
|
+
orders = page.rows;
|
|
10814
|
+
} catch (_e) { orders = []; }
|
|
10815
|
+
}
|
|
10816
|
+
var cartCount = await _cartCountForReq(req);
|
|
10817
|
+
_send(res, 200, renderSupportNew({
|
|
10818
|
+
orders: orders,
|
|
10819
|
+
shop_name: shopName,
|
|
10820
|
+
cart_count: cartCount,
|
|
10821
|
+
}));
|
|
10822
|
+
});
|
|
10823
|
+
|
|
10824
|
+
// Create the ticket as the session customer. customer_id is pinned
|
|
10825
|
+
// to the session — never read off the form — so a shopper can only
|
|
10826
|
+
// ever open a ticket against their own account. An optional order_id
|
|
10827
|
+
// is accepted only after confirming the order belongs to this
|
|
10828
|
+
// customer (so a forged id can't attach a stranger's order); a
|
|
10829
|
+
// non-owned / unknown order is dropped silently rather than failing
|
|
10830
|
+
// the ticket. The backend validates subject / body / category /
|
|
10831
|
+
// email shape and surfaces a TypeError on bad input as a clean
|
|
10832
|
+
// re-render, never a 500.
|
|
10833
|
+
router.post("/account/support", async function (req, res) {
|
|
10834
|
+
var auth = _supportAuth(req, res); if (!auth) return;
|
|
10835
|
+
var body = req.body || {};
|
|
10836
|
+
var cartCount = await _cartCountForReq(req);
|
|
10837
|
+
|
|
10838
|
+
async function _orders() {
|
|
10839
|
+
if (!deps.order) return [];
|
|
10840
|
+
try { return (await deps.order.listForCustomer(auth.customer_id, { limit: 20 })).rows; }
|
|
10841
|
+
catch (_e) { return []; }
|
|
10842
|
+
}
|
|
10843
|
+
|
|
10844
|
+
// Only attach an order the requesting customer actually owns.
|
|
10845
|
+
var orderId;
|
|
10846
|
+
if (body.order_id && deps.order) {
|
|
10847
|
+
try {
|
|
10848
|
+
var ord = await deps.order.get(body.order_id);
|
|
10849
|
+
if (ord && ord.customer_id === auth.customer_id) orderId = ord.id;
|
|
10850
|
+
} catch (_e) { /* malformed / unknown order id — attach nothing */ }
|
|
10851
|
+
}
|
|
10852
|
+
|
|
10853
|
+
var opened;
|
|
10854
|
+
try {
|
|
10855
|
+
opened = await support.open({
|
|
10856
|
+
customer_id: auth.customer_id,
|
|
10857
|
+
customer_email: body.customer_email,
|
|
10858
|
+
subject: body.subject,
|
|
10859
|
+
body: body.body,
|
|
10860
|
+
category: body.category,
|
|
10861
|
+
order_id: orderId,
|
|
10862
|
+
});
|
|
10863
|
+
} catch (e) {
|
|
10864
|
+
if (e instanceof TypeError) {
|
|
10865
|
+
return _send(res, 400, renderSupportNew({
|
|
10866
|
+
orders: await _orders(),
|
|
10867
|
+
values: body,
|
|
10868
|
+
notice: (e && e.message || "").replace(/^supportTickets[.:]\s*/, "") || "Please check your ticket and try again.",
|
|
10869
|
+
shop_name: shopName,
|
|
10870
|
+
cart_count: cartCount,
|
|
10871
|
+
}));
|
|
10872
|
+
}
|
|
10873
|
+
throw e;
|
|
10874
|
+
}
|
|
10875
|
+
res.status(303);
|
|
10876
|
+
res.setHeader && res.setHeader("location", "/account/support/" + encodeURIComponent(opened.id) + "?ok=1");
|
|
10877
|
+
return res.end ? res.end() : res.send("");
|
|
10878
|
+
});
|
|
10879
|
+
|
|
10880
|
+
// One ticket's thread. Ownership-scoped (foreign / unknown → 404).
|
|
10881
|
+
// Internal operator notes are filtered out before render — the
|
|
10882
|
+
// customer never sees an internal=1 message.
|
|
10883
|
+
router.get("/account/support/:id", async function (req, res) {
|
|
10884
|
+
var auth = _supportAuth(req, res); if (!auth) return;
|
|
10885
|
+
var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
|
|
10886
|
+
var thread = await support.thread(ticket.id);
|
|
10887
|
+
var visible = (thread.messages || []).filter(function (m) { return Number(m.internal) !== 1; });
|
|
10888
|
+
var cartCount = await _cartCountForReq(req);
|
|
10889
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
10890
|
+
_send(res, 200, renderSupportTicket({
|
|
10891
|
+
ticket: thread.ticket,
|
|
10892
|
+
messages: visible,
|
|
10893
|
+
replied: url && url.searchParams.get("ok") === "1",
|
|
10894
|
+
shop_name: shopName,
|
|
10895
|
+
cart_count: cartCount,
|
|
10896
|
+
}));
|
|
10897
|
+
});
|
|
10898
|
+
|
|
10899
|
+
// Append a customer reply. Ownership-scoped; refused on a closed
|
|
10900
|
+
// ticket (the backend rejects a closed-ticket reply with a typed
|
|
10901
|
+
// SUPPORT_TICKET_CLOSED error — surfaced as a 409 re-render, never a
|
|
10902
|
+
// 500). author is pinned to "customer".
|
|
10903
|
+
router.post("/account/support/:id/reply", async function (req, res) {
|
|
10904
|
+
var auth = _supportAuth(req, res); if (!auth) return;
|
|
10905
|
+
var ticket = await _ownedTicket(req, res, auth); if (!ticket) return;
|
|
10906
|
+
var body = req.body || {};
|
|
10907
|
+
var cartCount = await _cartCountForReq(req);
|
|
10908
|
+
|
|
10909
|
+
// A closed ticket can't take a reply — short-circuit to a
|
|
10910
|
+
// re-render with a notice rather than calling the backend (which
|
|
10911
|
+
// would also refuse, but this keeps the message customer-readable).
|
|
10912
|
+
if (ticket.status === "closed") {
|
|
10913
|
+
var closedThread = await support.thread(ticket.id);
|
|
10914
|
+
return _send(res, 409, renderSupportTicket({
|
|
10915
|
+
ticket: closedThread.ticket,
|
|
10916
|
+
messages: (closedThread.messages || []).filter(function (m) { return Number(m.internal) !== 1; }),
|
|
10917
|
+
notice: "This ticket is closed — raise a new one if you still need help.",
|
|
10918
|
+
shop_name: shopName,
|
|
10919
|
+
cart_count: cartCount,
|
|
10920
|
+
}));
|
|
10921
|
+
}
|
|
10922
|
+
|
|
10923
|
+
try {
|
|
10924
|
+
await support.reply({
|
|
10925
|
+
ticket_id: ticket.id,
|
|
10926
|
+
author: "customer",
|
|
10927
|
+
author_id: auth.customer_id,
|
|
10928
|
+
body: body.body,
|
|
10929
|
+
});
|
|
10930
|
+
} catch (e) {
|
|
10931
|
+
if (e instanceof TypeError || (e && e.code === "SUPPORT_TICKET_CLOSED")) {
|
|
10932
|
+
var againThread = await support.thread(ticket.id);
|
|
10933
|
+
var status = (e && e.code === "SUPPORT_TICKET_CLOSED") ? 409 : 400;
|
|
10934
|
+
return _send(res, status, renderSupportTicket({
|
|
10935
|
+
ticket: againThread.ticket,
|
|
10936
|
+
messages: (againThread.messages || []).filter(function (m) { return Number(m.internal) !== 1; }),
|
|
10937
|
+
notice: (e && e.message || "").replace(/^supportTickets[.:]\s*/, "") || "Please check your reply and try again.",
|
|
10938
|
+
shop_name: shopName,
|
|
10939
|
+
cart_count: cartCount,
|
|
10940
|
+
}));
|
|
10941
|
+
}
|
|
10942
|
+
throw e;
|
|
10943
|
+
}
|
|
10944
|
+
res.status(303);
|
|
10945
|
+
res.setHeader && res.setHeader("location", "/account/support/" + encodeURIComponent(ticket.id) + "?ok=1");
|
|
10946
|
+
return res.end ? res.end() : res.send("");
|
|
10947
|
+
});
|
|
10948
|
+
}
|
|
10949
|
+
|
|
10534
10950
|
// Loyalty — the signed-in customer's points balance + tier, the
|
|
10535
10951
|
// earn/redeem ledger, how points are earned, and (when a reward
|
|
10536
10952
|
// catalog + redemption primitive are wired) a redeem-a-reward
|
package/lib/support-tickets.js
CHANGED
|
@@ -755,6 +755,60 @@ function create(opts) {
|
|
|
755
755
|
return r.rows.map(_hydrate);
|
|
756
756
|
},
|
|
757
757
|
|
|
758
|
+
// Customer-account-scoped list — every ticket whose `customer_id`
|
|
759
|
+
// matches, newest-opened first, optionally narrowed to one status.
|
|
760
|
+
// Keyed on the row's `customer_id` (NOT the email hash) so the
|
|
761
|
+
// storefront, which holds only the session customer id (the raw
|
|
762
|
+
// email is never on disk), can list + ownership-gate a signed-in
|
|
763
|
+
// shopper's tickets without re-deriving the email hash. Composes the
|
|
764
|
+
// same id / status / limit validators as the rest of the surface.
|
|
765
|
+
listByCustomerId: async function (customerId, listOpts) {
|
|
766
|
+
customerId = _uuid(customerId, "customer_id");
|
|
767
|
+
listOpts = listOpts || {};
|
|
768
|
+
var limit = _limit(listOpts.limit);
|
|
769
|
+
var where = ["customer_id = ?1"];
|
|
770
|
+
var params = [customerId];
|
|
771
|
+
var idx = 2;
|
|
772
|
+
if (listOpts.status_filter != null) {
|
|
773
|
+
where.push("status = ?" + idx);
|
|
774
|
+
params.push(_status(listOpts.status_filter, "status_filter"));
|
|
775
|
+
idx += 1;
|
|
776
|
+
}
|
|
777
|
+
params.push(limit);
|
|
778
|
+
var sql = "SELECT * FROM support_tickets WHERE " + where.join(" AND ") +
|
|
779
|
+
" ORDER BY opened_at DESC, id DESC LIMIT ?" + idx;
|
|
780
|
+
var r = await query(sql, params);
|
|
781
|
+
return r.rows.map(_hydrate);
|
|
782
|
+
},
|
|
783
|
+
|
|
784
|
+
// Operator queue across every assignment state — newest-stale first
|
|
785
|
+
// (oldest last_action_at), optionally narrowed to one status. The
|
|
786
|
+
// status filter drives the admin console's status chips; absent a
|
|
787
|
+
// filter it surfaces the whole board (every status, including
|
|
788
|
+
// resolved / closed) so the operator can audit history, capped at
|
|
789
|
+
// `limit`.
|
|
790
|
+
list: async function (listOpts) {
|
|
791
|
+
listOpts = listOpts || {};
|
|
792
|
+
var limit = _limit(listOpts.limit);
|
|
793
|
+
var where = [];
|
|
794
|
+
var params = [];
|
|
795
|
+
var idx = 1;
|
|
796
|
+
if (listOpts.status_filter != null) {
|
|
797
|
+
where.push("status = ?" + idx);
|
|
798
|
+
params.push(_status(listOpts.status_filter, "status_filter"));
|
|
799
|
+
idx += 1;
|
|
800
|
+
}
|
|
801
|
+
params.push(limit);
|
|
802
|
+
var sql = "SELECT * FROM support_tickets" +
|
|
803
|
+
(where.length ? " WHERE " + where.join(" AND ") : "") +
|
|
804
|
+
" ORDER BY CASE priority " +
|
|
805
|
+
" WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 " +
|
|
806
|
+
" WHEN 'normal' THEN 2 WHEN 'low' THEN 3 ELSE 4 END, " +
|
|
807
|
+
"last_action_at ASC LIMIT ?" + idx;
|
|
808
|
+
var r = await query(sql, params);
|
|
809
|
+
return r.rows.map(_hydrate);
|
|
810
|
+
},
|
|
811
|
+
|
|
758
812
|
// Full thread view — ticket + every message in created_at ASC
|
|
759
813
|
// order so the caller can render the conversation top-to-bottom.
|
|
760
814
|
thread: async function (ticketId) {
|
package/package.json
CHANGED