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