@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/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", W("product.create", async function (req, res) {
248
- var p = await catalog.products.create(req.body || {});
249
- _json(res, 201, p);
250
- return p;
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", R(async function (req, res) {
273
- var url = req.url ? new URL(req.url, "http://localhost") : null;
274
- var status = url && url.searchParams.get("status");
275
- var cursor = url && url.searchParams.get("cursor");
276
- var limitS = url && url.searchParams.get("limit");
277
- var limit = limitS == null ? 50 : parseInt(limitS, 10);
278
- var page = await catalog.products.list({ status: status || undefined, cursor: cursor || undefined, limit: limit });
279
- _json(res, 200, page);
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
- router.post("/admin/products/:id/archive", W("product.archive", async function (req, res) {
296
- var p = await catalog.products.archive(req.params.id);
297
- if (!p) return _problem(res, 404, "product-not-found");
298
- _json(res, 200, p);
299
- return p;
300
- }));
301
-
302
- router.post("/admin/products/:id/restore", W("product.restore", async function (req, res) {
303
- var p = await catalog.products.restore(req.params.id);
304
- if (!p) return _problem(res, 404, "product-not-found");
305
- _json(res, 200, p);
306
- return p;
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
- router.get("/admin/orders/:id", R(async function (req, res) {
550
- var o = await order.get(req.params.id);
551
- if (!o) return _problem(res, 404, "order-not-found");
552
- _json(res, 200, o);
553
- }));
554
-
555
- router.post("/admin/orders/:id/transition", W("order.transition", async function (req, res) {
556
- var body = req.body || {};
557
- if (!body.event) throw new TypeError("admin.order.transition: body.event required");
558
- var o = await order.transition(req.params.id, body.event, { reason: body.reason, metadata: body.metadata });
559
- _json(res, 200, o);
560
- return o;
561
- }));
562
-
563
- // ---- refunds --------------------------------------------------------
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
- if (payment) {
566
- router.post("/admin/orders/:id/refund", W("order.refund", async function (req, res) {
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
- if (!o.payment_intent_id) return _problem(res, 422, "no-payment-intent", "Order has no linked payment intent");
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
- var refundIdempotencyKey = "refund:" + o.id + ":" + (body.idempotency_suffix || _b().uuid.v7());
572
- var refund;
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
- refund = await payment.refund({
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
- return _problem(res, 502, "stripe-refund-failed", (e && e.message) || String(e));
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 Stripe; transition refusal logged, surface to operator via re-fetch */ }
589
- var updated = await order.get(o.id);
590
- _json(res, 200, { refund: refund, order: updated });
591
- return { id: o.id };
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><span class=\"order-id\">" + _htmlEscape(o.id.slice(0, 8)) + "</span></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
- var html = _renderTemplate(DASHBOARD_LAYOUT, {
1330
- shop_name: opts.shop_name || "blamejs.shop",
1331
- window_label: "Window: last 30 days (operator-tunable via ?since=&until=)",
1332
- body: "RAW_BODY",
1333
- }).replace("RAW_BODY", body);
1334
- return html;
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
- function _renderAdminShell(shopName, subtitle, bodyHtml) {
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\">&larr; 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
  };