@blamejs/blamejs-shop 0.1.8 → 0.1.9
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/README.md +1 -1
- package/lib/admin.js +350 -99
- package/lib/returns.js +10 -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.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.9 (2026-05-25) — **Admin console — a returns moderation screen.** Returns join the admin console. `/admin/returns` is the RMA moderation queue: filter by status (pending, approved, received, refunded, rejected), and open a request to see its items, reason, customer notes, and linked order. From the detail page an operator works the return through its lifecycle — approve with a refund amount, mark the goods received, record the refund, or reject with a reason shown to the customer — with only the moves legal from the current status offered. An illegal move or a bad id is refused with a notice rather than an error. As with Products and Orders, every path content-negotiates: a bearer-token client gets the JSON API unchanged, a signed-in browser gets the HTML console. **Added:** *Returns moderation screen* — `/admin/returns` renders the RMA queue as a table (RMA code, order, reason, status, item count, refund amount, requested date) with status-filter chips, when opened in a signed-in browser; the same path serves the existing JSON list to a bearer-token client. Each return links to a detail page showing its line items, reason and customer notes, linked order, and refund details, with the legal next actions as forms — Approve takes a refund amount and currency, Reject takes a reason, Mark received and Refund are single-click. Each posts to its own endpoint and redirects (PRG); an unknown id renders a 404 page and an illegal or refused action redirects back with a notice, never a 500. · *returns.transitionsFrom* — `returns.transitionsFrom(status)` returns the moderation events legal from a given status as `{ on, to }`, derived from the same transition table the setters use — so the console's action buttons stay in lockstep with the return state machine. **Changed:** *Console nav gains Returns; the returns API content-negotiates* — The signed-in admin nav now includes Returns alongside Home, Dashboard, Products, Orders, Integrations, and Setup — shown only when the returns primitive is wired, so the link never points at an unmounted route. The `/admin/returns` list, detail, and approve / received / refund / reject endpoints now serve the HTML console to a signed-in browser while continuing to serve the JSON API to a bearer-token client unchanged. A request without the bearer token is no longer answered with a 401 on these paths — a browser GET receives the sign-in form and a write redirects to the console, matching the other console screens.
|
|
12
|
+
|
|
11
13
|
- v0.1.8 (2026-05-25) — **Admin console — an orders screen with full lifecycle control.** Orders join the admin console. `/admin/orders` lists recent orders newest-first with one-click status filters, and each order opens to its line items, totals, shipping address, and payment reference. From the detail page an operator drives the order through its lifecycle — mark paid, start fulfilment, mark shipped, mark delivered, cancel, refund — with only the moves that are legal from the current status offered as buttons; an illegal move is refused with a notice rather than an error. Refund moves money, so the console Refund button issues a real payment-provider refund (and only appears when a provider is wired and the order has a captured intent) before the order advances to refunded. As with Products, every path content-negotiates: a bearer-token client gets the JSON API unchanged, a signed-in browser gets the HTML console. **Added:** *Orders management screen* — `/admin/orders` renders recent orders as a table (order, status, item count, total, placed date) with status-filter chips, when opened in a signed-in browser; the same path serves a new JSON list to a bearer-token client. Each order links to a detail page showing its line items, subtotal / tax / shipping / total, shipping address, and linked payment intent, with the order's legal next transitions as action buttons that post back and redirect (PRG). The Refund button posts to the payment-refund flow — it issues the provider refund and then advances the order, so a console refund never marks an order refunded without moving the money; it appears only when a payment provider is wired and the order has a captured intent (partial refunds remain on the JSON API). A bad or unknown order id renders a 404 page, never a 500; an illegal or failed action redirects back with a notice and leaves the order unchanged. · *order.listRecent + order.transitionsFrom* — `order.listRecent({ limit, status })` returns recent orders across all customers (guest orders included), newest-first, optionally filtered to one status, with line items and shipping hydrated. `order.transitionsFrom(status)` returns the lifecycle moves legal from a given status as `{ on, to, label }` — both derived from a single order-FSM edge list, so the console actions stay in lockstep with the state machine. **Changed:** *Console nav gains Orders; dashboard orders link through* — The signed-in admin nav now includes Orders alongside Home, Dashboard, Products, Integrations, and Setup. The dashboard's recent-orders list links each row to its order detail.
|
|
12
14
|
|
|
13
15
|
- v0.1.7 (2026-05-25) — **Admin console — persistent navigation and a products screen.** The admin is now a cohesive, navigable web console rather than a set of disconnected pages. A persistent nav (Home, Dashboard, Products, Integrations, Setup) runs across every signed-in page, and Products is the first full management screen: browse the catalog, create a product, and archive or restore one — all from the browser. The JSON API is unchanged: an endpoint serves the HTML console to a signed-in browser and JSON to a bearer-token client, so tooling keeps working exactly as before. **Added:** *Console navigation* — Every signed-in admin page shares a top nav with the current section highlighted, so the dashboard, products, integrations, and setup wizard are one console. The shell is the same brand-matched layout as the dashboard. · *Products management screen* — `/admin/products` renders the catalog as a table (title, slug, status) with Archive / Restore actions and a New-product form, when opened in a signed-in browser. The same path returns the existing JSON list to a bearer-token client; create, archive, and restore content-negotiate the same way (browser form → redirect; bad input re-renders with a notice, never a 500). The product create / archive / restore JSON API is unchanged.
|
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
72
72
|
| **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
|
|
73
73
|
| **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. |
|
|
74
74
|
| **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
|
|
75
|
-
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel
|
|
75
|
+
| **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. |
|
|
76
76
|
| **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
|
|
77
77
|
| **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
|
|
78
78
|
|
package/lib/admin.js
CHANGED
|
@@ -226,6 +226,11 @@ function mount(router, deps) {
|
|
|
226
226
|
var reviews = deps.reviews || null; // moderation endpoints disabled when absent
|
|
227
227
|
var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
|
|
228
228
|
|
|
229
|
+
// Which optional console sections are wired — gates their nav links so a
|
|
230
|
+
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
231
|
+
// into every authed render call as `nav_available`.
|
|
232
|
+
var navAvailable = { returns: !!returns, reviews: !!reviews };
|
|
233
|
+
|
|
229
234
|
try { _b().audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
230
235
|
|
|
231
236
|
var W = function (auditAction, h) {
|
|
@@ -277,7 +282,7 @@ function mount(router, deps) {
|
|
|
277
282
|
if (e instanceof TypeError || e.code === "CATALOG_DUPLICATE" || /slug|exists|duplicate/i.test(e.message || "")) {
|
|
278
283
|
var page = await catalog.products.list({ limit: 100 });
|
|
279
284
|
return _sendHtml(res, 400, renderAdminProducts({
|
|
280
|
-
shop_name: deps.shop_name, products: page.rows || [],
|
|
285
|
+
shop_name: deps.shop_name, nav_available: navAvailable, products: page.rows || [],
|
|
281
286
|
notice: (e && e.message) || "Couldn't create that product.",
|
|
282
287
|
}));
|
|
283
288
|
}
|
|
@@ -321,7 +326,7 @@ function mount(router, deps) {
|
|
|
321
326
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
322
327
|
var created = !!(url && url.searchParams.get("created"));
|
|
323
328
|
var page = await catalog.products.list({ limit: 100 });
|
|
324
|
-
_sendHtml(res, 200, renderAdminProducts({ shop_name: deps.shop_name, products: page.rows || [], created: created }));
|
|
329
|
+
_sendHtml(res, 200, renderAdminProducts({ shop_name: deps.shop_name, nav_available: navAvailable, products: page.rows || [], created: created }));
|
|
325
330
|
},
|
|
326
331
|
));
|
|
327
332
|
|
|
@@ -622,7 +627,7 @@ function mount(router, deps) {
|
|
|
622
627
|
}
|
|
623
628
|
var list = await order.listRecent({ status: status || undefined, limit: 100 });
|
|
624
629
|
_sendHtml(res, 200, renderAdminOrders({
|
|
625
|
-
shop_name: deps.shop_name, orders: list.rows || [],
|
|
630
|
+
shop_name: deps.shop_name, nav_available: navAvailable, orders: list.rows || [],
|
|
626
631
|
status: status, notice: notice,
|
|
627
632
|
}));
|
|
628
633
|
},
|
|
@@ -640,11 +645,12 @@ function mount(router, deps) {
|
|
|
640
645
|
try { o = await order.get(req.params.id); }
|
|
641
646
|
catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
|
|
642
647
|
if (!o) return _sendHtml(res, 404, renderAdminOrders({
|
|
643
|
-
shop_name: deps.shop_name, orders: [], notice: "Order not found.",
|
|
648
|
+
shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
|
|
644
649
|
}));
|
|
645
650
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
646
651
|
_sendHtml(res, 200, renderAdminOrder({
|
|
647
652
|
shop_name: deps.shop_name,
|
|
653
|
+
nav_available: navAvailable,
|
|
648
654
|
order: o,
|
|
649
655
|
transitions: order.transitionsFrom(o.status),
|
|
650
656
|
// Refund moves money, so the console only offers it when a payment
|
|
@@ -819,94 +825,182 @@ function mount(router, deps) {
|
|
|
819
825
|
return null;
|
|
820
826
|
}
|
|
821
827
|
|
|
822
|
-
router.get("/admin/returns",
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
828
|
+
router.get("/admin/returns", _pageOrApi(true,
|
|
829
|
+
R(async function (req, res) {
|
|
830
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
831
|
+
var status = (url && url.searchParams.get("status")) || "pending";
|
|
832
|
+
var cursor = url && url.searchParams.get("cursor");
|
|
833
|
+
var limitS = url && url.searchParams.get("limit");
|
|
834
|
+
var limit = limitS == null ? undefined : parseInt(limitS, 10);
|
|
835
|
+
var page = await returns.listByStatus(status, { cursor: cursor || undefined, limit: limit });
|
|
836
|
+
_json(res, 200, page);
|
|
837
|
+
}),
|
|
838
|
+
async function (req, res) {
|
|
839
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
840
|
+
var status = (url && url.searchParams.get("status")) || "pending";
|
|
841
|
+
var notice = null, rows = [];
|
|
842
|
+
// A bad ?status= (not one of the RMA states) raises a TypeError
|
|
843
|
+
// from listByStatus — fall back to pending with a notice rather
|
|
844
|
+
// than erroring the page.
|
|
845
|
+
try {
|
|
846
|
+
var page = await returns.listByStatus(status, { limit: 100 });
|
|
847
|
+
rows = page.rows || [];
|
|
848
|
+
} catch (e) {
|
|
849
|
+
if (!(e instanceof TypeError)) throw e;
|
|
850
|
+
status = "pending"; notice = "Unknown status filter — showing pending returns.";
|
|
851
|
+
rows = (await returns.listByStatus("pending", { limit: 100 })).rows || [];
|
|
852
|
+
}
|
|
853
|
+
_sendHtml(res, 200, renderAdminReturns({
|
|
854
|
+
shop_name: deps.shop_name, nav_available: navAvailable, returns: rows, status: status, notice: notice,
|
|
855
|
+
}));
|
|
856
|
+
},
|
|
857
|
+
));
|
|
831
858
|
|
|
832
|
-
router.get("/admin/returns/:id",
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
859
|
+
router.get("/admin/returns/:id", _pageOrApi(true,
|
|
860
|
+
R(async function (req, res) {
|
|
861
|
+
var rma;
|
|
862
|
+
try {
|
|
863
|
+
rma = await returns.get(req.params.id);
|
|
864
|
+
} catch (e) {
|
|
865
|
+
// A non-UUID :id raises a guardUuid TypeError — surface it as a
|
|
866
|
+
// 404 (the route is a defensive request-shape reader, never a
|
|
867
|
+
// 500). Re-raise anything that isn't the bad-id shape so the
|
|
868
|
+
// wrapper's generic handling applies.
|
|
869
|
+
if (e instanceof TypeError) return _problem(res, 404, "return-not-found");
|
|
870
|
+
throw e;
|
|
871
|
+
}
|
|
872
|
+
if (!rma) return _problem(res, 404, "return-not-found");
|
|
873
|
+
_json(res, 200, rma);
|
|
874
|
+
}),
|
|
875
|
+
async function (req, res) {
|
|
876
|
+
var rma;
|
|
877
|
+
try { rma = await returns.get(req.params.id); }
|
|
878
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; rma = null; }
|
|
879
|
+
if (!rma) return _sendHtml(res, 404, renderAdminReturns({
|
|
880
|
+
shop_name: deps.shop_name, nav_available: navAvailable, returns: [], status: "pending", notice: "Return not found.",
|
|
881
|
+
}));
|
|
882
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
883
|
+
_sendHtml(res, 200, renderAdminReturn({
|
|
884
|
+
shop_name: deps.shop_name,
|
|
885
|
+
nav_available: navAvailable,
|
|
886
|
+
rma: rma,
|
|
887
|
+
transitions: returns.transitionsFrom(rma.status),
|
|
888
|
+
moved: url && url.searchParams.get("moved"),
|
|
889
|
+
notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this return." : null,
|
|
890
|
+
}));
|
|
891
|
+
},
|
|
892
|
+
));
|
|
847
893
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
894
|
+
// The browser side of an RMA action: run `opFn(id, body)`, then PRG
|
|
895
|
+
// back to the detail. A bad id / shape (TypeError) or an FSM refusal /
|
|
896
|
+
// not-found (mapped by _returnsClientError) becomes a notice on the
|
|
897
|
+
// detail, never a 500; anything else propagates.
|
|
898
|
+
function _returnAction(jsonHandler, auditEvent, opFn) {
|
|
899
|
+
return _pageOrApi(false, jsonHandler, async function (req, res) {
|
|
900
|
+
var id = req.params.id;
|
|
901
|
+
try { await opFn(id, req.body || {}); }
|
|
902
|
+
catch (e) {
|
|
903
|
+
if (e instanceof TypeError || _returnsClientError(e)) {
|
|
904
|
+
return _redirect(res, "/admin/returns/" + encodeURIComponent(id) + "?err=1");
|
|
905
|
+
}
|
|
906
|
+
throw e;
|
|
907
|
+
}
|
|
908
|
+
_b().audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
|
|
909
|
+
_redirect(res, "/admin/returns/" + encodeURIComponent(id) + "?moved=1");
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
router.post("/admin/returns/:id/approve", _returnAction(
|
|
914
|
+
W("return.approve", async function (req, res) {
|
|
915
|
+
var body = req.body || {};
|
|
916
|
+
var rma;
|
|
917
|
+
try {
|
|
918
|
+
rma = await returns.approve(req.params.id, {
|
|
919
|
+
refund_amount_minor: body.refund_amount_minor,
|
|
920
|
+
refund_currency: body.refund_currency,
|
|
921
|
+
operator_notes: body.operator_notes,
|
|
922
|
+
});
|
|
923
|
+
} catch (e) {
|
|
924
|
+
var ce = _returnsClientError(e);
|
|
925
|
+
if (ce) return _problem(res, ce.status, ce.slug, e.message);
|
|
926
|
+
throw e;
|
|
927
|
+
}
|
|
928
|
+
_json(res, 200, rma);
|
|
929
|
+
return rma;
|
|
930
|
+
}),
|
|
931
|
+
"return.approve",
|
|
932
|
+
function (id, body) {
|
|
933
|
+
// Browser form fields arrive as strings. Convert ONLY a clean
|
|
934
|
+
// non-negative integer to a number; anything else (e.g. "4999usd",
|
|
935
|
+
// "1e3", "") passes through unchanged so returns.approve's
|
|
936
|
+
// _nonNegInt rejects it (→ notice via _returnAction) instead of
|
|
937
|
+
// parseInt silently truncating garbage onto a money field.
|
|
938
|
+
var raw = body.refund_amount_minor;
|
|
939
|
+
var amount = (typeof raw === "string" && /^\d+$/.test(raw.trim())) ? Number(raw.trim()) : raw;
|
|
940
|
+
return returns.approve(id, {
|
|
941
|
+
refund_amount_minor: amount,
|
|
942
|
+
refund_currency: body.refund_currency || undefined,
|
|
943
|
+
operator_notes: body.operator_notes || undefined,
|
|
856
944
|
});
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
if (ce) return _problem(res, ce.status, ce.slug, e.message);
|
|
860
|
-
throw e;
|
|
861
|
-
}
|
|
862
|
-
_json(res, 200, rma);
|
|
863
|
-
return rma;
|
|
864
|
-
}));
|
|
945
|
+
},
|
|
946
|
+
));
|
|
865
947
|
|
|
866
|
-
router.post("/admin/returns/:id/received",
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
948
|
+
router.post("/admin/returns/:id/received", _returnAction(
|
|
949
|
+
W("return.received", async function (req, res) {
|
|
950
|
+
var body = req.body || {};
|
|
951
|
+
var rma;
|
|
952
|
+
try {
|
|
953
|
+
rma = await returns.markReceived(req.params.id, { operator_notes: body.operator_notes });
|
|
954
|
+
} catch (e) {
|
|
955
|
+
var ce = _returnsClientError(e);
|
|
956
|
+
if (ce) return _problem(res, ce.status, ce.slug, e.message);
|
|
957
|
+
throw e;
|
|
958
|
+
}
|
|
959
|
+
_json(res, 200, rma);
|
|
960
|
+
return rma;
|
|
961
|
+
}),
|
|
962
|
+
"return.received",
|
|
963
|
+
function (id, body) { return returns.markReceived(id, { operator_notes: body.operator_notes || undefined }); },
|
|
964
|
+
));
|
|
879
965
|
|
|
880
|
-
router.post("/admin/returns/:id/refund",
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
966
|
+
router.post("/admin/returns/:id/refund", _returnAction(
|
|
967
|
+
W("return.refund", async function (req, res) {
|
|
968
|
+
var body = req.body || {};
|
|
969
|
+
var rma;
|
|
970
|
+
try {
|
|
971
|
+
rma = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
|
|
972
|
+
} catch (e) {
|
|
973
|
+
var ce = _returnsClientError(e);
|
|
974
|
+
if (ce) return _problem(res, ce.status, ce.slug, e.message);
|
|
975
|
+
throw e;
|
|
976
|
+
}
|
|
977
|
+
_json(res, 200, rma);
|
|
978
|
+
return rma;
|
|
979
|
+
}),
|
|
980
|
+
"return.refund",
|
|
981
|
+
function (id, body) { return returns.refund(id, { operator_notes: body.operator_notes || undefined }); },
|
|
982
|
+
));
|
|
893
983
|
|
|
894
|
-
router.post("/admin/returns/:id/reject",
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
984
|
+
router.post("/admin/returns/:id/reject", _returnAction(
|
|
985
|
+
W("return.reject", async function (req, res) {
|
|
986
|
+
var body = req.body || {};
|
|
987
|
+
var rma;
|
|
988
|
+
try {
|
|
989
|
+
rma = await returns.reject(req.params.id, {
|
|
990
|
+
rejected_reason: body.rejected_reason,
|
|
991
|
+
operator_notes: body.operator_notes,
|
|
992
|
+
});
|
|
993
|
+
} catch (e) {
|
|
994
|
+
var ce = _returnsClientError(e);
|
|
995
|
+
if (ce) return _problem(res, ce.status, ce.slug, e.message);
|
|
996
|
+
throw e;
|
|
997
|
+
}
|
|
998
|
+
_json(res, 200, rma);
|
|
999
|
+
return rma;
|
|
1000
|
+
}),
|
|
1001
|
+
"return.reject",
|
|
1002
|
+
function (id, body) { return returns.reject(id, { rejected_reason: body.rejected_reason, operator_notes: body.operator_notes || undefined }); },
|
|
1003
|
+
));
|
|
910
1004
|
}
|
|
911
1005
|
|
|
912
1006
|
// ---- config ---------------------------------------------------------
|
|
@@ -1073,6 +1167,7 @@ function mount(router, deps) {
|
|
|
1073
1167
|
top_skus: top,
|
|
1074
1168
|
recent: recent,
|
|
1075
1169
|
shop_name: (deps.shop_name || "blamejs.shop"),
|
|
1170
|
+
nav_available: navAvailable,
|
|
1076
1171
|
}));
|
|
1077
1172
|
});
|
|
1078
1173
|
}
|
|
@@ -1166,6 +1261,7 @@ function mount(router, deps) {
|
|
|
1166
1261
|
_sendHtml(res, 200, renderAdminLanding({
|
|
1167
1262
|
shop_name: deps.shop_name,
|
|
1168
1263
|
setup_complete: await _setupComplete(),
|
|
1264
|
+
nav_available: navAvailable,
|
|
1169
1265
|
}));
|
|
1170
1266
|
});
|
|
1171
1267
|
|
|
@@ -1202,6 +1298,7 @@ function mount(router, deps) {
|
|
|
1202
1298
|
_sendHtml(res, 200, renderAdminIntegrations({
|
|
1203
1299
|
shop_name: deps.shop_name,
|
|
1204
1300
|
status: deps.integrations || {},
|
|
1301
|
+
nav_available: navAvailable,
|
|
1205
1302
|
}));
|
|
1206
1303
|
});
|
|
1207
1304
|
|
|
@@ -1217,7 +1314,7 @@ function mount(router, deps) {
|
|
|
1217
1314
|
values.currency = await config.get("shop.currency", "");
|
|
1218
1315
|
values.support_url = await config.get("shop.support_url", "");
|
|
1219
1316
|
} catch (_e) { /* unconfigured — render an empty form */ }
|
|
1220
|
-
_sendHtml(res, 200, renderAdminSetup({ shop_name: deps.shop_name, values: values, saved: saved }));
|
|
1317
|
+
_sendHtml(res, 200, renderAdminSetup({ shop_name: deps.shop_name, values: values, saved: saved, nav_available: navAvailable }));
|
|
1221
1318
|
});
|
|
1222
1319
|
|
|
1223
1320
|
router.post("/admin/setup", async function (req, res) {
|
|
@@ -1244,7 +1341,7 @@ function mount(router, deps) {
|
|
|
1244
1341
|
if (!u || (u.protocol !== "https:" && u.protocol !== "http:")) notice = "Support URL must be a valid http(s) URL.";
|
|
1245
1342
|
}
|
|
1246
1343
|
if (notice) {
|
|
1247
|
-
return _sendHtml(res, 400, renderAdminSetup({ shop_name: deps.shop_name, values: values, notice: notice }));
|
|
1344
|
+
return _sendHtml(res, 400, renderAdminSetup({ shop_name: deps.shop_name, values: values, notice: notice, nav_available: navAvailable }));
|
|
1248
1345
|
}
|
|
1249
1346
|
try {
|
|
1250
1347
|
await config.put("shop.name", values.shop_name);
|
|
@@ -1254,7 +1351,7 @@ function mount(router, deps) {
|
|
|
1254
1351
|
await config.put("setup.completed", true);
|
|
1255
1352
|
} catch (e) {
|
|
1256
1353
|
return _sendHtml(res, 500, renderAdminSetup({
|
|
1257
|
-
shop_name: deps.shop_name, values: values,
|
|
1354
|
+
shop_name: deps.shop_name, values: values, nav_available: navAvailable,
|
|
1258
1355
|
notice: "Couldn't save — " + ((e && e.message) || "please try again."),
|
|
1259
1356
|
}));
|
|
1260
1357
|
}
|
|
@@ -1343,6 +1440,9 @@ var DASHBOARD_LAYOUT =
|
|
|
1343
1440
|
" .order-totals { width:100%; }\n" +
|
|
1344
1441
|
" .order-totals td { padding:.3rem 0; }\n" +
|
|
1345
1442
|
" .order-actions { display:flex; flex-wrap:wrap; gap:.6rem; }\n" +
|
|
1443
|
+
" .return-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1.25rem; }\n" +
|
|
1444
|
+
" .return-action { border:1px solid var(--hair); border-radius:8px; padding:1rem; }\n" +
|
|
1445
|
+
" .return-action h4 { margin:0 0 .6rem; font-size:.9rem; }\n" +
|
|
1346
1446
|
" .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
|
|
1347
1447
|
" .nav-card { display:block; background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.4rem; text-decoration:none; color:var(--ink); }\n" +
|
|
1348
1448
|
" .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
|
|
@@ -1501,6 +1601,7 @@ function renderDashboard(opts) {
|
|
|
1501
1601
|
"Window: last 30 days (operator-tunable via ?since=&until=)",
|
|
1502
1602
|
body,
|
|
1503
1603
|
"dashboard",
|
|
1604
|
+
opts.nav_available,
|
|
1504
1605
|
);
|
|
1505
1606
|
}
|
|
1506
1607
|
|
|
@@ -1514,30 +1615,40 @@ function _statCard(label, value, accent) {
|
|
|
1514
1615
|
// Console nav — one entry per HTML console screen. `active` highlights
|
|
1515
1616
|
// the current page; `null`/`false` (unauthenticated pages like the
|
|
1516
1617
|
// sign-in form) renders no nav at all.
|
|
1618
|
+
// Items carrying `requires` map to an optional `deps.<key>` primitive —
|
|
1619
|
+
// their routes only mount when that dep is wired, so the nav link is shown
|
|
1620
|
+
// only when `available[key]` is truthy (otherwise it would point at an
|
|
1621
|
+
// unregistered route). Items without `requires` are always present.
|
|
1517
1622
|
var ADMIN_NAV_ITEMS = [
|
|
1518
1623
|
{ key: "home", href: "/admin", label: "Home" },
|
|
1519
1624
|
{ key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
|
|
1520
1625
|
{ key: "products", href: "/admin/products", label: "Products" },
|
|
1521
1626
|
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
1627
|
+
{ key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
|
|
1522
1628
|
{ key: "integrations", href: "/admin/integrations", label: "Integrations" },
|
|
1523
1629
|
{ key: "setup", href: "/admin/setup", label: "Setup" },
|
|
1524
1630
|
];
|
|
1525
|
-
|
|
1631
|
+
// `available` is a map of optional-section key → truthy when wired. When
|
|
1632
|
+
// omitted (a render fn called without it), optional items are shown — the
|
|
1633
|
+
// route handlers always pass it, so a real deployment gates correctly.
|
|
1634
|
+
function _adminNav(active, available) {
|
|
1526
1635
|
if (active === null || active === undefined || active === false) return "";
|
|
1527
|
-
var links = ADMIN_NAV_ITEMS.
|
|
1636
|
+
var links = ADMIN_NAV_ITEMS.filter(function (it) {
|
|
1637
|
+
return !it.requires || !available || available[it.requires];
|
|
1638
|
+
}).map(function (it) {
|
|
1528
1639
|
return "<a href=\"" + it.href + "\"" + (it.key === active ? " class=\"active\"" : "") + ">" +
|
|
1529
1640
|
_htmlEscape(it.label) + "</a>";
|
|
1530
1641
|
}).join("");
|
|
1531
1642
|
return "<nav class=\"admin-nav\"><div class=\"admin-nav__inner\">" + links + "</div></nav>";
|
|
1532
1643
|
}
|
|
1533
1644
|
|
|
1534
|
-
function _renderAdminShell(shopName, subtitle, bodyHtml, active) {
|
|
1645
|
+
function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
|
|
1535
1646
|
return _renderTemplate(DASHBOARD_LAYOUT, {
|
|
1536
1647
|
shop_name: shopName || "blamejs.shop",
|
|
1537
1648
|
window_label: subtitle || "",
|
|
1538
1649
|
nav: "RAW_NAV",
|
|
1539
1650
|
body: "RAW_BODY",
|
|
1540
|
-
}).replace("RAW_NAV", _adminNav(active)).replace("RAW_BODY", bodyHtml);
|
|
1651
|
+
}).replace("RAW_NAV", _adminNav(active, available)).replace("RAW_BODY", bodyHtml);
|
|
1541
1652
|
}
|
|
1542
1653
|
|
|
1543
1654
|
function renderAdminLogin(opts) {
|
|
@@ -1574,7 +1685,7 @@ function renderAdminLanding(opts) {
|
|
|
1574
1685
|
"</div>" +
|
|
1575
1686
|
"<div class=\"actions-row\"><form method=\"post\" action=\"/admin/logout\"><button type=\"submit\" class=\"btn btn--ghost\">Sign out</button></form></div>" +
|
|
1576
1687
|
"</section>";
|
|
1577
|
-
return _renderAdminShell(opts.shop_name, "", body, "home");
|
|
1688
|
+
return _renderAdminShell(opts.shop_name, "", body, "home", opts.nav_available);
|
|
1578
1689
|
}
|
|
1579
1690
|
|
|
1580
1691
|
function _setupField(label, name, value, type, hint, extra) {
|
|
@@ -1603,7 +1714,7 @@ function renderAdminSetup(opts) {
|
|
|
1603
1714
|
"<a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
|
|
1604
1715
|
"</form>" +
|
|
1605
1716
|
"</section>";
|
|
1606
|
-
return _renderAdminShell(opts.shop_name, "Setup", body, "setup");
|
|
1717
|
+
return _renderAdminShell(opts.shop_name, "Setup", body, "setup", opts.nav_available);
|
|
1607
1718
|
}
|
|
1608
1719
|
|
|
1609
1720
|
// Each integration is off until the operator supplies its credentials.
|
|
@@ -1655,7 +1766,7 @@ function renderAdminIntegrations(opts) {
|
|
|
1655
1766
|
"<p class=\"meta\" style=\"margin-top:1.25rem;\">Sign in with Apple and PayPal are planned. “Sign in with Shop” / Shop Pay isn't available to a self-hosted store. See the README “Optional integrations” section for full setup steps.</p>" +
|
|
1656
1767
|
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
|
|
1657
1768
|
"</section>";
|
|
1658
|
-
return _renderAdminShell(opts.shop_name, "Integrations", body, "integrations");
|
|
1769
|
+
return _renderAdminShell(opts.shop_name, "Integrations", body, "integrations", opts.nav_available);
|
|
1659
1770
|
}
|
|
1660
1771
|
|
|
1661
1772
|
function renderAdminProducts(opts) {
|
|
@@ -1689,7 +1800,7 @@ function renderAdminProducts(opts) {
|
|
|
1689
1800
|
"</form>" +
|
|
1690
1801
|
"</div>" +
|
|
1691
1802
|
"</section>";
|
|
1692
|
-
return _renderAdminShell(opts.shop_name, "Products", body, "products");
|
|
1803
|
+
return _renderAdminShell(opts.shop_name, "Products", body, "products", opts.nav_available);
|
|
1693
1804
|
}
|
|
1694
1805
|
|
|
1695
1806
|
// created_at / updated_at are epoch-ms numbers (order._now()); render a
|
|
@@ -1734,7 +1845,7 @@ function renderAdminOrders(opts) {
|
|
|
1734
1845
|
: "<p class=\"empty\">No orders" + (active ? " with status “" + _htmlEscape(active) + "”" : " yet") + ".</p>";
|
|
1735
1846
|
|
|
1736
1847
|
var body = "<section><h2>Orders</h2>" + notice + chips + table + "</section>";
|
|
1737
|
-
return _renderAdminShell(opts.shop_name, "Orders", body, "orders");
|
|
1848
|
+
return _renderAdminShell(opts.shop_name, "Orders", body, "orders", opts.nav_available);
|
|
1738
1849
|
}
|
|
1739
1850
|
|
|
1740
1851
|
function renderAdminOrder(opts) {
|
|
@@ -1814,7 +1925,145 @@ function renderAdminOrder(opts) {
|
|
|
1814
1925
|
"<div class=\"order-actions\">" + actions + "</div>" +
|
|
1815
1926
|
"</div>" +
|
|
1816
1927
|
"</section>";
|
|
1817
|
-
return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders");
|
|
1928
|
+
return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders", opts.nav_available);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// The RMA states an operator can filter the returns queue by — drives the
|
|
1932
|
+
// filter chips, lifecycle order then terminal.
|
|
1933
|
+
var RETURN_STATUS_FILTERS = ["pending", "approved", "received", "refunded", "rejected"];
|
|
1934
|
+
|
|
1935
|
+
// status → status-pill CSS class. The pill stylesheet has paid/fulfilling/
|
|
1936
|
+
// shipped/delivered (green), refunded, cancelled, pending — map the RMA
|
|
1937
|
+
// states onto the closest existing colour without new CSS.
|
|
1938
|
+
function _returnPillClass(status) {
|
|
1939
|
+
if (status === "approved" || status === "received") return "shipped"; // in-progress green
|
|
1940
|
+
if (status === "refunded") return "refunded";
|
|
1941
|
+
if (status === "rejected") return "cancelled";
|
|
1942
|
+
return "pending";
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function renderAdminReturns(opts) {
|
|
1946
|
+
opts = opts || {};
|
|
1947
|
+
var rmas = opts.returns || [];
|
|
1948
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
1949
|
+
var active = opts.status || "pending";
|
|
1950
|
+
|
|
1951
|
+
var chips = "<div class=\"order-filters\">" +
|
|
1952
|
+
RETURN_STATUS_FILTERS.map(function (s) {
|
|
1953
|
+
return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/returns?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
|
|
1954
|
+
}).join("") +
|
|
1955
|
+
"</div>";
|
|
1956
|
+
|
|
1957
|
+
var rows = rmas.map(function (r) {
|
|
1958
|
+
var items = (r.lines || []).reduce(function (n, l) { return n + (l.qty || 0); }, 0);
|
|
1959
|
+
var amount = r.refund_amount_minor != null ? pricing.format(r.refund_amount_minor, r.refund_currency || "USD") : "—";
|
|
1960
|
+
return "<tr>" +
|
|
1961
|
+
"<td><a class=\"order-id\" href=\"/admin/returns/" + _htmlEscape(r.id) + "\">" + _htmlEscape(r.rma_code || r.id.slice(0, 8)) + "</a></td>" +
|
|
1962
|
+
"<td><span class=\"order-id\">" + _htmlEscape(String(r.order_id).slice(0, 8)) + "</span></td>" +
|
|
1963
|
+
"<td>" + _htmlEscape(r.reason) + "</td>" +
|
|
1964
|
+
"<td><span class=\"status-pill " + _returnPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></td>" +
|
|
1965
|
+
"<td class=\"num\">" + _htmlEscape(String(items)) + "</td>" +
|
|
1966
|
+
"<td class=\"num\">" + _htmlEscape(amount) + "</td>" +
|
|
1967
|
+
"<td>" + _htmlEscape(_fmtDate(r.created_at)) + "</td>" +
|
|
1968
|
+
"</tr>";
|
|
1969
|
+
}).join("");
|
|
1970
|
+
|
|
1971
|
+
var table = rmas.length
|
|
1972
|
+
? "<div class=\"panel\"><table><thead><tr><th>RMA</th><th>Order</th><th>Reason</th><th>Status</th><th class=\"num\">Items</th><th class=\"num\">Refund</th><th>Requested</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
|
|
1973
|
+
: "<p class=\"empty\">No “" + _htmlEscape(active) + "” returns.</p>";
|
|
1974
|
+
|
|
1975
|
+
var body = "<section><h2>Returns</h2>" + notice + chips + table + "</section>";
|
|
1976
|
+
return _renderAdminShell(opts.shop_name, "Returns", body, "returns", opts.nav_available);
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
function renderAdminReturn(opts) {
|
|
1980
|
+
opts = opts || {};
|
|
1981
|
+
var r = opts.rma;
|
|
1982
|
+
var transitions = opts.transitions || [];
|
|
1983
|
+
var moved = opts.moved ? "<div class=\"banner banner--ok\">Return updated.</div>" : "";
|
|
1984
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
1985
|
+
var has = function (on) { return transitions.some(function (t) { return t.on === on; }); };
|
|
1986
|
+
|
|
1987
|
+
var lineRows = (r.lines || []).map(function (l) {
|
|
1988
|
+
return "<tr><td>" + _htmlEscape(l.sku) + "</td><td class=\"num\">" + _htmlEscape(String(l.qty)) + "</td>" +
|
|
1989
|
+
"<td>" + _htmlEscape(l.reason || "—") + "</td></tr>";
|
|
1990
|
+
}).join("");
|
|
1991
|
+
var linesTable = (r.lines && r.lines.length)
|
|
1992
|
+
? "<table><thead><tr><th>SKU</th><th class=\"num\">Qty</th><th>Reason</th></tr></thead><tbody>" + lineRows + "</tbody></table>"
|
|
1993
|
+
: "<p class=\"empty\">No line items recorded.</p>";
|
|
1994
|
+
|
|
1995
|
+
function _field(label, value) {
|
|
1996
|
+
return "<p><span class=\"meta\">" + _htmlEscape(label) + "</span><br>" + (value ? _htmlEscape(String(value)) : "<span class=\"meta\">—</span>") + "</p>";
|
|
1997
|
+
}
|
|
1998
|
+
var refundShown = r.refund_amount_minor != null ? pricing.format(r.refund_amount_minor, r.refund_currency || "USD") : null;
|
|
1999
|
+
|
|
2000
|
+
// Action forms keyed to the legal transitions. Approve + reject need
|
|
2001
|
+
// input (refund amount / rejection reason); mark-received + refund are
|
|
2002
|
+
// single-click. Each posts to its own endpoint and redirects (PRG).
|
|
2003
|
+
var actionBlocks = [];
|
|
2004
|
+
if (has("approve")) {
|
|
2005
|
+
actionBlocks.push(
|
|
2006
|
+
"<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/approve\" class=\"return-action\">" +
|
|
2007
|
+
"<h4>Approve</h4>" +
|
|
2008
|
+
_setupField("Refund amount (minor units)", "refund_amount_minor", "", "number", "e.g. 4999 for $49.99.", " min=\"0\" required") +
|
|
2009
|
+
_setupField("Refund currency", "refund_currency", r.refund_currency || "USD", "text", "3-letter ISO 4217.", " maxlength=\"3\" style=\"text-transform:uppercase;max-width:8rem;\"") +
|
|
2010
|
+
_setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
|
|
2011
|
+
"<button class=\"btn\" type=\"submit\">Approve return</button>" +
|
|
2012
|
+
"</form>");
|
|
2013
|
+
}
|
|
2014
|
+
if (has("markReceived")) {
|
|
2015
|
+
actionBlocks.push(
|
|
2016
|
+
"<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/received\" class=\"return-action\">" +
|
|
2017
|
+
"<h4>Mark received</h4><p class=\"meta\">Confirm the returned goods arrived.</p>" +
|
|
2018
|
+
_setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
|
|
2019
|
+
"<button class=\"btn\" type=\"submit\">Mark received</button>" +
|
|
2020
|
+
"</form>");
|
|
2021
|
+
}
|
|
2022
|
+
if (has("refund")) {
|
|
2023
|
+
actionBlocks.push(
|
|
2024
|
+
"<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/refund\" class=\"return-action\">" +
|
|
2025
|
+
"<h4>Refund</h4><p class=\"meta\">Record the refund" + (refundShown ? " of " + _htmlEscape(refundShown) : "") + " for this return.</p>" +
|
|
2026
|
+
_setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
|
|
2027
|
+
"<button class=\"btn\" type=\"submit\">Refund</button>" +
|
|
2028
|
+
"</form>");
|
|
2029
|
+
}
|
|
2030
|
+
if (has("reject")) {
|
|
2031
|
+
actionBlocks.push(
|
|
2032
|
+
"<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/reject\" class=\"return-action\">" +
|
|
2033
|
+
"<h4>Reject</h4>" +
|
|
2034
|
+
_setupField("Reason for rejection", "rejected_reason", "", "text", "Shown to the customer.", " maxlength=\"500\" required") +
|
|
2035
|
+
_setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
|
|
2036
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Reject return</button>" +
|
|
2037
|
+
"</form>");
|
|
2038
|
+
}
|
|
2039
|
+
var actions = actionBlocks.length
|
|
2040
|
+
? "<div class=\"return-actions\">" + actionBlocks.join("") + "</div>"
|
|
2041
|
+
: "<span class=\"meta\">This return is in a final state — no further changes.</span>";
|
|
2042
|
+
|
|
2043
|
+
var body =
|
|
2044
|
+
"<section style=\"max-width:48rem;\">" +
|
|
2045
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/returns\">← Returns</a></div>" +
|
|
2046
|
+
"<h2>Return <code class=\"order-id\">" + _htmlEscape(r.rma_code || r.id.slice(0, 8)) + "</code> " +
|
|
2047
|
+
"<span class=\"status-pill " + _returnPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></h2>" +
|
|
2048
|
+
"<p class=\"meta\">Requested " + _htmlEscape(_fmtDate(r.created_at)) +
|
|
2049
|
+
" · order <a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(r.order_id) + "\">" + _htmlEscape(String(r.order_id).slice(0, 8)) + "</a></p>" +
|
|
2050
|
+
moved + notice +
|
|
2051
|
+
"<div class=\"two-col\">" +
|
|
2052
|
+
"<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Items</h3>" + linesTable + "</div>" +
|
|
2053
|
+
"<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Details</h3>" +
|
|
2054
|
+
_field("Reason", r.reason) +
|
|
2055
|
+
_field("Customer detail", r.reason_detail) +
|
|
2056
|
+
_field("Customer notes", r.customer_notes) +
|
|
2057
|
+
(refundShown ? _field("Refund", refundShown) : "") +
|
|
2058
|
+
(r.operator_notes ? _field("Operator notes", r.operator_notes) : "") +
|
|
2059
|
+
(r.rejected_reason ? _field("Rejection reason", r.rejected_reason) : "") +
|
|
2060
|
+
"</div>" +
|
|
2061
|
+
"</div>" +
|
|
2062
|
+
"<div class=\"panel\" style=\"margin-top:1.5rem;\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Actions</h3>" +
|
|
2063
|
+
actions +
|
|
2064
|
+
"</div>" +
|
|
2065
|
+
"</section>";
|
|
2066
|
+
return _renderAdminShell(opts.shop_name, "Return " + (r.rma_code || r.id.slice(0, 8)), body, "returns", opts.nav_available);
|
|
1818
2067
|
}
|
|
1819
2068
|
|
|
1820
2069
|
module.exports = {
|
|
@@ -1828,4 +2077,6 @@ module.exports = {
|
|
|
1828
2077
|
renderAdminProducts: renderAdminProducts,
|
|
1829
2078
|
renderAdminOrders: renderAdminOrders,
|
|
1830
2079
|
renderAdminOrder: renderAdminOrder,
|
|
2080
|
+
renderAdminReturns: renderAdminReturns,
|
|
2081
|
+
renderAdminReturn: renderAdminReturn,
|
|
1831
2082
|
};
|
package/lib/returns.js
CHANGED
|
@@ -429,6 +429,16 @@ function create(opts) {
|
|
|
429
429
|
return await _hydrate(r.rows[0] || null);
|
|
430
430
|
},
|
|
431
431
|
|
|
432
|
+
// The moderation events legal from a given status, as {on, to} —
|
|
433
|
+
// drives the action buttons on the operator return-detail page. A
|
|
434
|
+
// terminal status returns []. Synchronous (pure lookup over the
|
|
435
|
+
// transition table). `on` is the API method name (approve / reject /
|
|
436
|
+
// markReceived / refund).
|
|
437
|
+
transitionsFrom: function (status) {
|
|
438
|
+
var edges = TRANSITIONS[status] || {};
|
|
439
|
+
return Object.keys(edges).map(function (on) { return { on: on, to: edges[on] }; });
|
|
440
|
+
},
|
|
441
|
+
|
|
432
442
|
byCode: async function (rmaCode) {
|
|
433
443
|
var canonical = _canonicalCode(rmaCode);
|
|
434
444
|
var r = await query(
|
package/package.json
CHANGED