@blamejs/blamejs-shop 0.3.35 → 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 +2 -0
- package/lib/admin.js +375 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +450 -0
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +14 -2
- package/lib/vendor/blamejs/lib/crypto-field.js +69 -0
- package/lib/vendor/blamejs/lib/mail-store-fts.js +40 -18
- package/lib/vendor/blamejs/lib/mail-store.js +188 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.10.json +54 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +17 -6
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-field-derived-hash.test.js +44 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-store-fts.test.js +208 -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.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
|
+
|
|
11
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.
|
|
12
14
|
|
|
13
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.
|
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) + " → " + _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\">← 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
|
|
package/lib/asset-manifest.json
CHANGED