@blamejs/blamejs-shop 0.1.8 → 0.1.10

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/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
@@ -755,15 +761,41 @@ function mount(router, deps) {
755
761
  // `pending`. Endpoints are omitted entirely when no reviews primitive
756
762
  // is wired.
757
763
  if (reviews) {
758
- router.get("/admin/reviews", R(async function (req, res) {
759
- var url = req.url ? new URL(req.url, "http://localhost") : null;
760
- var status = (url && url.searchParams.get("status")) || "pending";
761
- var cursor = url && url.searchParams.get("cursor");
762
- var limitS = url && url.searchParams.get("limit");
763
- var limit = limitS == null ? undefined : parseInt(limitS, 10);
764
- var page = await reviews.listByStatus(status, { cursor: cursor || undefined, limit: limit });
765
- _json(res, 200, page);
766
- }));
764
+ router.get("/admin/reviews", _pageOrApi(true,
765
+ R(async function (req, res) {
766
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
767
+ var status = (url && url.searchParams.get("status")) || "pending";
768
+ var cursor = url && url.searchParams.get("cursor");
769
+ var limitS = url && url.searchParams.get("limit");
770
+ var limit = limitS == null ? undefined : parseInt(limitS, 10);
771
+ var page = await reviews.listByStatus(status, { cursor: cursor || undefined, limit: limit });
772
+ _json(res, 200, page);
773
+ }),
774
+ async function (req, res) {
775
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
776
+ var status = (url && url.searchParams.get("status")) || "pending";
777
+ var notice = null, rows = [];
778
+ // A bad ?status= raises a TypeError — fall back to pending.
779
+ try {
780
+ rows = (await reviews.listByStatus(status, { limit: 100 })).rows || [];
781
+ } catch (e) {
782
+ if (!(e instanceof TypeError)) throw e;
783
+ status = "pending"; notice = "Unknown status filter — showing pending reviews.";
784
+ rows = (await reviews.listByStatus("pending", { limit: 100 })).rows || [];
785
+ }
786
+ // A failed publish/reject redirects back with ?err=1 — surface it
787
+ // so a no-op (e.g. unknown id / missing reason) isn't mistaken for
788
+ // success, the way orders/returns do.
789
+ if (!notice && url && url.searchParams.get("err")) {
790
+ notice = "That action couldn't be completed for the review.";
791
+ }
792
+ _sendHtml(res, 200, renderAdminReviews({
793
+ shop_name: deps.shop_name, nav_available: navAvailable,
794
+ reviews: rows, status: status, notice: notice,
795
+ moved: url && url.searchParams.get("moved"),
796
+ }));
797
+ },
798
+ ));
767
799
 
768
800
  router.get("/admin/reviews/:id", R(async function (req, res) {
769
801
  var rev = await reviews.get(req.params.id);
@@ -771,30 +803,56 @@ function mount(router, deps) {
771
803
  _json(res, 200, rev);
772
804
  }));
773
805
 
774
- router.post("/admin/reviews/:id/publish", W("review.publish", async function (req, res) {
775
- var rev;
776
- try {
777
- rev = await reviews.publish(req.params.id);
778
- } catch (e) {
779
- if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
780
- throw e;
781
- }
782
- _json(res, 200, rev);
783
- return rev;
784
- }));
806
+ // Publish / reject content-negotiate: bearer → JSON (unchanged);
807
+ // browser form → moderate, then PRG back to the queue (a not-found id
808
+ // is a no-op notice, never a 500).
809
+ function _reviewModerate(jsonHandler, auditEvent, opFn) {
810
+ return _pageOrApi(false, jsonHandler, async function (req, res) {
811
+ var id = req.params.id;
812
+ try { await opFn(id, req.body || {}); }
813
+ catch (e) {
814
+ if (e instanceof TypeError || (e && e.code === "REVIEW_NOT_FOUND")) {
815
+ return _redirect(res, "/admin/reviews?err=1");
816
+ }
817
+ throw e;
818
+ }
819
+ _b().audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
820
+ _redirect(res, "/admin/reviews?moved=1");
821
+ });
822
+ }
785
823
 
786
- router.post("/admin/reviews/:id/reject", W("review.reject", async function (req, res) {
787
- var body = req.body || {};
788
- var rev;
789
- try {
790
- rev = await reviews.reject(req.params.id, body.reason);
791
- } catch (e) {
792
- if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
793
- throw e;
794
- }
795
- _json(res, 200, rev);
796
- return rev;
797
- }));
824
+ router.post("/admin/reviews/:id/publish", _reviewModerate(
825
+ W("review.publish", async function (req, res) {
826
+ var rev;
827
+ try {
828
+ rev = await reviews.publish(req.params.id);
829
+ } catch (e) {
830
+ if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
831
+ throw e;
832
+ }
833
+ _json(res, 200, rev);
834
+ return rev;
835
+ }),
836
+ "review.publish",
837
+ function (id) { return reviews.publish(id); },
838
+ ));
839
+
840
+ router.post("/admin/reviews/:id/reject", _reviewModerate(
841
+ W("review.reject", async function (req, res) {
842
+ var body = req.body || {};
843
+ var rev;
844
+ try {
845
+ rev = await reviews.reject(req.params.id, body.reason);
846
+ } catch (e) {
847
+ if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
848
+ throw e;
849
+ }
850
+ _json(res, 200, rev);
851
+ return rev;
852
+ }),
853
+ "review.reject",
854
+ function (id, body) { return reviews.reject(id, body.reason || undefined); },
855
+ ));
798
856
  }
799
857
 
800
858
  // ---- returns (moderation) -------------------------------------------
@@ -819,94 +877,182 @@ function mount(router, deps) {
819
877
  return null;
820
878
  }
821
879
 
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
- }));
880
+ router.get("/admin/returns", _pageOrApi(true,
881
+ R(async function (req, res) {
882
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
883
+ var status = (url && url.searchParams.get("status")) || "pending";
884
+ var cursor = url && url.searchParams.get("cursor");
885
+ var limitS = url && url.searchParams.get("limit");
886
+ var limit = limitS == null ? undefined : parseInt(limitS, 10);
887
+ var page = await returns.listByStatus(status, { cursor: cursor || undefined, limit: limit });
888
+ _json(res, 200, page);
889
+ }),
890
+ async function (req, res) {
891
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
892
+ var status = (url && url.searchParams.get("status")) || "pending";
893
+ var notice = null, rows = [];
894
+ // A bad ?status= (not one of the RMA states) raises a TypeError
895
+ // from listByStatus — fall back to pending with a notice rather
896
+ // than erroring the page.
897
+ try {
898
+ var page = await returns.listByStatus(status, { limit: 100 });
899
+ rows = page.rows || [];
900
+ } catch (e) {
901
+ if (!(e instanceof TypeError)) throw e;
902
+ status = "pending"; notice = "Unknown status filter — showing pending returns.";
903
+ rows = (await returns.listByStatus("pending", { limit: 100 })).rows || [];
904
+ }
905
+ _sendHtml(res, 200, renderAdminReturns({
906
+ shop_name: deps.shop_name, nav_available: navAvailable, returns: rows, status: status, notice: notice,
907
+ }));
908
+ },
909
+ ));
831
910
 
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
- }));
911
+ router.get("/admin/returns/:id", _pageOrApi(true,
912
+ R(async function (req, res) {
913
+ var rma;
914
+ try {
915
+ rma = await returns.get(req.params.id);
916
+ } catch (e) {
917
+ // A non-UUID :id raises a guardUuid TypeError surface it as a
918
+ // 404 (the route is a defensive request-shape reader, never a
919
+ // 500). Re-raise anything that isn't the bad-id shape so the
920
+ // wrapper's generic handling applies.
921
+ if (e instanceof TypeError) return _problem(res, 404, "return-not-found");
922
+ throw e;
923
+ }
924
+ if (!rma) return _problem(res, 404, "return-not-found");
925
+ _json(res, 200, rma);
926
+ }),
927
+ async function (req, res) {
928
+ var rma;
929
+ try { rma = await returns.get(req.params.id); }
930
+ catch (e) { if (!(e instanceof TypeError)) throw e; rma = null; }
931
+ if (!rma) return _sendHtml(res, 404, renderAdminReturns({
932
+ shop_name: deps.shop_name, nav_available: navAvailable, returns: [], status: "pending", notice: "Return not found.",
933
+ }));
934
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
935
+ _sendHtml(res, 200, renderAdminReturn({
936
+ shop_name: deps.shop_name,
937
+ nav_available: navAvailable,
938
+ rma: rma,
939
+ transitions: returns.transitionsFrom(rma.status),
940
+ moved: url && url.searchParams.get("moved"),
941
+ notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this return." : null,
942
+ }));
943
+ },
944
+ ));
847
945
 
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,
946
+ // The browser side of an RMA action: run `opFn(id, body)`, then PRG
947
+ // back to the detail. A bad id / shape (TypeError) or an FSM refusal /
948
+ // not-found (mapped by _returnsClientError) becomes a notice on the
949
+ // detail, never a 500; anything else propagates.
950
+ function _returnAction(jsonHandler, auditEvent, opFn) {
951
+ return _pageOrApi(false, jsonHandler, async function (req, res) {
952
+ var id = req.params.id;
953
+ try { await opFn(id, req.body || {}); }
954
+ catch (e) {
955
+ if (e instanceof TypeError || _returnsClientError(e)) {
956
+ return _redirect(res, "/admin/returns/" + encodeURIComponent(id) + "?err=1");
957
+ }
958
+ throw e;
959
+ }
960
+ _b().audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
961
+ _redirect(res, "/admin/returns/" + encodeURIComponent(id) + "?moved=1");
962
+ });
963
+ }
964
+
965
+ router.post("/admin/returns/:id/approve", _returnAction(
966
+ W("return.approve", async function (req, res) {
967
+ var body = req.body || {};
968
+ var rma;
969
+ try {
970
+ rma = await returns.approve(req.params.id, {
971
+ refund_amount_minor: body.refund_amount_minor,
972
+ refund_currency: body.refund_currency,
973
+ operator_notes: body.operator_notes,
974
+ });
975
+ } catch (e) {
976
+ var ce = _returnsClientError(e);
977
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
978
+ throw e;
979
+ }
980
+ _json(res, 200, rma);
981
+ return rma;
982
+ }),
983
+ "return.approve",
984
+ function (id, body) {
985
+ // Browser form fields arrive as strings. Convert ONLY a clean
986
+ // non-negative integer to a number; anything else (e.g. "4999usd",
987
+ // "1e3", "") passes through unchanged so returns.approve's
988
+ // _nonNegInt rejects it (→ notice via _returnAction) instead of
989
+ // parseInt silently truncating garbage onto a money field.
990
+ var raw = body.refund_amount_minor;
991
+ var amount = (typeof raw === "string" && /^\d+$/.test(raw.trim())) ? Number(raw.trim()) : raw;
992
+ return returns.approve(id, {
993
+ refund_amount_minor: amount,
994
+ refund_currency: body.refund_currency || undefined,
995
+ operator_notes: body.operator_notes || undefined,
856
996
  });
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
- }));
997
+ },
998
+ ));
865
999
 
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
- }));
1000
+ router.post("/admin/returns/:id/received", _returnAction(
1001
+ W("return.received", async function (req, res) {
1002
+ var body = req.body || {};
1003
+ var rma;
1004
+ try {
1005
+ rma = await returns.markReceived(req.params.id, { operator_notes: body.operator_notes });
1006
+ } catch (e) {
1007
+ var ce = _returnsClientError(e);
1008
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
1009
+ throw e;
1010
+ }
1011
+ _json(res, 200, rma);
1012
+ return rma;
1013
+ }),
1014
+ "return.received",
1015
+ function (id, body) { return returns.markReceived(id, { operator_notes: body.operator_notes || undefined }); },
1016
+ ));
879
1017
 
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
- }));
1018
+ router.post("/admin/returns/:id/refund", _returnAction(
1019
+ W("return.refund", async function (req, res) {
1020
+ var body = req.body || {};
1021
+ var rma;
1022
+ try {
1023
+ rma = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
1024
+ } catch (e) {
1025
+ var ce = _returnsClientError(e);
1026
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
1027
+ throw e;
1028
+ }
1029
+ _json(res, 200, rma);
1030
+ return rma;
1031
+ }),
1032
+ "return.refund",
1033
+ function (id, body) { return returns.refund(id, { operator_notes: body.operator_notes || undefined }); },
1034
+ ));
893
1035
 
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
- }));
1036
+ router.post("/admin/returns/:id/reject", _returnAction(
1037
+ W("return.reject", async function (req, res) {
1038
+ var body = req.body || {};
1039
+ var rma;
1040
+ try {
1041
+ rma = await returns.reject(req.params.id, {
1042
+ rejected_reason: body.rejected_reason,
1043
+ operator_notes: body.operator_notes,
1044
+ });
1045
+ } catch (e) {
1046
+ var ce = _returnsClientError(e);
1047
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
1048
+ throw e;
1049
+ }
1050
+ _json(res, 200, rma);
1051
+ return rma;
1052
+ }),
1053
+ "return.reject",
1054
+ function (id, body) { return returns.reject(id, { rejected_reason: body.rejected_reason, operator_notes: body.operator_notes || undefined }); },
1055
+ ));
910
1056
  }
911
1057
 
912
1058
  // ---- config ---------------------------------------------------------
@@ -1073,6 +1219,7 @@ function mount(router, deps) {
1073
1219
  top_skus: top,
1074
1220
  recent: recent,
1075
1221
  shop_name: (deps.shop_name || "blamejs.shop"),
1222
+ nav_available: navAvailable,
1076
1223
  }));
1077
1224
  });
1078
1225
  }
@@ -1166,6 +1313,7 @@ function mount(router, deps) {
1166
1313
  _sendHtml(res, 200, renderAdminLanding({
1167
1314
  shop_name: deps.shop_name,
1168
1315
  setup_complete: await _setupComplete(),
1316
+ nav_available: navAvailable,
1169
1317
  }));
1170
1318
  });
1171
1319
 
@@ -1202,6 +1350,7 @@ function mount(router, deps) {
1202
1350
  _sendHtml(res, 200, renderAdminIntegrations({
1203
1351
  shop_name: deps.shop_name,
1204
1352
  status: deps.integrations || {},
1353
+ nav_available: navAvailable,
1205
1354
  }));
1206
1355
  });
1207
1356
 
@@ -1217,7 +1366,7 @@ function mount(router, deps) {
1217
1366
  values.currency = await config.get("shop.currency", "");
1218
1367
  values.support_url = await config.get("shop.support_url", "");
1219
1368
  } catch (_e) { /* unconfigured — render an empty form */ }
1220
- _sendHtml(res, 200, renderAdminSetup({ shop_name: deps.shop_name, values: values, saved: saved }));
1369
+ _sendHtml(res, 200, renderAdminSetup({ shop_name: deps.shop_name, values: values, saved: saved, nav_available: navAvailable }));
1221
1370
  });
1222
1371
 
1223
1372
  router.post("/admin/setup", async function (req, res) {
@@ -1244,7 +1393,7 @@ function mount(router, deps) {
1244
1393
  if (!u || (u.protocol !== "https:" && u.protocol !== "http:")) notice = "Support URL must be a valid http(s) URL.";
1245
1394
  }
1246
1395
  if (notice) {
1247
- return _sendHtml(res, 400, renderAdminSetup({ shop_name: deps.shop_name, values: values, notice: notice }));
1396
+ return _sendHtml(res, 400, renderAdminSetup({ shop_name: deps.shop_name, values: values, notice: notice, nav_available: navAvailable }));
1248
1397
  }
1249
1398
  try {
1250
1399
  await config.put("shop.name", values.shop_name);
@@ -1254,7 +1403,7 @@ function mount(router, deps) {
1254
1403
  await config.put("setup.completed", true);
1255
1404
  } catch (e) {
1256
1405
  return _sendHtml(res, 500, renderAdminSetup({
1257
- shop_name: deps.shop_name, values: values,
1406
+ shop_name: deps.shop_name, values: values, nav_available: navAvailable,
1258
1407
  notice: "Couldn't save — " + ((e && e.message) || "please try again."),
1259
1408
  }));
1260
1409
  }
@@ -1343,6 +1492,15 @@ var DASHBOARD_LAYOUT =
1343
1492
  " .order-totals { width:100%; }\n" +
1344
1493
  " .order-totals td { padding:.3rem 0; }\n" +
1345
1494
  " .order-actions { display:flex; flex-wrap:wrap; gap:.6rem; }\n" +
1495
+ " .return-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1.25rem; }\n" +
1496
+ " .return-action { border:1px solid var(--hair); border-radius:8px; padding:1rem; }\n" +
1497
+ " .return-action h4 { margin:0 0 .6rem; font-size:.9rem; }\n" +
1498
+ " .review-card { margin-bottom:1rem; }\n" +
1499
+ " .review-card__head { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:.5rem; }\n" +
1500
+ " .review-card__body { margin:.25rem 0 .75rem; white-space:pre-wrap; }\n" +
1501
+ " .review-stars { color:#c9821f; letter-spacing:.1em; }\n" +
1502
+ " .review-reject { display:inline-flex; gap:.4rem; align-items:center; }\n" +
1503
+ " .review-reject input { padding:.45rem .6rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
1346
1504
  " .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
1347
1505
  " .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
1506
  " .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
@@ -1501,6 +1659,7 @@ function renderDashboard(opts) {
1501
1659
  "Window: last 30 days (operator-tunable via ?since=&until=)",
1502
1660
  body,
1503
1661
  "dashboard",
1662
+ opts.nav_available,
1504
1663
  );
1505
1664
  }
1506
1665
 
@@ -1514,30 +1673,41 @@ function _statCard(label, value, accent) {
1514
1673
  // Console nav — one entry per HTML console screen. `active` highlights
1515
1674
  // the current page; `null`/`false` (unauthenticated pages like the
1516
1675
  // sign-in form) renders no nav at all.
1676
+ // Items carrying `requires` map to an optional `deps.<key>` primitive —
1677
+ // their routes only mount when that dep is wired, so the nav link is shown
1678
+ // only when `available[key]` is truthy (otherwise it would point at an
1679
+ // unregistered route). Items without `requires` are always present.
1517
1680
  var ADMIN_NAV_ITEMS = [
1518
1681
  { key: "home", href: "/admin", label: "Home" },
1519
1682
  { key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
1520
1683
  { key: "products", href: "/admin/products", label: "Products" },
1521
1684
  { key: "orders", href: "/admin/orders", label: "Orders" },
1685
+ { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
1686
+ { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
1522
1687
  { key: "integrations", href: "/admin/integrations", label: "Integrations" },
1523
1688
  { key: "setup", href: "/admin/setup", label: "Setup" },
1524
1689
  ];
1525
- function _adminNav(active) {
1690
+ // `available` is a map of optional-section key → truthy when wired. When
1691
+ // omitted (a render fn called without it), optional items are shown — the
1692
+ // route handlers always pass it, so a real deployment gates correctly.
1693
+ function _adminNav(active, available) {
1526
1694
  if (active === null || active === undefined || active === false) return "";
1527
- var links = ADMIN_NAV_ITEMS.map(function (it) {
1695
+ var links = ADMIN_NAV_ITEMS.filter(function (it) {
1696
+ return !it.requires || !available || available[it.requires];
1697
+ }).map(function (it) {
1528
1698
  return "<a href=\"" + it.href + "\"" + (it.key === active ? " class=\"active\"" : "") + ">" +
1529
1699
  _htmlEscape(it.label) + "</a>";
1530
1700
  }).join("");
1531
1701
  return "<nav class=\"admin-nav\"><div class=\"admin-nav__inner\">" + links + "</div></nav>";
1532
1702
  }
1533
1703
 
1534
- function _renderAdminShell(shopName, subtitle, bodyHtml, active) {
1704
+ function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
1535
1705
  return _renderTemplate(DASHBOARD_LAYOUT, {
1536
1706
  shop_name: shopName || "blamejs.shop",
1537
1707
  window_label: subtitle || "",
1538
1708
  nav: "RAW_NAV",
1539
1709
  body: "RAW_BODY",
1540
- }).replace("RAW_NAV", _adminNav(active)).replace("RAW_BODY", bodyHtml);
1710
+ }).replace("RAW_NAV", _adminNav(active, available)).replace("RAW_BODY", bodyHtml);
1541
1711
  }
1542
1712
 
1543
1713
  function renderAdminLogin(opts) {
@@ -1574,7 +1744,7 @@ function renderAdminLanding(opts) {
1574
1744
  "</div>" +
1575
1745
  "<div class=\"actions-row\"><form method=\"post\" action=\"/admin/logout\"><button type=\"submit\" class=\"btn btn--ghost\">Sign out</button></form></div>" +
1576
1746
  "</section>";
1577
- return _renderAdminShell(opts.shop_name, "", body, "home");
1747
+ return _renderAdminShell(opts.shop_name, "", body, "home", opts.nav_available);
1578
1748
  }
1579
1749
 
1580
1750
  function _setupField(label, name, value, type, hint, extra) {
@@ -1603,7 +1773,7 @@ function renderAdminSetup(opts) {
1603
1773
  "<a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
1604
1774
  "</form>" +
1605
1775
  "</section>";
1606
- return _renderAdminShell(opts.shop_name, "Setup", body, "setup");
1776
+ return _renderAdminShell(opts.shop_name, "Setup", body, "setup", opts.nav_available);
1607
1777
  }
1608
1778
 
1609
1779
  // Each integration is off until the operator supplies its credentials.
@@ -1655,7 +1825,7 @@ function renderAdminIntegrations(opts) {
1655
1825
  "<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
1826
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
1657
1827
  "</section>";
1658
- return _renderAdminShell(opts.shop_name, "Integrations", body, "integrations");
1828
+ return _renderAdminShell(opts.shop_name, "Integrations", body, "integrations", opts.nav_available);
1659
1829
  }
1660
1830
 
1661
1831
  function renderAdminProducts(opts) {
@@ -1689,7 +1859,7 @@ function renderAdminProducts(opts) {
1689
1859
  "</form>" +
1690
1860
  "</div>" +
1691
1861
  "</section>";
1692
- return _renderAdminShell(opts.shop_name, "Products", body, "products");
1862
+ return _renderAdminShell(opts.shop_name, "Products", body, "products", opts.nav_available);
1693
1863
  }
1694
1864
 
1695
1865
  // created_at / updated_at are epoch-ms numbers (order._now()); render a
@@ -1734,7 +1904,7 @@ function renderAdminOrders(opts) {
1734
1904
  : "<p class=\"empty\">No orders" + (active ? " with status “" + _htmlEscape(active) + "”" : " yet") + ".</p>";
1735
1905
 
1736
1906
  var body = "<section><h2>Orders</h2>" + notice + chips + table + "</section>";
1737
- return _renderAdminShell(opts.shop_name, "Orders", body, "orders");
1907
+ return _renderAdminShell(opts.shop_name, "Orders", body, "orders", opts.nav_available);
1738
1908
  }
1739
1909
 
1740
1910
  function renderAdminOrder(opts) {
@@ -1814,7 +1984,198 @@ function renderAdminOrder(opts) {
1814
1984
  "<div class=\"order-actions\">" + actions + "</div>" +
1815
1985
  "</div>" +
1816
1986
  "</section>";
1817
- return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders");
1987
+ return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders", opts.nav_available);
1988
+ }
1989
+
1990
+ // The RMA states an operator can filter the returns queue by — drives the
1991
+ // filter chips, lifecycle order then terminal.
1992
+ var RETURN_STATUS_FILTERS = ["pending", "approved", "received", "refunded", "rejected"];
1993
+
1994
+ // status → status-pill CSS class. The pill stylesheet has paid/fulfilling/
1995
+ // shipped/delivered (green), refunded, cancelled, pending — map the RMA
1996
+ // states onto the closest existing colour without new CSS.
1997
+ function _returnPillClass(status) {
1998
+ if (status === "approved" || status === "received") return "shipped"; // in-progress green
1999
+ if (status === "refunded") return "refunded";
2000
+ if (status === "rejected") return "cancelled";
2001
+ return "pending";
2002
+ }
2003
+
2004
+ function renderAdminReturns(opts) {
2005
+ opts = opts || {};
2006
+ var rmas = opts.returns || [];
2007
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
2008
+ var active = opts.status || "pending";
2009
+
2010
+ var chips = "<div class=\"order-filters\">" +
2011
+ RETURN_STATUS_FILTERS.map(function (s) {
2012
+ return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/returns?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
2013
+ }).join("") +
2014
+ "</div>";
2015
+
2016
+ var rows = rmas.map(function (r) {
2017
+ var items = (r.lines || []).reduce(function (n, l) { return n + (l.qty || 0); }, 0);
2018
+ var amount = r.refund_amount_minor != null ? pricing.format(r.refund_amount_minor, r.refund_currency || "USD") : "—";
2019
+ return "<tr>" +
2020
+ "<td><a class=\"order-id\" href=\"/admin/returns/" + _htmlEscape(r.id) + "\">" + _htmlEscape(r.rma_code || r.id.slice(0, 8)) + "</a></td>" +
2021
+ "<td><span class=\"order-id\">" + _htmlEscape(String(r.order_id).slice(0, 8)) + "</span></td>" +
2022
+ "<td>" + _htmlEscape(r.reason) + "</td>" +
2023
+ "<td><span class=\"status-pill " + _returnPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></td>" +
2024
+ "<td class=\"num\">" + _htmlEscape(String(items)) + "</td>" +
2025
+ "<td class=\"num\">" + _htmlEscape(amount) + "</td>" +
2026
+ "<td>" + _htmlEscape(_fmtDate(r.created_at)) + "</td>" +
2027
+ "</tr>";
2028
+ }).join("");
2029
+
2030
+ var table = rmas.length
2031
+ ? "<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>"
2032
+ : "<p class=\"empty\">No “" + _htmlEscape(active) + "” returns.</p>";
2033
+
2034
+ var body = "<section><h2>Returns</h2>" + notice + chips + table + "</section>";
2035
+ return _renderAdminShell(opts.shop_name, "Returns", body, "returns", opts.nav_available);
2036
+ }
2037
+
2038
+ function renderAdminReturn(opts) {
2039
+ opts = opts || {};
2040
+ var r = opts.rma;
2041
+ var transitions = opts.transitions || [];
2042
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Return updated.</div>" : "";
2043
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
2044
+ var has = function (on) { return transitions.some(function (t) { return t.on === on; }); };
2045
+
2046
+ var lineRows = (r.lines || []).map(function (l) {
2047
+ return "<tr><td>" + _htmlEscape(l.sku) + "</td><td class=\"num\">" + _htmlEscape(String(l.qty)) + "</td>" +
2048
+ "<td>" + _htmlEscape(l.reason || "—") + "</td></tr>";
2049
+ }).join("");
2050
+ var linesTable = (r.lines && r.lines.length)
2051
+ ? "<table><thead><tr><th>SKU</th><th class=\"num\">Qty</th><th>Reason</th></tr></thead><tbody>" + lineRows + "</tbody></table>"
2052
+ : "<p class=\"empty\">No line items recorded.</p>";
2053
+
2054
+ function _field(label, value) {
2055
+ return "<p><span class=\"meta\">" + _htmlEscape(label) + "</span><br>" + (value ? _htmlEscape(String(value)) : "<span class=\"meta\">—</span>") + "</p>";
2056
+ }
2057
+ var refundShown = r.refund_amount_minor != null ? pricing.format(r.refund_amount_minor, r.refund_currency || "USD") : null;
2058
+
2059
+ // Action forms keyed to the legal transitions. Approve + reject need
2060
+ // input (refund amount / rejection reason); mark-received + refund are
2061
+ // single-click. Each posts to its own endpoint and redirects (PRG).
2062
+ var actionBlocks = [];
2063
+ if (has("approve")) {
2064
+ actionBlocks.push(
2065
+ "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/approve\" class=\"return-action\">" +
2066
+ "<h4>Approve</h4>" +
2067
+ _setupField("Refund amount (minor units)", "refund_amount_minor", "", "number", "e.g. 4999 for $49.99.", " min=\"0\" required") +
2068
+ _setupField("Refund currency", "refund_currency", r.refund_currency || "USD", "text", "3-letter ISO 4217.", " maxlength=\"3\" style=\"text-transform:uppercase;max-width:8rem;\"") +
2069
+ _setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
2070
+ "<button class=\"btn\" type=\"submit\">Approve return</button>" +
2071
+ "</form>");
2072
+ }
2073
+ if (has("markReceived")) {
2074
+ actionBlocks.push(
2075
+ "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/received\" class=\"return-action\">" +
2076
+ "<h4>Mark received</h4><p class=\"meta\">Confirm the returned goods arrived.</p>" +
2077
+ _setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
2078
+ "<button class=\"btn\" type=\"submit\">Mark received</button>" +
2079
+ "</form>");
2080
+ }
2081
+ if (has("refund")) {
2082
+ actionBlocks.push(
2083
+ "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/refund\" class=\"return-action\">" +
2084
+ "<h4>Refund</h4><p class=\"meta\">Record the refund" + (refundShown ? " of " + _htmlEscape(refundShown) : "") + " for this return.</p>" +
2085
+ _setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
2086
+ "<button class=\"btn\" type=\"submit\">Refund</button>" +
2087
+ "</form>");
2088
+ }
2089
+ if (has("reject")) {
2090
+ actionBlocks.push(
2091
+ "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/reject\" class=\"return-action\">" +
2092
+ "<h4>Reject</h4>" +
2093
+ _setupField("Reason for rejection", "rejected_reason", "", "text", "Shown to the customer.", " maxlength=\"500\" required") +
2094
+ _setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
2095
+ "<button class=\"btn btn--danger\" type=\"submit\">Reject return</button>" +
2096
+ "</form>");
2097
+ }
2098
+ var actions = actionBlocks.length
2099
+ ? "<div class=\"return-actions\">" + actionBlocks.join("") + "</div>"
2100
+ : "<span class=\"meta\">This return is in a final state — no further changes.</span>";
2101
+
2102
+ var body =
2103
+ "<section style=\"max-width:48rem;\">" +
2104
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/returns\">&larr; Returns</a></div>" +
2105
+ "<h2>Return <code class=\"order-id\">" + _htmlEscape(r.rma_code || r.id.slice(0, 8)) + "</code> " +
2106
+ "<span class=\"status-pill " + _returnPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></h2>" +
2107
+ "<p class=\"meta\">Requested " + _htmlEscape(_fmtDate(r.created_at)) +
2108
+ " · order <a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(r.order_id) + "\">" + _htmlEscape(String(r.order_id).slice(0, 8)) + "</a></p>" +
2109
+ moved + notice +
2110
+ "<div class=\"two-col\">" +
2111
+ "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Items</h3>" + linesTable + "</div>" +
2112
+ "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Details</h3>" +
2113
+ _field("Reason", r.reason) +
2114
+ _field("Customer detail", r.reason_detail) +
2115
+ _field("Customer notes", r.customer_notes) +
2116
+ (refundShown ? _field("Refund", refundShown) : "") +
2117
+ (r.operator_notes ? _field("Operator notes", r.operator_notes) : "") +
2118
+ (r.rejected_reason ? _field("Rejection reason", r.rejected_reason) : "") +
2119
+ "</div>" +
2120
+ "</div>" +
2121
+ "<div class=\"panel\" style=\"margin-top:1.5rem;\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Actions</h3>" +
2122
+ actions +
2123
+ "</div>" +
2124
+ "</section>";
2125
+ return _renderAdminShell(opts.shop_name, "Return " + (r.rma_code || r.id.slice(0, 8)), body, "returns", opts.nav_available);
2126
+ }
2127
+
2128
+ // The review states an operator can filter the moderation queue by.
2129
+ var REVIEW_STATUS_FILTERS = ["pending", "published", "rejected"];
2130
+
2131
+ function _stars(n) {
2132
+ var r = Math.max(0, Math.min(5, parseInt(n, 10) || 0));
2133
+ return "★★★★★".slice(0, r) + "☆☆☆☆☆".slice(0, 5 - r);
2134
+ }
2135
+
2136
+ function renderAdminReviews(opts) {
2137
+ opts = opts || {};
2138
+ var list = opts.reviews || [];
2139
+ var active = opts.status || "pending";
2140
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Review updated.</div>" : "";
2141
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
2142
+
2143
+ var chips = "<div class=\"order-filters\">" +
2144
+ REVIEW_STATUS_FILTERS.map(function (s) {
2145
+ return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/reviews?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
2146
+ }).join("") +
2147
+ "</div>";
2148
+
2149
+ // Reviews are short, so the queue moderates inline — each card shows the
2150
+ // rating, title, body, verified-purchase flag, and the actions that make
2151
+ // sense from its current status (a rejected review can be published, a
2152
+ // published one taken down, a pending one either way).
2153
+ var cards = list.map(function (rv) {
2154
+ var pub = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/publish\" style=\"display:inline;\">" +
2155
+ "<button class=\"btn\" type=\"submit\">Publish</button></form>";
2156
+ var rej = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/reject\" class=\"review-reject\">" +
2157
+ "<input type=\"text\" name=\"reason\" placeholder=\"Reason (shown in the log)\" maxlength=\"300\" required>" +
2158
+ "<button class=\"btn btn--danger\" type=\"submit\">Reject</button></form>";
2159
+ var actions = rv.status === "published" ? rej
2160
+ : rv.status === "rejected" ? pub
2161
+ : pub + " " + rej; // pending → either
2162
+ return "<div class=\"panel review-card\">" +
2163
+ "<div class=\"review-card__head\">" +
2164
+ "<span class=\"review-stars\" title=\"" + _htmlEscape(String(rv.rating)) + " of 5\">" + _stars(rv.rating) + "</span> " +
2165
+ "<strong>" + _htmlEscape(rv.title || "(no title)") + "</strong> " +
2166
+ (rv.verified_purchase ? "<span class=\"status-pill paid\">Verified</span> " : "") +
2167
+ "<span class=\"status-pill " + (rv.status === "published" ? "paid" : rv.status === "rejected" ? "cancelled" : "pending") + "\">" + _htmlEscape(rv.status) + "</span>" +
2168
+ "</div>" +
2169
+ "<p class=\"review-card__body\">" + _htmlEscape(rv.body || "") + "</p>" +
2170
+ "<p class=\"meta\">Product <code class=\"order-id\">" + _htmlEscape(String(rv.product_id).slice(0, 8)) + "</code> · " + _htmlEscape(_fmtDate(rv.created_at)) +
2171
+ (rv.rejected_reason ? " · rejected: " + _htmlEscape(rv.rejected_reason) : "") + "</p>" +
2172
+ "<div class=\"order-actions\">" + actions + "</div>" +
2173
+ "</div>";
2174
+ }).join("");
2175
+
2176
+ var queue = list.length ? cards : "<p class=\"empty\">No “" + _htmlEscape(active) + "” reviews.</p>";
2177
+ var body = "<section><h2>Reviews</h2>" + moved + notice + chips + queue + "</section>";
2178
+ return _renderAdminShell(opts.shop_name, "Reviews", body, "reviews", opts.nav_available);
1818
2179
  }
1819
2180
 
1820
2181
  module.exports = {
@@ -1828,4 +2189,7 @@ module.exports = {
1828
2189
  renderAdminProducts: renderAdminProducts,
1829
2190
  renderAdminOrders: renderAdminOrders,
1830
2191
  renderAdminOrder: renderAdminOrder,
2192
+ renderAdminReturns: renderAdminReturns,
2193
+ renderAdminReturn: renderAdminReturn,
2194
+ renderAdminReviews: renderAdminReviews,
1831
2195
  };