@blamejs/blamejs-shop 0.1.7 → 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/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
 
@@ -599,50 +604,153 @@ function mount(router, deps) {
599
604
 
600
605
  // ---- orders ---------------------------------------------------------
601
606
 
602
- router.get("/admin/orders/:id", R(async function (req, res) {
603
- var o = await order.get(req.params.id);
604
- if (!o) return _problem(res, 404, "order-not-found");
605
- _json(res, 200, o);
606
- }));
607
-
608
- router.post("/admin/orders/:id/transition", W("order.transition", async function (req, res) {
609
- var body = req.body || {};
610
- if (!body.event) throw new TypeError("admin.order.transition: body.event required");
611
- var o = await order.transition(req.params.id, body.event, { reason: body.reason, metadata: body.metadata });
612
- _json(res, 200, o);
613
- return o;
614
- }));
615
-
616
- // ---- refunds --------------------------------------------------------
607
+ // Recent orders across all customers. Bearer → no list endpoint existed
608
+ // before, so this adds one (JSON); a signed-in browser gets the console.
609
+ router.get("/admin/orders", _pageOrApi(true,
610
+ R(async function (req, res) {
611
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
612
+ var status = url && url.searchParams.get("status");
613
+ var limitS = url && url.searchParams.get("limit");
614
+ var limit = limitS == null ? 50 : parseInt(limitS, 10);
615
+ var list = await order.listRecent({ status: status || undefined, limit: limit });
616
+ _json(res, 200, list);
617
+ }),
618
+ async function (req, res) {
619
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
620
+ var statusRaw = url && url.searchParams.get("status");
621
+ // A bad ?status= filter falls back to "all" rather than erroring the
622
+ // page — the operator just sees everything, which is a safe default.
623
+ var status = null, notice = null;
624
+ if (statusRaw) {
625
+ try { await order.listRecent({ status: statusRaw, limit: 1 }); status = statusRaw; }
626
+ catch (_e) { notice = "Unknown status filter — showing all orders."; }
627
+ }
628
+ var list = await order.listRecent({ status: status || undefined, limit: 100 });
629
+ _sendHtml(res, 200, renderAdminOrders({
630
+ shop_name: deps.shop_name, nav_available: navAvailable, orders: list.rows || [],
631
+ status: status, notice: notice,
632
+ }));
633
+ },
634
+ ));
617
635
 
618
- if (payment) {
619
- router.post("/admin/orders/:id/refund", W("order.refund", async function (req, res) {
636
+ router.get("/admin/orders/:id", _pageOrApi(true,
637
+ R(async function (req, res) {
620
638
  var o = await order.get(req.params.id);
621
639
  if (!o) return _problem(res, 404, "order-not-found");
622
- if (!o.payment_intent_id) return _problem(res, 422, "no-payment-intent", "Order has no linked payment intent");
640
+ _json(res, 200, o);
641
+ }),
642
+ async function (req, res) {
643
+ var o;
644
+ // A malformed id throws (defensive id reader) — render 404, not 500.
645
+ try { o = await order.get(req.params.id); }
646
+ catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
647
+ if (!o) return _sendHtml(res, 404, renderAdminOrders({
648
+ shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
649
+ }));
650
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
651
+ _sendHtml(res, 200, renderAdminOrder({
652
+ shop_name: deps.shop_name,
653
+ nav_available: navAvailable,
654
+ order: o,
655
+ transitions: order.transitionsFrom(o.status),
656
+ // Refund moves money, so the console only offers it when a payment
657
+ // provider is wired AND the order has a captured intent to refund.
658
+ can_refund: !!(payment && o.payment_intent_id),
659
+ moved: url && url.searchParams.get("moved"),
660
+ notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this order." : null,
661
+ }));
662
+ },
663
+ ));
664
+
665
+ router.post("/admin/orders/:id/transition", _pageOrApi(false,
666
+ W("order.transition", async function (req, res) {
623
667
  var body = req.body || {};
624
- var refundIdempotencyKey = "refund:" + o.id + ":" + (body.idempotency_suffix || _b().uuid.v7());
625
- var refund;
668
+ if (!body.event) throw new TypeError("admin.order.transition: body.event required");
669
+ var o = await order.transition(req.params.id, body.event, { reason: body.reason, metadata: body.metadata });
670
+ _json(res, 200, o);
671
+ return o;
672
+ }),
673
+ async function (req, res) {
674
+ // Browser form → run the transition, then redirect back to the
675
+ // detail (PRG). A bad id (TypeError) or an FSM refusal (the move
676
+ // isn't legal from this status) surfaces as a notice, not a 500;
677
+ // any other failure propagates.
678
+ var id = req.params.id;
679
+ var event = (req.body || {}).event;
680
+ if (!event) return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
626
681
  try {
627
- refund = await payment.refund({
628
- payment_intent: o.payment_intent_id,
629
- amount_minor: body.amount_minor || undefined,
630
- reason: body.reason || undefined,
631
- metadata: { order_id: o.id },
632
- }, refundIdempotencyKey);
682
+ await order.transition(id, event, { reason: "admin:console" });
633
683
  } catch (e) {
634
- return _problem(res, 502, "stripe-refund-failed", (e && e.message) || String(e));
684
+ if (e instanceof TypeError || (e && e.code && /FSM|TRANSITION|GUARD/i.test(e.code))) {
685
+ return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
686
+ }
687
+ throw e;
635
688
  }
689
+ _b().audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.transition", outcome: "success", metadata: { id: id, event: event } });
690
+ _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?moved=1");
691
+ },
692
+ ));
693
+
694
+ // ---- refunds --------------------------------------------------------
695
+
696
+ if (payment) {
697
+ // Issue the actual payment-provider refund, then advance the order
698
+ // FSM. Shared by the JSON API and the browser console so a console
699
+ // "Refund" moves the money first (never a bare state change — that
700
+ // would mark an order refunded with the customer never paid back).
701
+ async function _refundOrder(o, body) {
702
+ var refundIdempotencyKey = "refund:" + o.id + ":" + (body.idempotency_suffix || _b().uuid.v7());
703
+ var refund = await payment.refund({
704
+ payment_intent: o.payment_intent_id,
705
+ amount_minor: body.amount_minor || undefined,
706
+ reason: body.reason || undefined,
707
+ metadata: { order_id: o.id },
708
+ }, refundIdempotencyKey);
636
709
  try {
637
710
  await order.transition(o.id, "refund", {
638
711
  reason: "admin:refund:" + (body.reason || "requested_by_customer"),
639
712
  metadata: { stripe_refund_id: refund.id, amount_minor: refund.amount },
640
713
  });
641
- } catch (_e) { /* refund succeeded at Stripe; transition refusal logged, surface to operator via re-fetch */ }
642
- var updated = await order.get(o.id);
643
- _json(res, 200, { refund: refund, order: updated });
644
- return { id: o.id };
645
- }));
714
+ } catch (_e) { /* refund succeeded at the provider; transition refusal logged, surfaced via re-fetch */ }
715
+ return { refund: refund, order: await order.get(o.id) };
716
+ }
717
+
718
+ router.post("/admin/orders/:id/refund", _pageOrApi(false,
719
+ W("order.refund", async function (req, res) {
720
+ var o = await order.get(req.params.id);
721
+ if (!o) return _problem(res, 404, "order-not-found");
722
+ if (!o.payment_intent_id) return _problem(res, 422, "no-payment-intent", "Order has no linked payment intent");
723
+ var result;
724
+ try {
725
+ result = await _refundOrder(o, req.body || {});
726
+ } catch (e) {
727
+ return _problem(res, 502, "stripe-refund-failed", (e && e.message) || String(e));
728
+ }
729
+ _json(res, 200, result);
730
+ return { id: o.id };
731
+ }),
732
+ async function (req, res) {
733
+ // Browser console: full refund (partial refunds stay on the JSON
734
+ // API via amount_minor), then PRG back to the detail. A bad id or
735
+ // missing payment intent surfaces as a notice, never a 500.
736
+ var id = req.params.id;
737
+ var o;
738
+ try { o = await order.get(id); }
739
+ catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
740
+ if (!o || !o.payment_intent_id) {
741
+ return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
742
+ }
743
+ try {
744
+ await _refundOrder(o, { reason: "requested_by_customer" });
745
+ } catch (_e) {
746
+ // Provider refund failed — the order is untouched (the FSM
747
+ // transition only runs after a successful refund).
748
+ return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
749
+ }
750
+ _b().audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.refund", outcome: "success", metadata: { id: id } });
751
+ _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?moved=1");
752
+ },
753
+ ));
646
754
  }
647
755
 
648
756
  // ---- reviews (moderation) -------------------------------------------
@@ -717,94 +825,182 @@ function mount(router, deps) {
717
825
  return null;
718
826
  }
719
827
 
720
- router.get("/admin/returns", R(async function (req, res) {
721
- var url = req.url ? new URL(req.url, "http://localhost") : null;
722
- var status = (url && url.searchParams.get("status")) || "pending";
723
- var cursor = url && url.searchParams.get("cursor");
724
- var limitS = url && url.searchParams.get("limit");
725
- var limit = limitS == null ? undefined : parseInt(limitS, 10);
726
- var page = await returns.listByStatus(status, { cursor: cursor || undefined, limit: limit });
727
- _json(res, 200, page);
728
- }));
729
-
730
- router.get("/admin/returns/:id", R(async function (req, res) {
731
- var rma;
732
- try {
733
- rma = await returns.get(req.params.id);
734
- } catch (e) {
735
- // A non-UUID :id raises a guardUuid TypeError surface it as a
736
- // 404 (the route is a defensive request-shape reader, never a
737
- // 500). Re-raise anything that isn't the bad-id shape so the
738
- // wrapper's generic handling applies.
739
- if (e instanceof TypeError) return _problem(res, 404, "return-not-found");
740
- throw e;
741
- }
742
- if (!rma) return _problem(res, 404, "return-not-found");
743
- _json(res, 200, rma);
744
- }));
745
-
746
- router.post("/admin/returns/:id/approve", W("return.approve", async function (req, res) {
747
- var body = req.body || {};
748
- var rma;
749
- try {
750
- rma = await returns.approve(req.params.id, {
751
- refund_amount_minor: body.refund_amount_minor,
752
- refund_currency: body.refund_currency,
753
- operator_notes: body.operator_notes,
754
- });
755
- } catch (e) {
756
- var ce = _returnsClientError(e);
757
- if (ce) return _problem(res, ce.status, ce.slug, e.message);
758
- throw e;
759
- }
760
- _json(res, 200, rma);
761
- return rma;
762
- }));
763
-
764
- router.post("/admin/returns/:id/received", W("return.received", async function (req, res) {
765
- var body = req.body || {};
766
- var rma;
767
- try {
768
- rma = await returns.markReceived(req.params.id, { operator_notes: body.operator_notes });
769
- } catch (e) {
770
- var ce = _returnsClientError(e);
771
- if (ce) return _problem(res, ce.status, ce.slug, e.message);
772
- throw e;
773
- }
774
- _json(res, 200, rma);
775
- return rma;
776
- }));
777
-
778
- router.post("/admin/returns/:id/refund", W("return.refund", async function (req, res) {
779
- var body = req.body || {};
780
- var rma;
781
- try {
782
- rma = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
783
- } catch (e) {
784
- var ce = _returnsClientError(e);
785
- if (ce) return _problem(res, ce.status, ce.slug, e.message);
786
- throw e;
787
- }
788
- _json(res, 200, rma);
789
- return rma;
790
- }));
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
+ ));
858
+
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
+ ));
893
+
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
+ }
791
912
 
792
- router.post("/admin/returns/:id/reject", W("return.reject", async function (req, res) {
793
- var body = req.body || {};
794
- var rma;
795
- try {
796
- rma = await returns.reject(req.params.id, {
797
- rejected_reason: body.rejected_reason,
798
- operator_notes: body.operator_notes,
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,
799
944
  });
800
- } catch (e) {
801
- var ce = _returnsClientError(e);
802
- if (ce) return _problem(res, ce.status, ce.slug, e.message);
803
- throw e;
804
- }
805
- _json(res, 200, rma);
806
- return rma;
807
- }));
945
+ },
946
+ ));
947
+
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
+ ));
965
+
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
+ ));
983
+
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
+ ));
808
1004
  }
809
1005
 
810
1006
  // ---- config ---------------------------------------------------------
@@ -971,6 +1167,7 @@ function mount(router, deps) {
971
1167
  top_skus: top,
972
1168
  recent: recent,
973
1169
  shop_name: (deps.shop_name || "blamejs.shop"),
1170
+ nav_available: navAvailable,
974
1171
  }));
975
1172
  });
976
1173
  }
@@ -1064,6 +1261,7 @@ function mount(router, deps) {
1064
1261
  _sendHtml(res, 200, renderAdminLanding({
1065
1262
  shop_name: deps.shop_name,
1066
1263
  setup_complete: await _setupComplete(),
1264
+ nav_available: navAvailable,
1067
1265
  }));
1068
1266
  });
1069
1267
 
@@ -1100,6 +1298,7 @@ function mount(router, deps) {
1100
1298
  _sendHtml(res, 200, renderAdminIntegrations({
1101
1299
  shop_name: deps.shop_name,
1102
1300
  status: deps.integrations || {},
1301
+ nav_available: navAvailable,
1103
1302
  }));
1104
1303
  });
1105
1304
 
@@ -1115,7 +1314,7 @@ function mount(router, deps) {
1115
1314
  values.currency = await config.get("shop.currency", "");
1116
1315
  values.support_url = await config.get("shop.support_url", "");
1117
1316
  } catch (_e) { /* unconfigured — render an empty form */ }
1118
- _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 }));
1119
1318
  });
1120
1319
 
1121
1320
  router.post("/admin/setup", async function (req, res) {
@@ -1142,7 +1341,7 @@ function mount(router, deps) {
1142
1341
  if (!u || (u.protocol !== "https:" && u.protocol !== "http:")) notice = "Support URL must be a valid http(s) URL.";
1143
1342
  }
1144
1343
  if (notice) {
1145
- 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 }));
1146
1345
  }
1147
1346
  try {
1148
1347
  await config.put("shop.name", values.shop_name);
@@ -1152,7 +1351,7 @@ function mount(router, deps) {
1152
1351
  await config.put("setup.completed", true);
1153
1352
  } catch (e) {
1154
1353
  return _sendHtml(res, 500, renderAdminSetup({
1155
- shop_name: deps.shop_name, values: values,
1354
+ shop_name: deps.shop_name, values: values, nav_available: navAvailable,
1156
1355
  notice: "Couldn't save — " + ((e && e.message) || "please try again."),
1157
1356
  }));
1158
1357
  }
@@ -1232,6 +1431,18 @@ var DASHBOARD_LAYOUT =
1232
1431
  " .btn:hover { background:var(--accent-d); border-color:var(--accent-d); }\n" +
1233
1432
  " .btn--ghost { background:transparent; color:var(--ink); border-color:var(--ink); }\n" +
1234
1433
  " .btn--ghost:hover { background:var(--ink); color:var(--paper); }\n" +
1434
+ " .btn--danger { background:transparent; color:var(--accent-d); border-color:var(--accent-d); }\n" +
1435
+ " .btn--danger:hover { background:var(--accent-d); color:var(--paper); }\n" +
1436
+ " .order-filters { display:flex; flex-wrap:wrap; gap:.5rem; margin-bottom:1.25rem; }\n" +
1437
+ " .chip { display:inline-block; padding:.3rem .8rem; border-radius:999px; border:1px solid var(--hair); color:var(--ink-2); text-decoration:none; font-size:.78rem; text-transform:capitalize; }\n" +
1438
+ " .chip:hover { border-color:var(--accent); }\n" +
1439
+ " .chip--on { background:var(--ink); color:var(--paper); border-color:var(--ink); }\n" +
1440
+ " .order-totals { width:100%; }\n" +
1441
+ " .order-totals td { padding:.3rem 0; }\n" +
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" +
1235
1446
  " .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
1236
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" +
1237
1448
  " .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
@@ -1364,7 +1575,7 @@ function renderDashboard(opts) {
1364
1575
  ? recent.map(function (o) {
1365
1576
  var statusClass = _htmlEscape(o.status);
1366
1577
  return "<tr>" +
1367
- "<td><span class=\"order-id\">" + _htmlEscape(o.id.slice(0, 8)) + "</span></td>" +
1578
+ "<td><a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(o.id) + "\">" + _htmlEscape(o.id.slice(0, 8)) + "</a></td>" +
1368
1579
  "<td><span class=\"status-pill " + statusClass + "\">" + _htmlEscape(o.status) + "</span></td>" +
1369
1580
  "<td class=\"num\">" + _htmlEscape(pricing.format(o.grand_total_minor, o.currency)) + "</td>" +
1370
1581
  "</tr>";
@@ -1390,6 +1601,7 @@ function renderDashboard(opts) {
1390
1601
  "Window: last 30 days (operator-tunable via ?since=&until=)",
1391
1602
  body,
1392
1603
  "dashboard",
1604
+ opts.nav_available,
1393
1605
  );
1394
1606
  }
1395
1607
 
@@ -1403,29 +1615,40 @@ function _statCard(label, value, accent) {
1403
1615
  // Console nav — one entry per HTML console screen. `active` highlights
1404
1616
  // the current page; `null`/`false` (unauthenticated pages like the
1405
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.
1406
1622
  var ADMIN_NAV_ITEMS = [
1407
1623
  { key: "home", href: "/admin", label: "Home" },
1408
1624
  { key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
1409
1625
  { key: "products", href: "/admin/products", label: "Products" },
1626
+ { key: "orders", href: "/admin/orders", label: "Orders" },
1627
+ { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
1410
1628
  { key: "integrations", href: "/admin/integrations", label: "Integrations" },
1411
1629
  { key: "setup", href: "/admin/setup", label: "Setup" },
1412
1630
  ];
1413
- 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) {
1414
1635
  if (active === null || active === undefined || active === false) return "";
1415
- 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) {
1416
1639
  return "<a href=\"" + it.href + "\"" + (it.key === active ? " class=\"active\"" : "") + ">" +
1417
1640
  _htmlEscape(it.label) + "</a>";
1418
1641
  }).join("");
1419
1642
  return "<nav class=\"admin-nav\"><div class=\"admin-nav__inner\">" + links + "</div></nav>";
1420
1643
  }
1421
1644
 
1422
- function _renderAdminShell(shopName, subtitle, bodyHtml, active) {
1645
+ function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
1423
1646
  return _renderTemplate(DASHBOARD_LAYOUT, {
1424
1647
  shop_name: shopName || "blamejs.shop",
1425
1648
  window_label: subtitle || "",
1426
1649
  nav: "RAW_NAV",
1427
1650
  body: "RAW_BODY",
1428
- }).replace("RAW_NAV", _adminNav(active)).replace("RAW_BODY", bodyHtml);
1651
+ }).replace("RAW_NAV", _adminNav(active, available)).replace("RAW_BODY", bodyHtml);
1429
1652
  }
1430
1653
 
1431
1654
  function renderAdminLogin(opts) {
@@ -1462,7 +1685,7 @@ function renderAdminLanding(opts) {
1462
1685
  "</div>" +
1463
1686
  "<div class=\"actions-row\"><form method=\"post\" action=\"/admin/logout\"><button type=\"submit\" class=\"btn btn--ghost\">Sign out</button></form></div>" +
1464
1687
  "</section>";
1465
- return _renderAdminShell(opts.shop_name, "", body, "home");
1688
+ return _renderAdminShell(opts.shop_name, "", body, "home", opts.nav_available);
1466
1689
  }
1467
1690
 
1468
1691
  function _setupField(label, name, value, type, hint, extra) {
@@ -1491,7 +1714,7 @@ function renderAdminSetup(opts) {
1491
1714
  "<a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
1492
1715
  "</form>" +
1493
1716
  "</section>";
1494
- return _renderAdminShell(opts.shop_name, "Setup", body, "setup");
1717
+ return _renderAdminShell(opts.shop_name, "Setup", body, "setup", opts.nav_available);
1495
1718
  }
1496
1719
 
1497
1720
  // Each integration is off until the operator supplies its credentials.
@@ -1543,7 +1766,7 @@ function renderAdminIntegrations(opts) {
1543
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>" +
1544
1767
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
1545
1768
  "</section>";
1546
- return _renderAdminShell(opts.shop_name, "Integrations", body, "integrations");
1769
+ return _renderAdminShell(opts.shop_name, "Integrations", body, "integrations", opts.nav_available);
1547
1770
  }
1548
1771
 
1549
1772
  function renderAdminProducts(opts) {
@@ -1577,7 +1800,270 @@ function renderAdminProducts(opts) {
1577
1800
  "</form>" +
1578
1801
  "</div>" +
1579
1802
  "</section>";
1580
- return _renderAdminShell(opts.shop_name, "Products", body, "products");
1803
+ return _renderAdminShell(opts.shop_name, "Products", body, "products", opts.nav_available);
1804
+ }
1805
+
1806
+ // created_at / updated_at are epoch-ms numbers (order._now()); render a
1807
+ // short, locale-neutral date. Guards against a string or a bad value so a
1808
+ // malformed row never throws inside the template.
1809
+ function _fmtDate(v) {
1810
+ var n = typeof v === "number" ? v : Date.parse(v);
1811
+ if (!isFinite(n)) return "—";
1812
+ return new Date(n).toISOString().slice(0, 10);
1813
+ }
1814
+
1815
+ // The status values an operator can filter the orders list by — drives the
1816
+ // filter chips. Kept in render-layer order (lifecycle, then terminal).
1817
+ var ORDER_STATUS_FILTERS = ["pending", "paid", "fulfilling", "shipped", "delivered", "refunded", "cancelled"];
1818
+
1819
+ function renderAdminOrders(opts) {
1820
+ opts = opts || {};
1821
+ var orders = opts.orders || [];
1822
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
1823
+ var active = opts.status || null;
1824
+
1825
+ var chips = "<div class=\"order-filters\">" +
1826
+ "<a class=\"chip" + (active ? "" : " chip--on") + "\" href=\"/admin/orders\">All</a>" +
1827
+ ORDER_STATUS_FILTERS.map(function (s) {
1828
+ return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/orders?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
1829
+ }).join("") +
1830
+ "</div>";
1831
+
1832
+ var rows = orders.map(function (o) {
1833
+ var items = (o.lines || []).reduce(function (n, l) { return n + (l.qty || 0); }, 0);
1834
+ return "<tr>" +
1835
+ "<td><a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(o.id) + "\">" + _htmlEscape(o.id.slice(0, 8)) + "</a></td>" +
1836
+ "<td><span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></td>" +
1837
+ "<td class=\"num\">" + _htmlEscape(String(items)) + "</td>" +
1838
+ "<td class=\"num\">" + _htmlEscape(pricing.format(o.grand_total_minor, o.currency)) + "</td>" +
1839
+ "<td>" + _htmlEscape(_fmtDate(o.created_at)) + "</td>" +
1840
+ "</tr>";
1841
+ }).join("");
1842
+
1843
+ var table = orders.length
1844
+ ? "<div class=\"panel\"><table><thead><tr><th>Order</th><th>Status</th><th class=\"num\">Items</th><th class=\"num\">Total</th><th>Placed</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
1845
+ : "<p class=\"empty\">No orders" + (active ? " with status “" + _htmlEscape(active) + "”" : " yet") + ".</p>";
1846
+
1847
+ var body = "<section><h2>Orders</h2>" + notice + chips + table + "</section>";
1848
+ return _renderAdminShell(opts.shop_name, "Orders", body, "orders", opts.nav_available);
1849
+ }
1850
+
1851
+ function renderAdminOrder(opts) {
1852
+ opts = opts || {};
1853
+ var o = opts.order;
1854
+ var transitions = opts.transitions || [];
1855
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Order updated.</div>" : "";
1856
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
1857
+
1858
+ var lineRows = (o.lines || []).map(function (l) {
1859
+ return "<tr>" +
1860
+ "<td>" + _htmlEscape(l.sku) + "</td>" +
1861
+ "<td class=\"num\">" + _htmlEscape(String(l.qty)) + "</td>" +
1862
+ "<td class=\"num\">" + _htmlEscape(pricing.format(l.unit_amount_minor, l.unit_currency)) + "</td>" +
1863
+ "<td class=\"num\">" + _htmlEscape(pricing.format(l.line_total_minor, l.unit_currency)) + "</td>" +
1864
+ "</tr>";
1865
+ }).join("");
1866
+ var linesTable = (o.lines && o.lines.length)
1867
+ ? "<table><thead><tr><th>SKU</th><th class=\"num\">Qty</th><th class=\"num\">Unit</th><th class=\"num\">Line</th></tr></thead><tbody>" + lineRows + "</tbody></table>"
1868
+ : "<p class=\"empty\">No line items recorded.</p>";
1869
+
1870
+ function _total(label, minor, strong) {
1871
+ return "<tr><td>" + _htmlEscape(label) + "</td><td class=\"num\">" +
1872
+ (strong ? "<strong>" : "") + _htmlEscape(pricing.format(minor, o.currency)) + (strong ? "</strong>" : "") +
1873
+ "</td></tr>";
1874
+ }
1875
+ var totals = "<table class=\"order-totals\"><tbody>" +
1876
+ _total("Subtotal", o.subtotal_minor, false) +
1877
+ (o.discount_minor ? _total("Discount", -o.discount_minor, false) : "") +
1878
+ _total("Tax", o.tax_minor, false) +
1879
+ _total("Shipping", o.shipping_minor, false) +
1880
+ _total("Total", o.grand_total_minor, true) +
1881
+ "</tbody></table>";
1882
+
1883
+ var ship = o.ship_to || {};
1884
+ var shipLines = [ship.name, ship.line1, ship.line2,
1885
+ [ship.city, ship.region, ship.postal_code].filter(Boolean).join(", "), ship.country]
1886
+ .filter(Boolean).map(function (s) { return _htmlEscape(String(s)); }).join("<br>");
1887
+
1888
+ // One form per legal next transition. `refund` is special: it moves
1889
+ // money, so it posts to the payment-refund endpoint (which issues the
1890
+ // provider refund THEN advances the FSM) rather than the bare
1891
+ // state-transition endpoint — and only when there's a captured payment
1892
+ // to refund. Every other move posts to /transition. A terminal status
1893
+ // (empty list) shows a note instead of buttons.
1894
+ var actionForms = transitions.map(function (t) {
1895
+ if (t.on === "refund") {
1896
+ if (!opts.can_refund) return ""; // no payment intent — nothing to refund here
1897
+ return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund\" style=\"display:inline;\">" +
1898
+ "<button class=\"btn btn--danger\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
1899
+ "</form>";
1900
+ }
1901
+ var danger = (t.on === "cancel");
1902
+ return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/transition\" style=\"display:inline;\">" +
1903
+ "<input type=\"hidden\" name=\"event\" value=\"" + _htmlEscape(t.on) + "\">" +
1904
+ "<button class=\"btn" + (danger ? " btn--danger" : "") + "\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
1905
+ "</form>";
1906
+ }).filter(Boolean).join(" ");
1907
+ var actions = actionForms || "<span class=\"meta\">This order is in a final state — no further changes.</span>";
1908
+
1909
+ var body =
1910
+ "<section style=\"max-width:48rem;\">" +
1911
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">&larr; Orders</a></div>" +
1912
+ "<h2>Order <code class=\"order-id\">" + _htmlEscape(o.id.slice(0, 8)) + "</code> " +
1913
+ "<span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></h2>" +
1914
+ "<p class=\"meta\">Placed " + _htmlEscape(_fmtDate(o.created_at)) + " · last updated " + _htmlEscape(_fmtDate(o.updated_at)) +
1915
+ (o.payment_intent_id ? " · payment <code class=\"order-id\">" + _htmlEscape(o.payment_intent_id) + "</code>" : "") + "</p>" +
1916
+ moved + notice +
1917
+ "<div class=\"two-col\">" +
1918
+ "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Items</h3>" + linesTable + "</div>" +
1919
+ "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Ship to</h3>" +
1920
+ (shipLines || "<span class=\"meta\">No shipping address.</span>") +
1921
+ "<h3 style=\"font-size:.95rem; margin:1.25rem 0 .75rem;\">Totals</h3>" + totals +
1922
+ "</div>" +
1923
+ "</div>" +
1924
+ "<div class=\"panel\" style=\"margin-top:1.5rem;\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Actions</h3>" +
1925
+ "<div class=\"order-actions\">" + actions + "</div>" +
1926
+ "</div>" +
1927
+ "</section>";
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);
1581
2067
  }
1582
2068
 
1583
2069
  module.exports = {
@@ -1589,4 +2075,8 @@ module.exports = {
1589
2075
  renderAdminSetup: renderAdminSetup,
1590
2076
  renderAdminIntegrations: renderAdminIntegrations,
1591
2077
  renderAdminProducts: renderAdminProducts,
2078
+ renderAdminOrders: renderAdminOrders,
2079
+ renderAdminOrder: renderAdminOrder,
2080
+ renderAdminReturns: renderAdminReturns,
2081
+ renderAdminReturn: renderAdminReturn,
1592
2082
  };