@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 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, refund) through the order 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. |
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", R(async function (req, res) {
823
- var url = req.url ? new URL(req.url, "http://localhost") : null;
824
- var status = (url && url.searchParams.get("status")) || "pending";
825
- var cursor = url && url.searchParams.get("cursor");
826
- var limitS = url && url.searchParams.get("limit");
827
- var limit = limitS == null ? undefined : parseInt(limitS, 10);
828
- var page = await returns.listByStatus(status, { cursor: cursor || undefined, limit: limit });
829
- _json(res, 200, page);
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", R(async function (req, res) {
833
- var rma;
834
- try {
835
- rma = await returns.get(req.params.id);
836
- } catch (e) {
837
- // A non-UUID :id raises a guardUuid TypeError — surface it as a
838
- // 404 (the route is a defensive request-shape reader, never a
839
- // 500). Re-raise anything that isn't the bad-id shape so the
840
- // wrapper's generic handling applies.
841
- if (e instanceof TypeError) return _problem(res, 404, "return-not-found");
842
- throw e;
843
- }
844
- if (!rma) return _problem(res, 404, "return-not-found");
845
- _json(res, 200, rma);
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
- router.post("/admin/returns/:id/approve", W("return.approve", async function (req, res) {
849
- var body = req.body || {};
850
- var rma;
851
- try {
852
- rma = await returns.approve(req.params.id, {
853
- refund_amount_minor: body.refund_amount_minor,
854
- refund_currency: body.refund_currency,
855
- operator_notes: body.operator_notes,
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
- } catch (e) {
858
- var ce = _returnsClientError(e);
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", W("return.received", async function (req, res) {
867
- var body = req.body || {};
868
- var rma;
869
- try {
870
- rma = await returns.markReceived(req.params.id, { operator_notes: body.operator_notes });
871
- } catch (e) {
872
- var ce = _returnsClientError(e);
873
- if (ce) return _problem(res, ce.status, ce.slug, e.message);
874
- throw e;
875
- }
876
- _json(res, 200, rma);
877
- return rma;
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", W("return.refund", async function (req, res) {
881
- var body = req.body || {};
882
- var rma;
883
- try {
884
- rma = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
885
- } catch (e) {
886
- var ce = _returnsClientError(e);
887
- if (ce) return _problem(res, ce.status, ce.slug, e.message);
888
- throw e;
889
- }
890
- _json(res, 200, rma);
891
- return rma;
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", W("return.reject", async function (req, res) {
895
- var body = req.body || {};
896
- var rma;
897
- try {
898
- rma = await returns.reject(req.params.id, {
899
- rejected_reason: body.rejected_reason,
900
- operator_notes: body.operator_notes,
901
- });
902
- } catch (e) {
903
- var ce = _returnsClientError(e);
904
- if (ce) return _problem(res, ce.status, ce.slug, e.message);
905
- throw e;
906
- }
907
- _json(res, 200, rma);
908
- return rma;
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
- function _adminNav(active) {
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.map(function (it) {
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\">&larr; 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {