@blamejs/blamejs-shop 0.1.6 → 0.1.8
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 +425 -72
- package/lib/order.js +70 -12
- 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
|
@@ -235,6 +235,24 @@ function mount(router, deps) {
|
|
|
235
235
|
return _wrap(h, { expectedToken: expectedToken });
|
|
236
236
|
};
|
|
237
237
|
|
|
238
|
+
// Content-negotiate one endpoint between the JSON API and the HTML
|
|
239
|
+
// console: a bearer token routes to `apiHandler` (the JSON contract,
|
|
240
|
+
// unchanged for tooling); a browser admin-cookie session routes to
|
|
241
|
+
// `htmlHandler` (the rendered console page). Unauthenticated GETs show
|
|
242
|
+
// the sign-in form; other methods bounce to /admin.
|
|
243
|
+
function _pageOrApi(isGet, apiHandler, htmlHandler) {
|
|
244
|
+
return async function (req, res) {
|
|
245
|
+
if (_authOk(_readBearer(req), expectedToken)) return apiHandler(req, res);
|
|
246
|
+
// Mirror _htmlAuthed: a missing vault makes the cookie check throw;
|
|
247
|
+
// treat that as "not authed" rather than 500-ing the route.
|
|
248
|
+
var cookieOk = false;
|
|
249
|
+
try { cookieOk = _adminCookieValid(req); } catch (_e) { cookieOk = false; }
|
|
250
|
+
if (cookieOk) return htmlHandler(req, res);
|
|
251
|
+
if (isGet) return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
|
|
252
|
+
return _redirect(res, "/admin");
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
238
256
|
function _json(res, status, obj) {
|
|
239
257
|
res.status(status);
|
|
240
258
|
if (res.setHeader) res.setHeader("content-type", "application/json; charset=utf-8");
|
|
@@ -244,11 +262,31 @@ function mount(router, deps) {
|
|
|
244
262
|
|
|
245
263
|
// ---- products -------------------------------------------------------
|
|
246
264
|
|
|
247
|
-
router.post("/admin/products",
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
265
|
+
router.post("/admin/products", _pageOrApi(false,
|
|
266
|
+
W("product.create", async function (req, res) {
|
|
267
|
+
var p = await catalog.products.create(req.body || {});
|
|
268
|
+
_json(res, 201, p);
|
|
269
|
+
return p;
|
|
270
|
+
}),
|
|
271
|
+
async function (req, res) {
|
|
272
|
+
// Browser form submit — create, then redirect (PRG). Bad input
|
|
273
|
+
// re-renders the products page with a notice, never a 500.
|
|
274
|
+
try {
|
|
275
|
+
await catalog.products.create(req.body || {});
|
|
276
|
+
} catch (e) {
|
|
277
|
+
if (e instanceof TypeError || e.code === "CATALOG_DUPLICATE" || /slug|exists|duplicate/i.test(e.message || "")) {
|
|
278
|
+
var page = await catalog.products.list({ limit: 100 });
|
|
279
|
+
return _sendHtml(res, 400, renderAdminProducts({
|
|
280
|
+
shop_name: deps.shop_name, products: page.rows || [],
|
|
281
|
+
notice: (e && e.message) || "Couldn't create that product.",
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
throw e;
|
|
285
|
+
}
|
|
286
|
+
_b().audit.safeEmit({ action: AUDIT_NAMESPACE + ".product.create", outcome: "success", metadata: {} });
|
|
287
|
+
_redirect(res, "/admin/products?created=1");
|
|
288
|
+
},
|
|
289
|
+
));
|
|
252
290
|
|
|
253
291
|
router.get("/admin/products/search", R(async function (req, res) {
|
|
254
292
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
@@ -269,15 +307,23 @@ function mount(router, deps) {
|
|
|
269
307
|
_json(res, 200, page);
|
|
270
308
|
}));
|
|
271
309
|
|
|
272
|
-
router.get("/admin/products",
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
310
|
+
router.get("/admin/products", _pageOrApi(true,
|
|
311
|
+
R(async function (req, res) {
|
|
312
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
313
|
+
var status = url && url.searchParams.get("status");
|
|
314
|
+
var cursor = url && url.searchParams.get("cursor");
|
|
315
|
+
var limitS = url && url.searchParams.get("limit");
|
|
316
|
+
var limit = limitS == null ? 50 : parseInt(limitS, 10);
|
|
317
|
+
var page = await catalog.products.list({ status: status || undefined, cursor: cursor || undefined, limit: limit });
|
|
318
|
+
_json(res, 200, page);
|
|
319
|
+
}),
|
|
320
|
+
async function (req, res) {
|
|
321
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
322
|
+
var created = !!(url && url.searchParams.get("created"));
|
|
323
|
+
var page = await catalog.products.list({ limit: 100 });
|
|
324
|
+
_sendHtml(res, 200, renderAdminProducts({ shop_name: deps.shop_name, products: page.rows || [], created: created }));
|
|
325
|
+
},
|
|
326
|
+
));
|
|
281
327
|
|
|
282
328
|
router.get("/admin/products/:id", R(async function (req, res) {
|
|
283
329
|
var p = await catalog.products.get(req.params.id);
|
|
@@ -292,19 +338,26 @@ function mount(router, deps) {
|
|
|
292
338
|
return p;
|
|
293
339
|
}));
|
|
294
340
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
341
|
+
function _productStateAction(verb, op, audit) {
|
|
342
|
+
return _pageOrApi(false,
|
|
343
|
+
W(audit, async function (req, res) {
|
|
344
|
+
var p = await op(req.params.id);
|
|
345
|
+
if (!p) return _problem(res, 404, "product-not-found");
|
|
346
|
+
_json(res, 200, p);
|
|
347
|
+
return p;
|
|
348
|
+
}),
|
|
349
|
+
async function (req, res) {
|
|
350
|
+
// A bad/missing id is a no-op (fall through to the list); a real
|
|
351
|
+
// failure must NOT be reported as success — let it surface.
|
|
352
|
+
try { await op(req.params.id); }
|
|
353
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
354
|
+
_b().audit.safeEmit({ action: AUDIT_NAMESPACE + "." + audit, outcome: "success", metadata: { id: req.params.id } });
|
|
355
|
+
_redirect(res, "/admin/products");
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
router.post("/admin/products/:id/archive", _productStateAction("archive", function (id) { return catalog.products.archive(id); }, "product.archive"));
|
|
360
|
+
router.post("/admin/products/:id/restore", _productStateAction("restore", function (id) { return catalog.products.restore(id); }, "product.restore"));
|
|
308
361
|
|
|
309
362
|
// ---- variants -------------------------------------------------------
|
|
310
363
|
|
|
@@ -546,50 +599,152 @@ function mount(router, deps) {
|
|
|
546
599
|
|
|
547
600
|
// ---- orders ---------------------------------------------------------
|
|
548
601
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
602
|
+
// Recent orders across all customers. Bearer → no list endpoint existed
|
|
603
|
+
// before, so this adds one (JSON); a signed-in browser gets the console.
|
|
604
|
+
router.get("/admin/orders", _pageOrApi(true,
|
|
605
|
+
R(async function (req, res) {
|
|
606
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
607
|
+
var status = url && url.searchParams.get("status");
|
|
608
|
+
var limitS = url && url.searchParams.get("limit");
|
|
609
|
+
var limit = limitS == null ? 50 : parseInt(limitS, 10);
|
|
610
|
+
var list = await order.listRecent({ status: status || undefined, limit: limit });
|
|
611
|
+
_json(res, 200, list);
|
|
612
|
+
}),
|
|
613
|
+
async function (req, res) {
|
|
614
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
615
|
+
var statusRaw = url && url.searchParams.get("status");
|
|
616
|
+
// A bad ?status= filter falls back to "all" rather than erroring the
|
|
617
|
+
// page — the operator just sees everything, which is a safe default.
|
|
618
|
+
var status = null, notice = null;
|
|
619
|
+
if (statusRaw) {
|
|
620
|
+
try { await order.listRecent({ status: statusRaw, limit: 1 }); status = statusRaw; }
|
|
621
|
+
catch (_e) { notice = "Unknown status filter — showing all orders."; }
|
|
622
|
+
}
|
|
623
|
+
var list = await order.listRecent({ status: status || undefined, limit: 100 });
|
|
624
|
+
_sendHtml(res, 200, renderAdminOrders({
|
|
625
|
+
shop_name: deps.shop_name, orders: list.rows || [],
|
|
626
|
+
status: status, notice: notice,
|
|
627
|
+
}));
|
|
628
|
+
},
|
|
629
|
+
));
|
|
564
630
|
|
|
565
|
-
|
|
566
|
-
|
|
631
|
+
router.get("/admin/orders/:id", _pageOrApi(true,
|
|
632
|
+
R(async function (req, res) {
|
|
567
633
|
var o = await order.get(req.params.id);
|
|
568
634
|
if (!o) return _problem(res, 404, "order-not-found");
|
|
569
|
-
|
|
635
|
+
_json(res, 200, o);
|
|
636
|
+
}),
|
|
637
|
+
async function (req, res) {
|
|
638
|
+
var o;
|
|
639
|
+
// A malformed id throws (defensive id reader) — render 404, not 500.
|
|
640
|
+
try { o = await order.get(req.params.id); }
|
|
641
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
|
|
642
|
+
if (!o) return _sendHtml(res, 404, renderAdminOrders({
|
|
643
|
+
shop_name: deps.shop_name, orders: [], notice: "Order not found.",
|
|
644
|
+
}));
|
|
645
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
646
|
+
_sendHtml(res, 200, renderAdminOrder({
|
|
647
|
+
shop_name: deps.shop_name,
|
|
648
|
+
order: o,
|
|
649
|
+
transitions: order.transitionsFrom(o.status),
|
|
650
|
+
// Refund moves money, so the console only offers it when a payment
|
|
651
|
+
// provider is wired AND the order has a captured intent to refund.
|
|
652
|
+
can_refund: !!(payment && o.payment_intent_id),
|
|
653
|
+
moved: url && url.searchParams.get("moved"),
|
|
654
|
+
notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this order." : null,
|
|
655
|
+
}));
|
|
656
|
+
},
|
|
657
|
+
));
|
|
658
|
+
|
|
659
|
+
router.post("/admin/orders/:id/transition", _pageOrApi(false,
|
|
660
|
+
W("order.transition", async function (req, res) {
|
|
570
661
|
var body = req.body || {};
|
|
571
|
-
|
|
572
|
-
var
|
|
662
|
+
if (!body.event) throw new TypeError("admin.order.transition: body.event required");
|
|
663
|
+
var o = await order.transition(req.params.id, body.event, { reason: body.reason, metadata: body.metadata });
|
|
664
|
+
_json(res, 200, o);
|
|
665
|
+
return o;
|
|
666
|
+
}),
|
|
667
|
+
async function (req, res) {
|
|
668
|
+
// Browser form → run the transition, then redirect back to the
|
|
669
|
+
// detail (PRG). A bad id (TypeError) or an FSM refusal (the move
|
|
670
|
+
// isn't legal from this status) surfaces as a notice, not a 500;
|
|
671
|
+
// any other failure propagates.
|
|
672
|
+
var id = req.params.id;
|
|
673
|
+
var event = (req.body || {}).event;
|
|
674
|
+
if (!event) return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
573
675
|
try {
|
|
574
|
-
|
|
575
|
-
payment_intent: o.payment_intent_id,
|
|
576
|
-
amount_minor: body.amount_minor || undefined,
|
|
577
|
-
reason: body.reason || undefined,
|
|
578
|
-
metadata: { order_id: o.id },
|
|
579
|
-
}, refundIdempotencyKey);
|
|
676
|
+
await order.transition(id, event, { reason: "admin:console" });
|
|
580
677
|
} catch (e) {
|
|
581
|
-
|
|
678
|
+
if (e instanceof TypeError || (e && e.code && /FSM|TRANSITION|GUARD/i.test(e.code))) {
|
|
679
|
+
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
680
|
+
}
|
|
681
|
+
throw e;
|
|
582
682
|
}
|
|
683
|
+
_b().audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.transition", outcome: "success", metadata: { id: id, event: event } });
|
|
684
|
+
_redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?moved=1");
|
|
685
|
+
},
|
|
686
|
+
));
|
|
687
|
+
|
|
688
|
+
// ---- refunds --------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
if (payment) {
|
|
691
|
+
// Issue the actual payment-provider refund, then advance the order
|
|
692
|
+
// FSM. Shared by the JSON API and the browser console so a console
|
|
693
|
+
// "Refund" moves the money first (never a bare state change — that
|
|
694
|
+
// would mark an order refunded with the customer never paid back).
|
|
695
|
+
async function _refundOrder(o, body) {
|
|
696
|
+
var refundIdempotencyKey = "refund:" + o.id + ":" + (body.idempotency_suffix || _b().uuid.v7());
|
|
697
|
+
var refund = await payment.refund({
|
|
698
|
+
payment_intent: o.payment_intent_id,
|
|
699
|
+
amount_minor: body.amount_minor || undefined,
|
|
700
|
+
reason: body.reason || undefined,
|
|
701
|
+
metadata: { order_id: o.id },
|
|
702
|
+
}, refundIdempotencyKey);
|
|
583
703
|
try {
|
|
584
704
|
await order.transition(o.id, "refund", {
|
|
585
705
|
reason: "admin:refund:" + (body.reason || "requested_by_customer"),
|
|
586
706
|
metadata: { stripe_refund_id: refund.id, amount_minor: refund.amount },
|
|
587
707
|
});
|
|
588
|
-
} catch (_e) { /* refund succeeded at
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
708
|
+
} catch (_e) { /* refund succeeded at the provider; transition refusal logged, surfaced via re-fetch */ }
|
|
709
|
+
return { refund: refund, order: await order.get(o.id) };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
router.post("/admin/orders/:id/refund", _pageOrApi(false,
|
|
713
|
+
W("order.refund", async function (req, res) {
|
|
714
|
+
var o = await order.get(req.params.id);
|
|
715
|
+
if (!o) return _problem(res, 404, "order-not-found");
|
|
716
|
+
if (!o.payment_intent_id) return _problem(res, 422, "no-payment-intent", "Order has no linked payment intent");
|
|
717
|
+
var result;
|
|
718
|
+
try {
|
|
719
|
+
result = await _refundOrder(o, req.body || {});
|
|
720
|
+
} catch (e) {
|
|
721
|
+
return _problem(res, 502, "stripe-refund-failed", (e && e.message) || String(e));
|
|
722
|
+
}
|
|
723
|
+
_json(res, 200, result);
|
|
724
|
+
return { id: o.id };
|
|
725
|
+
}),
|
|
726
|
+
async function (req, res) {
|
|
727
|
+
// Browser console: full refund (partial refunds stay on the JSON
|
|
728
|
+
// API via amount_minor), then PRG back to the detail. A bad id or
|
|
729
|
+
// missing payment intent surfaces as a notice, never a 500.
|
|
730
|
+
var id = req.params.id;
|
|
731
|
+
var o;
|
|
732
|
+
try { o = await order.get(id); }
|
|
733
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; o = null; }
|
|
734
|
+
if (!o || !o.payment_intent_id) {
|
|
735
|
+
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
await _refundOrder(o, { reason: "requested_by_customer" });
|
|
739
|
+
} catch (_e) {
|
|
740
|
+
// Provider refund failed — the order is untouched (the FSM
|
|
741
|
+
// transition only runs after a successful refund).
|
|
742
|
+
return _redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?err=1");
|
|
743
|
+
}
|
|
744
|
+
_b().audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.refund", outcome: "success", metadata: { id: id } });
|
|
745
|
+
_redirect(res, "/admin/orders/" + encodeURIComponent(id) + "?moved=1");
|
|
746
|
+
},
|
|
747
|
+
));
|
|
593
748
|
}
|
|
594
749
|
|
|
595
750
|
// ---- reviews (moderation) -------------------------------------------
|
|
@@ -1179,6 +1334,15 @@ var DASHBOARD_LAYOUT =
|
|
|
1179
1334
|
" .btn:hover { background:var(--accent-d); border-color:var(--accent-d); }\n" +
|
|
1180
1335
|
" .btn--ghost { background:transparent; color:var(--ink); border-color:var(--ink); }\n" +
|
|
1181
1336
|
" .btn--ghost:hover { background:var(--ink); color:var(--paper); }\n" +
|
|
1337
|
+
" .btn--danger { background:transparent; color:var(--accent-d); border-color:var(--accent-d); }\n" +
|
|
1338
|
+
" .btn--danger:hover { background:var(--accent-d); color:var(--paper); }\n" +
|
|
1339
|
+
" .order-filters { display:flex; flex-wrap:wrap; gap:.5rem; margin-bottom:1.25rem; }\n" +
|
|
1340
|
+
" .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" +
|
|
1341
|
+
" .chip:hover { border-color:var(--accent); }\n" +
|
|
1342
|
+
" .chip--on { background:var(--ink); color:var(--paper); border-color:var(--ink); }\n" +
|
|
1343
|
+
" .order-totals { width:100%; }\n" +
|
|
1344
|
+
" .order-totals td { padding:.3rem 0; }\n" +
|
|
1345
|
+
" .order-actions { display:flex; flex-wrap:wrap; gap:.6rem; }\n" +
|
|
1182
1346
|
" .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
|
|
1183
1347
|
" .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" +
|
|
1184
1348
|
" .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
|
|
@@ -1189,6 +1353,11 @@ var DASHBOARD_LAYOUT =
|
|
|
1189
1353
|
" .banner--ok { background:#e9f5ec; border:1px solid #bfe1c9; color:#1f6b3a; }\n" +
|
|
1190
1354
|
" .banner--err { background:#fff1eb; border:1px solid #f6c5af; color:var(--accent-d); }\n" +
|
|
1191
1355
|
" .actions-row { display:flex; gap:.75rem; flex-wrap:wrap; align-items:center; margin-top:1.5rem; }\n" +
|
|
1356
|
+
" .admin-nav { background:var(--paper); border-bottom:1px solid var(--hair); }\n" +
|
|
1357
|
+
" .admin-nav__inner { max-width:80rem; margin:0 auto; padding:0 1.5rem; display:flex; gap:.1rem; flex-wrap:wrap; }\n" +
|
|
1358
|
+
" .admin-nav a { display:inline-block; padding:.85rem .9rem; color:var(--ink-2); text-decoration:none; font-size:.84rem; font-weight:600; border-bottom:2px solid transparent; }\n" +
|
|
1359
|
+
" .admin-nav a:hover { color:var(--ink); }\n" +
|
|
1360
|
+
" .admin-nav a.active { color:var(--accent); border-bottom-color:var(--accent); }\n" +
|
|
1192
1361
|
" </style>\n" +
|
|
1193
1362
|
"</head>\n" +
|
|
1194
1363
|
"<body>\n" +
|
|
@@ -1198,6 +1367,7 @@ var DASHBOARD_LAYOUT =
|
|
|
1198
1367
|
" <span style=\"font-size:.8rem; color:var(--mute);\">{{window_label}}</span>\n" +
|
|
1199
1368
|
" </div>\n" +
|
|
1200
1369
|
" </header>\n" +
|
|
1370
|
+
" {{nav}}\n" +
|
|
1201
1371
|
" <main>{{body}}</main>\n" +
|
|
1202
1372
|
"</body>\n" +
|
|
1203
1373
|
"</html>\n";
|
|
@@ -1305,7 +1475,7 @@ function renderDashboard(opts) {
|
|
|
1305
1475
|
? recent.map(function (o) {
|
|
1306
1476
|
var statusClass = _htmlEscape(o.status);
|
|
1307
1477
|
return "<tr>" +
|
|
1308
|
-
"<td><
|
|
1478
|
+
"<td><a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(o.id) + "\">" + _htmlEscape(o.id.slice(0, 8)) + "</a></td>" +
|
|
1309
1479
|
"<td><span class=\"status-pill " + statusClass + "\">" + _htmlEscape(o.status) + "</span></td>" +
|
|
1310
1480
|
"<td class=\"num\">" + _htmlEscape(pricing.format(o.grand_total_minor, o.currency)) + "</td>" +
|
|
1311
1481
|
"</tr>";
|
|
@@ -1326,12 +1496,12 @@ function renderDashboard(opts) {
|
|
|
1326
1496
|
|
|
1327
1497
|
var body = statsBlock + otherCurrencies + spark + twoCol;
|
|
1328
1498
|
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
body
|
|
1333
|
-
|
|
1334
|
-
|
|
1499
|
+
return _renderAdminShell(
|
|
1500
|
+
opts.shop_name,
|
|
1501
|
+
"Window: last 30 days (operator-tunable via ?since=&until=)",
|
|
1502
|
+
body,
|
|
1503
|
+
"dashboard",
|
|
1504
|
+
);
|
|
1335
1505
|
}
|
|
1336
1506
|
|
|
1337
1507
|
function _statCard(label, value, accent) {
|
|
@@ -1341,12 +1511,33 @@ function _statCard(label, value, accent) {
|
|
|
1341
1511
|
|
|
1342
1512
|
// ---- admin web pages (login / landing / setup wizard) -------------------
|
|
1343
1513
|
|
|
1344
|
-
|
|
1514
|
+
// Console nav — one entry per HTML console screen. `active` highlights
|
|
1515
|
+
// the current page; `null`/`false` (unauthenticated pages like the
|
|
1516
|
+
// sign-in form) renders no nav at all.
|
|
1517
|
+
var ADMIN_NAV_ITEMS = [
|
|
1518
|
+
{ key: "home", href: "/admin", label: "Home" },
|
|
1519
|
+
{ key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
|
|
1520
|
+
{ key: "products", href: "/admin/products", label: "Products" },
|
|
1521
|
+
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
1522
|
+
{ key: "integrations", href: "/admin/integrations", label: "Integrations" },
|
|
1523
|
+
{ key: "setup", href: "/admin/setup", label: "Setup" },
|
|
1524
|
+
];
|
|
1525
|
+
function _adminNav(active) {
|
|
1526
|
+
if (active === null || active === undefined || active === false) return "";
|
|
1527
|
+
var links = ADMIN_NAV_ITEMS.map(function (it) {
|
|
1528
|
+
return "<a href=\"" + it.href + "\"" + (it.key === active ? " class=\"active\"" : "") + ">" +
|
|
1529
|
+
_htmlEscape(it.label) + "</a>";
|
|
1530
|
+
}).join("");
|
|
1531
|
+
return "<nav class=\"admin-nav\"><div class=\"admin-nav__inner\">" + links + "</div></nav>";
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function _renderAdminShell(shopName, subtitle, bodyHtml, active) {
|
|
1345
1535
|
return _renderTemplate(DASHBOARD_LAYOUT, {
|
|
1346
1536
|
shop_name: shopName || "blamejs.shop",
|
|
1347
1537
|
window_label: subtitle || "",
|
|
1538
|
+
nav: "RAW_NAV",
|
|
1348
1539
|
body: "RAW_BODY",
|
|
1349
|
-
}).replace("RAW_BODY", bodyHtml);
|
|
1540
|
+
}).replace("RAW_NAV", _adminNav(active)).replace("RAW_BODY", bodyHtml);
|
|
1350
1541
|
}
|
|
1351
1542
|
|
|
1352
1543
|
function renderAdminLogin(opts) {
|
|
@@ -1365,7 +1556,7 @@ function renderAdminLogin(opts) {
|
|
|
1365
1556
|
"<button type=\"submit\" class=\"btn\">Sign in</button>" +
|
|
1366
1557
|
"</form>" +
|
|
1367
1558
|
"</section>";
|
|
1368
|
-
return _renderAdminShell(opts.shop_name, "Sign in", body);
|
|
1559
|
+
return _renderAdminShell(opts.shop_name, "Sign in", body, null);
|
|
1369
1560
|
}
|
|
1370
1561
|
|
|
1371
1562
|
function renderAdminLanding(opts) {
|
|
@@ -1383,7 +1574,7 @@ function renderAdminLanding(opts) {
|
|
|
1383
1574
|
"</div>" +
|
|
1384
1575
|
"<div class=\"actions-row\"><form method=\"post\" action=\"/admin/logout\"><button type=\"submit\" class=\"btn btn--ghost\">Sign out</button></form></div>" +
|
|
1385
1576
|
"</section>";
|
|
1386
|
-
return _renderAdminShell(opts.shop_name, "", body);
|
|
1577
|
+
return _renderAdminShell(opts.shop_name, "", body, "home");
|
|
1387
1578
|
}
|
|
1388
1579
|
|
|
1389
1580
|
function _setupField(label, name, value, type, hint, extra) {
|
|
@@ -1412,7 +1603,7 @@ function renderAdminSetup(opts) {
|
|
|
1412
1603
|
"<a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
|
|
1413
1604
|
"</form>" +
|
|
1414
1605
|
"</section>";
|
|
1415
|
-
return _renderAdminShell(opts.shop_name, "Setup", body);
|
|
1606
|
+
return _renderAdminShell(opts.shop_name, "Setup", body, "setup");
|
|
1416
1607
|
}
|
|
1417
1608
|
|
|
1418
1609
|
// Each integration is off until the operator supplies its credentials.
|
|
@@ -1464,7 +1655,166 @@ function renderAdminIntegrations(opts) {
|
|
|
1464
1655
|
"<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>" +
|
|
1465
1656
|
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
|
|
1466
1657
|
"</section>";
|
|
1467
|
-
return _renderAdminShell(opts.shop_name, "Integrations", body);
|
|
1658
|
+
return _renderAdminShell(opts.shop_name, "Integrations", body, "integrations");
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function renderAdminProducts(opts) {
|
|
1662
|
+
opts = opts || {};
|
|
1663
|
+
var products = opts.products || [];
|
|
1664
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Product created.</div>" : "";
|
|
1665
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
1666
|
+
var rows = products.map(function (p) {
|
|
1667
|
+
var cls = p.status === "active" ? "paid" : (p.status === "archived" ? "refunded" : "pending");
|
|
1668
|
+
var action = p.status === "archived"
|
|
1669
|
+
? "<form method=\"post\" action=\"/admin/products/" + _htmlEscape(p.id) + "/restore\"><button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>"
|
|
1670
|
+
: "<form method=\"post\" action=\"/admin/products/" + _htmlEscape(p.id) + "/archive\"><button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>";
|
|
1671
|
+
return "<tr><td><strong>" + _htmlEscape(p.title) + "</strong></td>" +
|
|
1672
|
+
"<td><code class=\"order-id\">" + _htmlEscape(p.slug) + "</code></td>" +
|
|
1673
|
+
"<td><span class=\"status-pill " + cls + "\">" + _htmlEscape(p.status) + "</span></td>" +
|
|
1674
|
+
"<td>" + action + "</td></tr>";
|
|
1675
|
+
}).join("");
|
|
1676
|
+
var table = products.length
|
|
1677
|
+
? "<div class=\"panel\"><table><thead><tr><th>Title</th><th>Slug</th><th>Status</th><th>Action</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
|
|
1678
|
+
: "<p class=\"empty\">No products yet — create your first one below.</p>";
|
|
1679
|
+
var body =
|
|
1680
|
+
"<section><h2>Products</h2>" + created + notice + table +
|
|
1681
|
+
"<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
|
|
1682
|
+
"<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">New product</h3>" +
|
|
1683
|
+
"<form method=\"post\" action=\"/admin/products\">" +
|
|
1684
|
+
_setupField("Title", "title", "", "text", "", " maxlength=\"200\" required") +
|
|
1685
|
+
_setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — the storefront URL.", " maxlength=\"200\" required") +
|
|
1686
|
+
"<label class=\"form-field\"><span>Status</span><select name=\"status\"><option value=\"draft\">Draft</option><option value=\"active\">Active</option></select></label>" +
|
|
1687
|
+
_setupField("Description", "description", "", "text", "", " maxlength=\"2000\"") +
|
|
1688
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create product</button></div>" +
|
|
1689
|
+
"</form>" +
|
|
1690
|
+
"</div>" +
|
|
1691
|
+
"</section>";
|
|
1692
|
+
return _renderAdminShell(opts.shop_name, "Products", body, "products");
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// created_at / updated_at are epoch-ms numbers (order._now()); render a
|
|
1696
|
+
// short, locale-neutral date. Guards against a string or a bad value so a
|
|
1697
|
+
// malformed row never throws inside the template.
|
|
1698
|
+
function _fmtDate(v) {
|
|
1699
|
+
var n = typeof v === "number" ? v : Date.parse(v);
|
|
1700
|
+
if (!isFinite(n)) return "—";
|
|
1701
|
+
return new Date(n).toISOString().slice(0, 10);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// The status values an operator can filter the orders list by — drives the
|
|
1705
|
+
// filter chips. Kept in render-layer order (lifecycle, then terminal).
|
|
1706
|
+
var ORDER_STATUS_FILTERS = ["pending", "paid", "fulfilling", "shipped", "delivered", "refunded", "cancelled"];
|
|
1707
|
+
|
|
1708
|
+
function renderAdminOrders(opts) {
|
|
1709
|
+
opts = opts || {};
|
|
1710
|
+
var orders = opts.orders || [];
|
|
1711
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
1712
|
+
var active = opts.status || null;
|
|
1713
|
+
|
|
1714
|
+
var chips = "<div class=\"order-filters\">" +
|
|
1715
|
+
"<a class=\"chip" + (active ? "" : " chip--on") + "\" href=\"/admin/orders\">All</a>" +
|
|
1716
|
+
ORDER_STATUS_FILTERS.map(function (s) {
|
|
1717
|
+
return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/orders?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
|
|
1718
|
+
}).join("") +
|
|
1719
|
+
"</div>";
|
|
1720
|
+
|
|
1721
|
+
var rows = orders.map(function (o) {
|
|
1722
|
+
var items = (o.lines || []).reduce(function (n, l) { return n + (l.qty || 0); }, 0);
|
|
1723
|
+
return "<tr>" +
|
|
1724
|
+
"<td><a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(o.id) + "\">" + _htmlEscape(o.id.slice(0, 8)) + "</a></td>" +
|
|
1725
|
+
"<td><span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></td>" +
|
|
1726
|
+
"<td class=\"num\">" + _htmlEscape(String(items)) + "</td>" +
|
|
1727
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(o.grand_total_minor, o.currency)) + "</td>" +
|
|
1728
|
+
"<td>" + _htmlEscape(_fmtDate(o.created_at)) + "</td>" +
|
|
1729
|
+
"</tr>";
|
|
1730
|
+
}).join("");
|
|
1731
|
+
|
|
1732
|
+
var table = orders.length
|
|
1733
|
+
? "<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>"
|
|
1734
|
+
: "<p class=\"empty\">No orders" + (active ? " with status “" + _htmlEscape(active) + "”" : " yet") + ".</p>";
|
|
1735
|
+
|
|
1736
|
+
var body = "<section><h2>Orders</h2>" + notice + chips + table + "</section>";
|
|
1737
|
+
return _renderAdminShell(opts.shop_name, "Orders", body, "orders");
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
function renderAdminOrder(opts) {
|
|
1741
|
+
opts = opts || {};
|
|
1742
|
+
var o = opts.order;
|
|
1743
|
+
var transitions = opts.transitions || [];
|
|
1744
|
+
var moved = opts.moved ? "<div class=\"banner banner--ok\">Order updated.</div>" : "";
|
|
1745
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
1746
|
+
|
|
1747
|
+
var lineRows = (o.lines || []).map(function (l) {
|
|
1748
|
+
return "<tr>" +
|
|
1749
|
+
"<td>" + _htmlEscape(l.sku) + "</td>" +
|
|
1750
|
+
"<td class=\"num\">" + _htmlEscape(String(l.qty)) + "</td>" +
|
|
1751
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(l.unit_amount_minor, l.unit_currency)) + "</td>" +
|
|
1752
|
+
"<td class=\"num\">" + _htmlEscape(pricing.format(l.line_total_minor, l.unit_currency)) + "</td>" +
|
|
1753
|
+
"</tr>";
|
|
1754
|
+
}).join("");
|
|
1755
|
+
var linesTable = (o.lines && o.lines.length)
|
|
1756
|
+
? "<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>"
|
|
1757
|
+
: "<p class=\"empty\">No line items recorded.</p>";
|
|
1758
|
+
|
|
1759
|
+
function _total(label, minor, strong) {
|
|
1760
|
+
return "<tr><td>" + _htmlEscape(label) + "</td><td class=\"num\">" +
|
|
1761
|
+
(strong ? "<strong>" : "") + _htmlEscape(pricing.format(minor, o.currency)) + (strong ? "</strong>" : "") +
|
|
1762
|
+
"</td></tr>";
|
|
1763
|
+
}
|
|
1764
|
+
var totals = "<table class=\"order-totals\"><tbody>" +
|
|
1765
|
+
_total("Subtotal", o.subtotal_minor, false) +
|
|
1766
|
+
(o.discount_minor ? _total("Discount", -o.discount_minor, false) : "") +
|
|
1767
|
+
_total("Tax", o.tax_minor, false) +
|
|
1768
|
+
_total("Shipping", o.shipping_minor, false) +
|
|
1769
|
+
_total("Total", o.grand_total_minor, true) +
|
|
1770
|
+
"</tbody></table>";
|
|
1771
|
+
|
|
1772
|
+
var ship = o.ship_to || {};
|
|
1773
|
+
var shipLines = [ship.name, ship.line1, ship.line2,
|
|
1774
|
+
[ship.city, ship.region, ship.postal_code].filter(Boolean).join(", "), ship.country]
|
|
1775
|
+
.filter(Boolean).map(function (s) { return _htmlEscape(String(s)); }).join("<br>");
|
|
1776
|
+
|
|
1777
|
+
// One form per legal next transition. `refund` is special: it moves
|
|
1778
|
+
// money, so it posts to the payment-refund endpoint (which issues the
|
|
1779
|
+
// provider refund THEN advances the FSM) rather than the bare
|
|
1780
|
+
// state-transition endpoint — and only when there's a captured payment
|
|
1781
|
+
// to refund. Every other move posts to /transition. A terminal status
|
|
1782
|
+
// (empty list) shows a note instead of buttons.
|
|
1783
|
+
var actionForms = transitions.map(function (t) {
|
|
1784
|
+
if (t.on === "refund") {
|
|
1785
|
+
if (!opts.can_refund) return ""; // no payment intent — nothing to refund here
|
|
1786
|
+
return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund\" style=\"display:inline;\">" +
|
|
1787
|
+
"<button class=\"btn btn--danger\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
|
|
1788
|
+
"</form>";
|
|
1789
|
+
}
|
|
1790
|
+
var danger = (t.on === "cancel");
|
|
1791
|
+
return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/transition\" style=\"display:inline;\">" +
|
|
1792
|
+
"<input type=\"hidden\" name=\"event\" value=\"" + _htmlEscape(t.on) + "\">" +
|
|
1793
|
+
"<button class=\"btn" + (danger ? " btn--danger" : "") + "\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
|
|
1794
|
+
"</form>";
|
|
1795
|
+
}).filter(Boolean).join(" ");
|
|
1796
|
+
var actions = actionForms || "<span class=\"meta\">This order is in a final state — no further changes.</span>";
|
|
1797
|
+
|
|
1798
|
+
var body =
|
|
1799
|
+
"<section style=\"max-width:48rem;\">" +
|
|
1800
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">← Orders</a></div>" +
|
|
1801
|
+
"<h2>Order <code class=\"order-id\">" + _htmlEscape(o.id.slice(0, 8)) + "</code> " +
|
|
1802
|
+
"<span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></h2>" +
|
|
1803
|
+
"<p class=\"meta\">Placed " + _htmlEscape(_fmtDate(o.created_at)) + " · last updated " + _htmlEscape(_fmtDate(o.updated_at)) +
|
|
1804
|
+
(o.payment_intent_id ? " · payment <code class=\"order-id\">" + _htmlEscape(o.payment_intent_id) + "</code>" : "") + "</p>" +
|
|
1805
|
+
moved + notice +
|
|
1806
|
+
"<div class=\"two-col\">" +
|
|
1807
|
+
"<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Items</h3>" + linesTable + "</div>" +
|
|
1808
|
+
"<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Ship to</h3>" +
|
|
1809
|
+
(shipLines || "<span class=\"meta\">No shipping address.</span>") +
|
|
1810
|
+
"<h3 style=\"font-size:.95rem; margin:1.25rem 0 .75rem;\">Totals</h3>" + totals +
|
|
1811
|
+
"</div>" +
|
|
1812
|
+
"</div>" +
|
|
1813
|
+
"<div class=\"panel\" style=\"margin-top:1.5rem;\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Actions</h3>" +
|
|
1814
|
+
"<div class=\"order-actions\">" + actions + "</div>" +
|
|
1815
|
+
"</div>" +
|
|
1816
|
+
"</section>";
|
|
1817
|
+
return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders");
|
|
1468
1818
|
}
|
|
1469
1819
|
|
|
1470
1820
|
module.exports = {
|
|
@@ -1475,4 +1825,7 @@ module.exports = {
|
|
|
1475
1825
|
renderAdminLanding: renderAdminLanding,
|
|
1476
1826
|
renderAdminSetup: renderAdminSetup,
|
|
1477
1827
|
renderAdminIntegrations: renderAdminIntegrations,
|
|
1828
|
+
renderAdminProducts: renderAdminProducts,
|
|
1829
|
+
renderAdminOrders: renderAdminOrders,
|
|
1830
|
+
renderAdminOrder: renderAdminOrder,
|
|
1478
1831
|
};
|