@blamejs/blamejs-shop 0.3.34 → 0.3.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.36 (2026-05-31) — **Customers can request an exchange instead of only a refund.** Returning an order could only result in a refund, so a customer who received the wrong size had to refund and buy again. A customer can now request an exchange from the order page — choosing the item to swap, the replacement, and a reason — and operators work those requests from a new Exchanges queue in the admin console, moving each one through approval, shipping the replacement, receiving the returned item, and closing it out. A customer can only request and view exchanges on their own orders. This release also updates the vendored blamejs runtime to v0.14.10. **Added:** *Order exchanges* — An order can now be exchanged, not just refunded. From the order page a customer requests an exchange — picking the line to return, the replacement variant, and a reason — and tracks its status under Exchanges in their account. Operators get an Exchanges queue in the admin console with a status filter and a per-exchange screen that offers only the actions valid at the current step: approve or reject the request, mark the replacement shipped and delivered, mark the returned item received, and close it once both sides are complete. Every customer-facing exchange action is scoped to the customer's own orders, and an order must be in a fulfilled state to be eligible. **Changed:** *Vendored blamejs runtime updated to v0.14.10* — The bundled blamejs runtime is updated to v0.14.10, which routes the framework's full-text-search index token hashing through a keyed MAC (HMAC-SHAKE256) instead of a static-salted digest, so an index token hash cannot be forged or correlated across deployments without the per-deployment key. The change is internal to the runtime and requires no operator action.
12
+
13
+ - v0.3.35 (2026-05-31) — **Share a wishlist with a private link.** A customer's wishlist could only be viewed by the customer themselves. From the wishlist page a shopper can now create a shareable link and send it to someone — a friend buying a gift, for example — who opens it without needing an account and sees the wishlist's products. The link can be revoked at any time, after which it stops working. The shared page shows only the products: it does not reveal who owns the wishlist or any private notes on it, and it is not indexed by search engines. **Added:** *Shareable wishlist links* — The wishlist page now has a Share control that creates a private link to the wishlist. The full link is shown once, to copy and send; anyone with it can open the shared wishlist without an account and see its products, each linking to the product page. The owner sees their active links and can revoke any of them, which immediately stops the link from working. The shared view shows only the products — never the owner's identity or private notes — and is marked not to be indexed by search engines, so a personal wishlist stays private to whoever holds the link.
14
+
11
15
  - v0.3.34 (2026-05-31) — **Edit, pin, and archive the internal notes on a customer.** Internal notes on a customer could only be added — a note with a typo or one that was no longer true could not be corrected or removed, which is a liability for notes like "do not ship to this address" or "allergic to X". The customer detail screen now lets you edit a note's text, pin important notes to the top, and archive notes that no longer apply (and restore them). Each action is limited to notes that belong to the customer being viewed. **Added:** *Edit, pin, and archive customer notes* — The notes panel on a customer's detail screen now supports the full lifecycle: edit a note's text to fix or update it, pin the notes that matter so they sort to the top, and archive notes that no longer apply, with a toggle to show archived notes and restore them. Previously notes were append-only, so a wrong or stale note could not be corrected or retired. Every edit, pin, and archive action is scoped to the customer being viewed, so a note can only be changed from the record it belongs to.
12
16
 
13
17
  - v0.3.33 (2026-05-31) — **Create and manage customer segments from the admin console.** Customer segments could be shown on a customer's profile but there was no way to define or manage them, so the feature could not actually be used. A new Segments screen in the admin console creates rule-based segments from recency, frequency, lifetime-order, spend, average-order-value, and refund-rate criteria, edits their rules, archives and restores them, and recomputes membership on demand — showing how many customers each segment now contains. Segment membership stays rule-derived: it is recalculated from order history, not assigned by hand. **Added:** *Customer segments administration* — A Segments screen in the admin console manages rule-based customer segments. Each segment is defined by RFM-style criteria — how recently and how often a customer orders, their lifetime order count and spend, average order value, and refund rate — and a list shows every segment with its rules, current member count, and status, filterable by active or archived. You can create and edit segments, archive and restore them, and recompute membership on demand, which re-evaluates the rules against order history and reports how many customers match. Membership is always derived from the rules, never assigned per customer.
package/lib/admin.js CHANGED
@@ -509,6 +509,7 @@ function mount(router, deps) {
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
511
  var supportTickets = deps.supportTickets || null; // support-ticket queue + thread console disabled when absent
512
+ var orderExchanges = deps.orderExchanges || null; // exchange queue + FSM-action console disabled when absent
512
513
 
513
514
  // Which optional console sections are wired — gates their nav links so a
514
515
  // signed-in admin is never sent to a route that wasn't mounted. Passed
@@ -516,7 +517,7 @@ function mount(router, deps) {
516
517
  // `reports` is always present in the nav (read-only sales summary needs no
517
518
  // extra dep); its route mounts unconditionally and renders an unconfigured
518
519
  // notice when the salesReports primitive isn't wired.
519
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, 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 };
520
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, 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, orderExchanges: !!orderExchanges };
520
521
 
521
522
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
522
523
 
@@ -3903,6 +3904,220 @@ function mount(router, deps) {
3903
3904
  _supportTransition("reopen", "reopened");
3904
3905
  }
3905
3906
 
3907
+ // ---- order exchanges ------------------------------------------------
3908
+ //
3909
+ // The operator side of a customer-requested item swap: the FIFO queue of
3910
+ // open exchanges, one exchange's detail, and the FSM action routes that
3911
+ // walk it (approve / reject / mark-replacement-shipped / mark-replacement-
3912
+ // delivered / mark-return-received / close). Each action button renders
3913
+ // only when that edge is legal from the current status (mirrors the
3914
+ // module's TRANSITIONS graph), so the console never offers a transition
3915
+ // the primitive would refuse.
3916
+ if (orderExchanges) {
3917
+ var exchanges = orderExchanges;
3918
+
3919
+ // Map an exchange-primitive error to a browser-safe outcome class. A
3920
+ // bad shape (TypeError), an FSM refusal, a not-found row, or the
3921
+ // both-sides-required close guard all become an ?err=1 notice on the
3922
+ // detail rather than a 500; anything else propagates to the wrapper.
3923
+ function _exchangeClientError(e) {
3924
+ if (!e) return false;
3925
+ if (e instanceof TypeError) return true;
3926
+ return e.code === "EXCHANGE_NOT_FOUND" ||
3927
+ e.code === "EXCHANGE_TRANSITION_REFUSED" ||
3928
+ e.code === "EXCHANGE_BOTH_SIDES_REQUIRED";
3929
+ }
3930
+ // Map the same classes to a problem-details status for the JSON API: a
3931
+ // bad shape is 400, a missing row 404, a refused/illegal transition or
3932
+ // the both-sides-required guard a 409.
3933
+ function _exchangeApiError(res, e) {
3934
+ if (!e) return null;
3935
+ // The coded errors are thrown as TypeErrors that ALSO carry a `code`,
3936
+ // so the specific code checks MUST run before the generic
3937
+ // `instanceof TypeError` branch — otherwise a well-formed but missing
3938
+ // exchange id (EXCHANGE_NOT_FOUND) would map to 400 instead of 404.
3939
+ if (e.code === "EXCHANGE_NOT_FOUND") return _problem(res, 404, "exchange-not-found");
3940
+ if (e.code === "EXCHANGE_TRANSITION_REFUSED" || e.code === "EXCHANGE_BOTH_SIDES_REQUIRED") {
3941
+ return _problem(res, 409, "conflict", e.message);
3942
+ }
3943
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
3944
+ return null;
3945
+ }
3946
+
3947
+ router.get("/admin/exchanges", _pageOrApi(true,
3948
+ R(async function (req, res) {
3949
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3950
+ var status = url && url.searchParams.get("status");
3951
+ var rows = await exchanges.openExchanges(status ? { status: status } : {});
3952
+ _json(res, 200, { rows: rows, status: status || "open" });
3953
+ }),
3954
+ async function (req, res) {
3955
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3956
+ var status = url && url.searchParams.get("status");
3957
+ var notice = null, rows = [];
3958
+ // A bad ?status= (terminal / unknown) raises a TypeError from
3959
+ // openExchanges — fall back to the whole open queue with a notice.
3960
+ try {
3961
+ rows = await exchanges.openExchanges(status ? { status: status } : {});
3962
+ } catch (e) {
3963
+ if (!(e instanceof TypeError)) throw e;
3964
+ status = null; notice = "Unknown status filter — showing the open queue.";
3965
+ rows = await exchanges.openExchanges({});
3966
+ }
3967
+ _sendHtml(res, 200, renderAdminExchanges({
3968
+ shop_name: deps.shop_name, nav_available: navAvailable, exchanges: rows, status: status || "open", notice: notice,
3969
+ }));
3970
+ },
3971
+ ));
3972
+
3973
+ // Resolve an exchange by :id, surfacing a malformed / unknown id as a
3974
+ // 404 (the route is a defensive request-shape reader, never a 500).
3975
+ async function _exchangeOr404(req, res, isJson) {
3976
+ var exchange;
3977
+ try { exchange = await exchanges.getExchange(req.params.id); }
3978
+ catch (e) {
3979
+ if (e instanceof TypeError) {
3980
+ if (isJson) { _problem(res, 404, "exchange-not-found"); return null; }
3981
+ _sendHtml(res, 404, renderAdminExchanges({
3982
+ shop_name: deps.shop_name, nav_available: navAvailable, exchanges: [], status: "open", notice: "Exchange not found.",
3983
+ }));
3984
+ return null;
3985
+ }
3986
+ throw e;
3987
+ }
3988
+ if (!exchange) {
3989
+ if (isJson) { _problem(res, 404, "exchange-not-found"); return null; }
3990
+ _sendHtml(res, 404, renderAdminExchanges({
3991
+ shop_name: deps.shop_name, nav_available: navAvailable, exchanges: [], status: "open", notice: "Exchange not found.",
3992
+ }));
3993
+ return null;
3994
+ }
3995
+ return exchange;
3996
+ }
3997
+
3998
+ router.get("/admin/exchanges/:id", _pageOrApi(true,
3999
+ R(async function (req, res) {
4000
+ var exchange = await _exchangeOr404(req, res, true); if (!exchange) return;
4001
+ _json(res, 200, exchange);
4002
+ }),
4003
+ async function (req, res) {
4004
+ var exchange = await _exchangeOr404(req, res, false); if (!exchange) return;
4005
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4006
+ _sendHtml(res, 200, renderAdminExchange({
4007
+ shop_name: deps.shop_name,
4008
+ nav_available: navAvailable,
4009
+ exchange: exchange,
4010
+ moved: url && url.searchParams.get("moved"),
4011
+ notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this exchange." : null,
4012
+ }));
4013
+ },
4014
+ ));
4015
+
4016
+ // The browser side of an exchange action: run `opFn(id, body)`, then
4017
+ // PRG back to the detail. A bad shape / FSM refusal / not-found becomes
4018
+ // an ?err=1 notice, never a 500; anything else propagates.
4019
+ function _exchangeAction(jsonHandler, auditEvent, opFn) {
4020
+ return _pageOrApi(false, jsonHandler, async function (req, res) {
4021
+ var id = req.params.id;
4022
+ try { await opFn(id, req.body || {}); }
4023
+ catch (e) {
4024
+ if (_exchangeClientError(e)) {
4025
+ return _redirect(res, "/admin/exchanges/" + encodeURIComponent(id) + "?err=1");
4026
+ }
4027
+ throw e;
4028
+ }
4029
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
4030
+ _redirect(res, "/admin/exchanges/" + encodeURIComponent(id) + "?moved=1");
4031
+ });
4032
+ }
4033
+
4034
+ // pending -> approved. The operator id is a UUID seeded from the form.
4035
+ router.post("/admin/exchanges/:id/approve", _exchangeAction(
4036
+ W("exchange.approve", async function (req, res) {
4037
+ var body = req.body || {};
4038
+ var x;
4039
+ try { x = await exchanges.approveExchange(req.params.id, { approver_id: body.approver_id }); }
4040
+ catch (e) { var mapped = _exchangeApiError(res, e); if (mapped !== null) return mapped; throw e; }
4041
+ _json(res, 200, x);
4042
+ return x;
4043
+ }),
4044
+ "exchange.approve",
4045
+ function (id, body) { return exchanges.approveExchange(id, { approver_id: body.approver_id }); },
4046
+ ));
4047
+
4048
+ // pending|approved -> rejected (terminal). Records the operator id +
4049
+ // the reason surfaced to the customer.
4050
+ router.post("/admin/exchanges/:id/reject", _exchangeAction(
4051
+ W("exchange.reject", async function (req, res) {
4052
+ var body = req.body || {};
4053
+ var x;
4054
+ try { x = await exchanges.rejectExchange(req.params.id, { approver_id: body.approver_id, reject_reason: body.reject_reason }); }
4055
+ catch (e) { var mapped = _exchangeApiError(res, e); if (mapped !== null) return mapped; throw e; }
4056
+ _json(res, 200, x);
4057
+ return x;
4058
+ }),
4059
+ "exchange.reject",
4060
+ function (id, body) { return exchanges.rejectExchange(id, { approver_id: body.approver_id, reject_reason: body.reject_reason }); },
4061
+ ));
4062
+
4063
+ // approved -> shipped. Captures the outbound tracking number + carrier.
4064
+ router.post("/admin/exchanges/:id/mark-shipped", _exchangeAction(
4065
+ W("exchange.shipped", async function (req, res) {
4066
+ var body = req.body || {};
4067
+ var x;
4068
+ try { x = await exchanges.markReplacementShipped(req.params.id, { tracking_number: body.tracking_number, carrier: body.carrier }); }
4069
+ catch (e) { var mapped = _exchangeApiError(res, e); if (mapped !== null) return mapped; throw e; }
4070
+ _json(res, 200, x);
4071
+ return x;
4072
+ }),
4073
+ "exchange.shipped",
4074
+ function (id, body) { return exchanges.markReplacementShipped(id, { tracking_number: body.tracking_number, carrier: body.carrier }); },
4075
+ ));
4076
+
4077
+ // shipped|received -> delivered. Records when the replacement reached
4078
+ // the customer.
4079
+ router.post("/admin/exchanges/:id/mark-delivered", _exchangeAction(
4080
+ W("exchange.delivered", async function (req, res) {
4081
+ var x;
4082
+ try { x = await exchanges.markReplacementDelivered(req.params.id, {}); }
4083
+ catch (e) { var mapped = _exchangeApiError(res, e); if (mapped !== null) return mapped; throw e; }
4084
+ _json(res, 200, x);
4085
+ return x;
4086
+ }),
4087
+ "exchange.delivered",
4088
+ function (id) { return exchanges.markReplacementDelivered(id, {}); },
4089
+ ));
4090
+
4091
+ // shipped|delivered -> received. Records when the customer's return
4092
+ // reached the warehouse.
4093
+ router.post("/admin/exchanges/:id/mark-received", _exchangeAction(
4094
+ W("exchange.received", async function (req, res) {
4095
+ var x;
4096
+ try { x = await exchanges.markReturnReceived(req.params.id, {}); }
4097
+ catch (e) { var mapped = _exchangeApiError(res, e); if (mapped !== null) return mapped; throw e; }
4098
+ _json(res, 200, x);
4099
+ return x;
4100
+ }),
4101
+ "exchange.received",
4102
+ function (id) { return exchanges.markReturnReceived(id, {}); },
4103
+ ));
4104
+
4105
+ // delivered|received -> closed (terminal). The primitive refuses until
4106
+ // BOTH the replacement is delivered AND the customer's return is back,
4107
+ // surfaced as a 409.
4108
+ router.post("/admin/exchanges/:id/close", _exchangeAction(
4109
+ W("exchange.close", async function (req, res) {
4110
+ var x;
4111
+ try { x = await exchanges.closeExchange(req.params.id, {}); }
4112
+ catch (e) { var mapped = _exchangeApiError(res, e); if (mapped !== null) return mapped; throw e; }
4113
+ _json(res, 200, x);
4114
+ return x;
4115
+ }),
4116
+ "exchange.close",
4117
+ function (id) { return exchanges.closeExchange(id, {}); },
4118
+ ));
4119
+ }
4120
+
3906
4121
  // ---- config ---------------------------------------------------------
3907
4122
 
3908
4123
  var config = deps.config || null;
@@ -8759,6 +8974,7 @@ var ADMIN_NAV_ITEMS = [
8759
8974
  { key: "segments", href: "/admin/segments", label: "Segments", requires: "customerSegments" },
8760
8975
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
8761
8976
  { key: "support", href: "/admin/support", label: "Support", requires: "supportTickets" },
8977
+ { key: "exchanges", href: "/admin/exchanges", label: "Exchanges", requires: "orderExchanges" },
8762
8978
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
8763
8979
  { key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
8764
8980
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
@@ -10429,6 +10645,164 @@ function renderAdminSupportTicket(opts) {
10429
10645
  return _renderAdminShell(opts.shop_name, "Ticket", body, "support", opts.nav_available);
10430
10646
  }
10431
10647
 
10648
+ // The non-terminal exchange states the operator can filter the queue by —
10649
+ // lifecycle order (the terminal closed / rejected don't appear in the open
10650
+ // queue, so they're not filter chips).
10651
+ var EXCHANGE_STATUS_FILTERS = ["pending", "approved", "shipped", "delivered", "received"];
10652
+
10653
+ function _exchangePillClass(status) {
10654
+ if (status === "closed") return "paid"; // completed — green
10655
+ if (status === "rejected") return "cancelled";
10656
+ if (status === "shipped" || status === "delivered" || status === "received") return "shipped";
10657
+ return "pending"; // pending / approved
10658
+ }
10659
+
10660
+ // Operator queue. The status chips filter the open (non-terminal) board;
10661
+ // each row links to the exchange detail. openExchanges orders oldest-first
10662
+ // (FIFO), so the customer who's been waiting longest is at the top.
10663
+ function renderAdminExchanges(opts) {
10664
+ opts = opts || {};
10665
+ var list = opts.exchanges || [];
10666
+ var active = opts.status || "open";
10667
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
10668
+
10669
+ var chips = "<div class=\"order-filters\">" +
10670
+ "<a class=\"chip" + (active === "open" ? " chip--on" : "") + "\" href=\"/admin/exchanges\">open</a>" +
10671
+ EXCHANGE_STATUS_FILTERS.map(function (s) {
10672
+ return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/exchanges?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
10673
+ }).join("") +
10674
+ "</div>";
10675
+
10676
+ var rows = list.map(function (x) {
10677
+ return "<tr>" +
10678
+ "<td><a class=\"order-id\" href=\"/admin/exchanges/" + _htmlEscape(x.id) + "\">" + _htmlEscape(String(x.id).slice(0, 8)) + "</a></td>" +
10679
+ "<td><span class=\"order-id\">" + _htmlEscape(String(x.order_id).slice(0, 8)) + "</span></td>" +
10680
+ "<td>" + _htmlEscape(x.return_sku) + " &rarr; " + _htmlEscape(x.replacement_sku) + "</td>" +
10681
+ "<td>" + _htmlEscape(x.reason) + "</td>" +
10682
+ "<td><span class=\"status-pill " + _exchangePillClass(x.status) + "\">" + _htmlEscape(x.status) + "</span></td>" +
10683
+ "<td>" + _htmlEscape(_fmtDate(x.created_at)) + "</td>" +
10684
+ "</tr>";
10685
+ }).join("");
10686
+
10687
+ var table = list.length
10688
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Exchange</th><th scope=\"col\">Order</th><th scope=\"col\">Swap</th><th scope=\"col\">Reason</th><th scope=\"col\">Status</th><th scope=\"col\">Requested</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
10689
+ : "<p class=\"empty\">No “" + _htmlEscape(active) + "” exchanges.</p>";
10690
+
10691
+ var body = "<section><h2>Exchanges</h2>" + notice + chips + table + "</section>";
10692
+ return _renderAdminShell(opts.shop_name, "Exchanges", body, "exchanges", opts.nav_available);
10693
+ }
10694
+
10695
+ // Exchange detail — the swap, the reason, the timestamps, and the action
10696
+ // forms legal from the current status. The legal-edge map mirrors the
10697
+ // order-exchanges module's TRANSITIONS graph so the console only ever
10698
+ // offers a transition the primitive would accept.
10699
+ function renderAdminExchange(opts) {
10700
+ opts = opts || {};
10701
+ var x = opts.exchange;
10702
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Exchange updated.</div>" : "";
10703
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
10704
+
10705
+ function _field(label, value) {
10706
+ return "<p><span class=\"meta\">" + _htmlEscape(label) + "</span><br>" + (value ? _htmlEscape(String(value)) : "<span class=\"meta\">—</span>") + "</p>";
10707
+ }
10708
+ function _ts(value) { return value ? _fmtDate(value) : null; }
10709
+
10710
+ // Legal FSM edges from the current status -> the action verbs that
10711
+ // render. Mirrors lib/order-exchanges.js TRANSITIONS.
10712
+ var EDGES = {
10713
+ pending: ["approve", "reject"],
10714
+ approved: ["mark-shipped", "reject"],
10715
+ shipped: ["mark-delivered", "mark-received"],
10716
+ delivered: ["mark-received", "close"],
10717
+ received: ["mark-delivered", "close"],
10718
+ closed: [],
10719
+ rejected: [],
10720
+ };
10721
+ var legal = EDGES[x.status] || [];
10722
+ var has = function (verb) { return legal.indexOf(verb) !== -1; };
10723
+
10724
+ var actionBlocks = [];
10725
+ if (has("approve")) {
10726
+ actionBlocks.push(
10727
+ "<form method=\"post\" action=\"/admin/exchanges/" + _htmlEscape(x.id) + "/approve\" class=\"return-action\">" +
10728
+ "<h4>Approve</h4><p class=\"meta\">Accept the swap and pin the replacement shelf.</p>" +
10729
+ _setupField("Operator id (UUID)", "approver_id", "", "text", "The operator approving this exchange.", " required") +
10730
+ "<button class=\"btn\" type=\"submit\">Approve exchange</button>" +
10731
+ "</form>");
10732
+ }
10733
+ if (has("mark-shipped")) {
10734
+ actionBlocks.push(
10735
+ "<form method=\"post\" action=\"/admin/exchanges/" + _htmlEscape(x.id) + "/mark-shipped\" class=\"return-action\">" +
10736
+ "<h4>Mark replacement shipped</h4>" +
10737
+ _setupField("Tracking number", "tracking_number", "", "text", "", " maxlength=\"128\" required") +
10738
+ _setupField("Carrier", "carrier", "", "text", "", " maxlength=\"64\" required") +
10739
+ "<button class=\"btn\" type=\"submit\">Mark shipped</button>" +
10740
+ "</form>");
10741
+ }
10742
+ if (has("mark-delivered")) {
10743
+ actionBlocks.push(
10744
+ "<form method=\"post\" action=\"/admin/exchanges/" + _htmlEscape(x.id) + "/mark-delivered\" class=\"return-action\">" +
10745
+ "<h4>Mark replacement delivered</h4><p class=\"meta\">The replacement reached the customer.</p>" +
10746
+ "<button class=\"btn\" type=\"submit\">Mark delivered</button>" +
10747
+ "</form>");
10748
+ }
10749
+ if (has("mark-received")) {
10750
+ actionBlocks.push(
10751
+ "<form method=\"post\" action=\"/admin/exchanges/" + _htmlEscape(x.id) + "/mark-received\" class=\"return-action\">" +
10752
+ "<h4>Mark return received</h4><p class=\"meta\">The customer's original arrived back at the warehouse.</p>" +
10753
+ "<button class=\"btn\" type=\"submit\">Mark received</button>" +
10754
+ "</form>");
10755
+ }
10756
+ if (has("close")) {
10757
+ actionBlocks.push(
10758
+ "<form method=\"post\" action=\"/admin/exchanges/" + _htmlEscape(x.id) + "/close\" class=\"return-action\">" +
10759
+ "<h4>Close</h4><p class=\"meta\">Closes the exchange once both the replacement is delivered AND the return is back.</p>" +
10760
+ "<button class=\"btn\" type=\"submit\">Close exchange</button>" +
10761
+ "</form>");
10762
+ }
10763
+ if (has("reject")) {
10764
+ actionBlocks.push(
10765
+ "<form method=\"post\" action=\"/admin/exchanges/" + _htmlEscape(x.id) + "/reject\" class=\"return-action\">" +
10766
+ "<h4>Reject</h4>" +
10767
+ _setupField("Operator id (UUID)", "approver_id", "", "text", "The operator rejecting this exchange.", " required") +
10768
+ _setupField("Reason for rejection", "reject_reason", "", "text", "Shown to the customer.", " maxlength=\"1024\" required") +
10769
+ "<button class=\"btn btn--danger\" type=\"submit\">Reject exchange</button>" +
10770
+ "</form>");
10771
+ }
10772
+ var actions = actionBlocks.length
10773
+ ? "<div class=\"return-actions\">" + actionBlocks.join("") + "</div>"
10774
+ : "<span class=\"meta\">This exchange is in a final state — no further changes.</span>";
10775
+
10776
+ var body =
10777
+ "<section class=\"mw-48\">" +
10778
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/exchanges\">&larr; Exchanges</a></div>" +
10779
+ "<h2>Exchange <code class=\"order-id\">" + _htmlEscape(String(x.id).slice(0, 8)) + "</code> " +
10780
+ "<span class=\"status-pill " + _exchangePillClass(x.status) + "\">" + _htmlEscape(x.status) + "</span></h2>" +
10781
+ "<p class=\"meta\">Requested " + _htmlEscape(_fmtDate(x.created_at)) +
10782
+ " · order <a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(x.order_id) + "\">" + _htmlEscape(String(x.order_id).slice(0, 8)) + "</a></p>" +
10783
+ moved + notice +
10784
+ "<div class=\"two-col\">" +
10785
+ "<div class=\"panel\"><h3 class=\"subhead\">Swap</h3>" +
10786
+ _field("Item returned", x.return_sku + " ×" + x.return_qty) +
10787
+ _field("Replacement", x.replacement_sku + " ×" + x.replacement_qty) +
10788
+ _field("Replacement variant", x.replacement_variant_id) +
10789
+ _field("Reason", x.reason) +
10790
+ (x.reject_reason ? _field("Rejection reason", x.reject_reason) : "") +
10791
+ "</div>" +
10792
+ "<div class=\"panel\"><h3 class=\"subhead\">Timeline</h3>" +
10793
+ _field("Approved by", x.approver_id) +
10794
+ _field("Replacement shipped", _ts(x.shipped_at)) +
10795
+ (x.tracking_number ? _field("Tracking", (x.carrier ? x.carrier + " · " : "") + x.tracking_number) : "") +
10796
+ _field("Replacement delivered", _ts(x.delivered_at)) +
10797
+ _field("Return received", _ts(x.returned_at)) +
10798
+ _field("Closed", _ts(x.closed_at)) +
10799
+ "</div>" +
10800
+ "</div>" +
10801
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" + actions + "</div>" +
10802
+ "</section>";
10803
+ return _renderAdminShell(opts.shop_name, "Exchange " + String(x.id).slice(0, 8), body, "exchanges", opts.nav_available);
10804
+ }
10805
+
10432
10806
  // The review states an operator can filter the moderation queue by.
10433
10807
  var REVIEW_STATUS_FILTERS = ["pending", "published", "rejected"];
10434
10808
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.34",
2
+ "version": "0.3.36",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",