@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/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/lib/admin.js +622 -132
- package/lib/order.js +70 -12
- package/lib/returns.js +10 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/SECURITY.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +10 -2
- package/lib/vendor/blamejs/lib/network-dnssec.js +158 -7
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.50.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +13 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/dnssec.test.js +121 -0
- package/package.json +1 -1
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
625
|
-
var
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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",
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
rma
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
if (
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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/
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
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
|
-
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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><
|
|
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
|
-
|
|
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.
|
|
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\">← 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\">← 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
|
};
|