@blamejs/blamejs-shop 0.2.13 → 0.2.14

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +1 -1
  3. package/lib/admin.js +616 -21
  4. package/lib/asset-manifest.json +3 -3
  5. package/lib/catalog.js +19 -0
  6. package/lib/vendor/MANIFEST.json +3 -3
  7. package/lib/vendor/blamejs/CHANGELOG.md +16 -0
  8. package/lib/vendor/blamejs/api-snapshot.json +6 -2
  9. package/lib/vendor/blamejs/lib/agent-idempotency.js +50 -10
  10. package/lib/vendor/blamejs/lib/agent-orchestrator.js +58 -5
  11. package/lib/vendor/blamejs/lib/agent-saga.js +8 -4
  12. package/lib/vendor/blamejs/lib/agent-tenant.js +56 -4
  13. package/lib/vendor/blamejs/lib/agent-trace.js +4 -3
  14. package/lib/vendor/blamejs/lib/ai-adverse-decision.js +1 -1
  15. package/lib/vendor/blamejs/lib/audit-daily-review.js +3 -3
  16. package/lib/vendor/blamejs/lib/audit.js +11 -8
  17. package/lib/vendor/blamejs/lib/calendar.js +14 -10
  18. package/lib/vendor/blamejs/lib/circuit-breaker.js +6 -4
  19. package/lib/vendor/blamejs/lib/compliance-ai-act-transparency.js +2 -2
  20. package/lib/vendor/blamejs/lib/compliance.js +7 -9
  21. package/lib/vendor/blamejs/lib/cookies.js +2 -2
  22. package/lib/vendor/blamejs/lib/crypto-field.js +8 -3
  23. package/lib/vendor/blamejs/lib/data-act.js +4 -3
  24. package/lib/vendor/blamejs/lib/file-upload.js +18 -1
  25. package/lib/vendor/blamejs/lib/mail-bimi.js +3 -2
  26. package/lib/vendor/blamejs/lib/mail-crypto-pgp.js +7 -11
  27. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +6 -5
  28. package/lib/vendor/blamejs/lib/mail-crypto.js +5 -5
  29. package/lib/vendor/blamejs/lib/mail-store.js +3 -2
  30. package/lib/vendor/blamejs/lib/mcp.js +2 -3
  31. package/lib/vendor/blamejs/lib/middleware/age-gate.js +10 -6
  32. package/lib/vendor/blamejs/lib/middleware/ai-act-disclosure.js +1 -1
  33. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +3 -3
  34. package/lib/vendor/blamejs/lib/middleware/compose-pipeline.js +1 -1
  35. package/lib/vendor/blamejs/lib/middleware/csp-nonce.js +2 -2
  36. package/lib/vendor/blamejs/lib/middleware/flag-context.js +1 -1
  37. package/lib/vendor/blamejs/lib/middleware/require-auth.js +2 -1
  38. package/lib/vendor/blamejs/lib/queue-redis.js +9 -0
  39. package/lib/vendor/blamejs/lib/queue.js +17 -1
  40. package/lib/vendor/blamejs/lib/retry.js +26 -0
  41. package/lib/vendor/blamejs/lib/router.js +4 -1
  42. package/lib/vendor/blamejs/lib/safe-decompress.js +3 -2
  43. package/lib/vendor/blamejs/lib/safe-icap.js +3 -2
  44. package/lib/vendor/blamejs/lib/safe-mime.js +1 -1
  45. package/lib/vendor/blamejs/lib/safe-smtp.js +1 -1
  46. package/lib/vendor/blamejs/lib/sec-cyber.js +1 -1
  47. package/lib/vendor/blamejs/lib/storage.js +20 -16
  48. package/lib/vendor/blamejs/lib/vault/index.js +1 -0
  49. package/lib/vendor/blamejs/lib/vault-aad.js +2 -2
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.13.25.json +39 -0
  52. package/lib/vendor/blamejs/release-notes/v0.13.26.json +31 -0
  53. package/lib/vendor/blamejs/release-notes/v0.13.27.json +34 -0
  54. package/lib/vendor/blamejs/release-notes/v0.13.28.json +30 -0
  55. package/lib/vendor/blamejs/release-notes/v0.13.29.json +30 -0
  56. package/lib/vendor/blamejs/release-notes/v0.13.30.json +30 -0
  57. package/lib/vendor/blamejs/release-notes/v0.13.31.json +26 -0
  58. package/lib/vendor/blamejs/release-notes/v0.13.32.json +34 -0
  59. package/lib/vendor/blamejs/test/20-db.js +30 -0
  60. package/lib/vendor/blamejs/test/integration/queue-redis.test.js +14 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +37 -0
  62. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +37 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/agent-tenant.test.js +26 -0
  64. package/lib/vendor/blamejs/test/layer-0-primitives/audit-daily-review.test.js +9 -0
  65. package/lib/vendor/blamejs/test/layer-0-primitives/retry.test.js +25 -0
  66. package/package.json +1 -1
package/lib/admin.js CHANGED
@@ -138,6 +138,25 @@ function _strictMinorInt(value, prefix, label) {
138
138
  return n;
139
139
  }
140
140
 
141
+ // Parse a "k=v, k=v" form string into a variant options attribute map.
142
+ // An empty / whitespace string yields `{}` (no options). A pair missing
143
+ // its `=` is skipped rather than throwing — the catalog primitive does
144
+ // the authoritative validation on the resulting object. Keys + values
145
+ // are trimmed; a duplicate key keeps the last value.
146
+ function _parseOptionsString(s) {
147
+ if (s == null) return {};
148
+ if (typeof s !== "string") return {};
149
+ var out = {};
150
+ s.split(",").forEach(function (pair) {
151
+ var eq = pair.indexOf("=");
152
+ if (eq === -1) return;
153
+ var k = pair.slice(0, eq).trim();
154
+ var v = pair.slice(eq + 1).trim();
155
+ if (k) out[k] = v;
156
+ });
157
+ return out;
158
+ }
159
+
141
160
  // ---- bearer auth --------------------------------------------------------
142
161
 
143
162
  function _readBearer(req) {
@@ -401,11 +420,77 @@ function mount(router, deps) {
401
420
  },
402
421
  ));
403
422
 
404
- router.get("/admin/products/:id", R(async function (req, res) {
405
- var p = await catalog.products.get(req.params.id);
406
- if (!p) return _problem(res, 404, "product-not-found");
407
- _json(res, 200, p);
408
- }));
423
+ // Default currency for the price-set form — read from shop config when
424
+ // wired, falling back to USD. A config read failure (unconfigured store)
425
+ // is non-fatal: the form just defaults to USD.
426
+ async function _defaultCurrency() {
427
+ if (!deps.config) return "USD";
428
+ try {
429
+ var c = await deps.config.get("shop.currency", "USD");
430
+ return (typeof c === "string" && /^[A-Z]{3}$/.test(c)) ? c : "USD";
431
+ } catch (_e) { return "USD"; }
432
+ }
433
+
434
+ // Hydrate the full detail model: the product, its variants, each
435
+ // variant's price-per-currency (current + history), and the product's
436
+ // media. Throws TypeError on a malformed id (defensive id reader) — the
437
+ // caller maps that to a 404, never a 500.
438
+ async function _productDetailModel(id) {
439
+ var p = await catalog.products.get(id);
440
+ if (!p) return null;
441
+ var variants = await catalog.variants.listForProduct(id);
442
+ var pricesByVariant = {};
443
+ for (var i = 0; i < variants.length; i += 1) {
444
+ var v = variants[i];
445
+ var currencies = await catalog.prices.currencies(v.id);
446
+ var perCurrency = [];
447
+ for (var j = 0; j < currencies.length; j += 1) {
448
+ var cur = currencies[j];
449
+ perCurrency.push({
450
+ currency: cur,
451
+ current: await catalog.prices.current(v.id, cur),
452
+ history: await catalog.prices.history(v.id, cur),
453
+ });
454
+ }
455
+ pricesByVariant[v.id] = { currencies: perCurrency };
456
+ }
457
+ var media = await catalog.media.listForProduct(id);
458
+ return { product: p, variants: variants, prices_by_variant: pricesByVariant, media: media };
459
+ }
460
+
461
+ // Detail content-negotiates: bearer → JSON (product + variants + prices
462
+ // + media); browser → the full management screen. A bad / unknown id is
463
+ // a 404 page, never a 500.
464
+ router.get("/admin/products/:id", _pageOrApi(true,
465
+ R(async function (req, res) {
466
+ var model;
467
+ try { model = await _productDetailModel(req.params.id); }
468
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "product-not-found", e.message); throw e; }
469
+ if (!model) return _problem(res, 404, "product-not-found");
470
+ _json(res, 200, model);
471
+ }),
472
+ async function (req, res) {
473
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
474
+ var model;
475
+ try { model = await _productDetailModel(req.params.id); }
476
+ catch (e) { if (!(e instanceof TypeError)) throw e; model = null; }
477
+ if (!model) {
478
+ var page = await catalog.products.list({ limit: 100 });
479
+ return _sendHtml(res, 404, renderAdminProducts({
480
+ shop_name: deps.shop_name, nav_available: navAvailable, products: page.rows || [], notice: "Product not found.",
481
+ }));
482
+ }
483
+ _sendHtml(res, 200, renderAdminProduct({
484
+ shop_name: deps.shop_name, nav_available: navAvailable,
485
+ product: model.product, variants: model.variants,
486
+ prices_by_variant: model.prices_by_variant, media: model.media,
487
+ asset_prefix: assetPrefix, upload_available: !!r2,
488
+ default_currency: await _defaultCurrency(),
489
+ saved: url && url.searchParams.get("saved"),
490
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
491
+ }));
492
+ },
493
+ ));
409
494
 
410
495
  router.patch("/admin/products/:id", W("product.update", async function (req, res) {
411
496
  var p = await catalog.products.update(req.params.id, req.body || {});
@@ -414,6 +499,40 @@ function mount(router, deps) {
414
499
  return p;
415
500
  }));
416
501
 
502
+ // Browser POST alias for the product fields edit (HTML forms can't
503
+ // PATCH). Bearer clients keep using PATCH /admin/products/:id. Bad input
504
+ // re-renders the detail with a notice (?err=1), never a 500.
505
+ router.post("/admin/products/:id/edit", _pageOrApi(false,
506
+ W("product.update", async function (req, res) {
507
+ var p;
508
+ try { p = await catalog.products.update(req.params.id, req.body || {}); }
509
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
510
+ if (!p) return _problem(res, 404, "product-not-found");
511
+ _json(res, 200, p);
512
+ return p;
513
+ }),
514
+ async function (req, res) {
515
+ var id = req.params.id;
516
+ var enc = encodeURIComponent(id);
517
+ var body = req.body || {};
518
+ var patch = {};
519
+ if (typeof body.slug === "string") patch.slug = body.slug.trim();
520
+ if (typeof body.title === "string") patch.title = body.title;
521
+ if (typeof body.description === "string") patch.description = body.description;
522
+ if (typeof body.status === "string" && body.status) patch.status = body.status;
523
+ try {
524
+ if (Object.keys(patch).length === 0) return _redirect(res, "/admin/products/" + enc + "?err=1");
525
+ var p = await catalog.products.update(id, patch);
526
+ if (!p) return _redirect(res, "/admin/products/" + enc + "?err=1");
527
+ } catch (e) {
528
+ if (!(e instanceof TypeError)) throw e;
529
+ return _redirect(res, "/admin/products/" + enc + "?err=1");
530
+ }
531
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".product.update", outcome: "success", metadata: { id: id } });
532
+ _redirect(res, "/admin/products/" + enc + "?saved=1");
533
+ },
534
+ ));
535
+
417
536
  function _productStateAction(verb, op, audit) {
418
537
  return _pageOrApi(false,
419
538
  W(audit, async function (req, res) {
@@ -443,6 +562,37 @@ function mount(router, deps) {
443
562
  return v;
444
563
  }));
445
564
 
565
+ // Browser POST alias for variant create from the product detail screen.
566
+ // The options string ("k=v, k=v") and the weight / requires_shipping
567
+ // selects come in as form strings — coerce them to the shapes the
568
+ // primitive expects before handing off. Bad input → ?err=1 notice.
569
+ router.post("/admin/products/:id/variants/create", _pageOrApi(false,
570
+ W("variant.create", async function (req, res) {
571
+ var v = await catalog.variants.create(req.params.id, req.body || {});
572
+ _json(res, 201, v);
573
+ return v;
574
+ }),
575
+ async function (req, res) {
576
+ var id = req.params.id;
577
+ var enc = encodeURIComponent(id);
578
+ var body = req.body || {};
579
+ try {
580
+ await catalog.variants.create(id, {
581
+ sku: typeof body.sku === "string" ? body.sku.trim() : body.sku,
582
+ title: typeof body.title === "string" ? body.title : undefined,
583
+ options: _parseOptionsString(body.options),
584
+ weight_grams: body.weight_grams === undefined || body.weight_grams === "" ? undefined : _strictMinorInt(body.weight_grams, "admin.variant", "weight_grams"),
585
+ requires_shipping: body.requires_shipping === undefined ? undefined : (String(body.requires_shipping) === "1"),
586
+ });
587
+ } catch (e) {
588
+ if (!(e instanceof TypeError)) throw e;
589
+ return _redirect(res, "/admin/products/" + enc + "?err=1");
590
+ }
591
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".variant.create", outcome: "success", metadata: { id: id } });
592
+ _redirect(res, "/admin/products/" + enc + "?saved=1");
593
+ },
594
+ ));
595
+
446
596
  router.patch("/admin/variants/:id", W("variant.update", async function (req, res) {
447
597
  var v = await catalog.variants.update(req.params.id, req.body || {});
448
598
  if (!v) return _problem(res, 404, "variant-not-found");
@@ -450,6 +600,51 @@ function mount(router, deps) {
450
600
  return v;
451
601
  }));
452
602
 
603
+ // Resolve the owning product id for a variant so a variant-scoped
604
+ // browser route can redirect back to the product detail page. Returns
605
+ // null for a missing / malformed id.
606
+ async function _variantProductId(variantId) {
607
+ var v = null;
608
+ try { v = await catalog.variants.get(variantId); }
609
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
610
+ return v ? v.product_id : null;
611
+ }
612
+
613
+ // Browser POST alias for variant edit (HTML forms can't PATCH).
614
+ router.post("/admin/variants/:id/edit", _pageOrApi(false,
615
+ W("variant.update", async function (req, res) {
616
+ var v;
617
+ try { v = await catalog.variants.update(req.params.id, req.body || {}); }
618
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
619
+ if (!v) return _problem(res, 404, "variant-not-found");
620
+ _json(res, 200, v);
621
+ return v;
622
+ }),
623
+ async function (req, res) {
624
+ var vid = req.params.id;
625
+ var body = req.body || {};
626
+ var productId = await _variantProductId(vid);
627
+ var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
628
+ if (!productId) return _redirect(res, back + "?err=1");
629
+ try {
630
+ var patch = {};
631
+ if (typeof body.sku === "string") patch.sku = body.sku.trim();
632
+ if (typeof body.title === "string") patch.title = body.title;
633
+ if (typeof body.options === "string") patch.options = _parseOptionsString(body.options);
634
+ if (body.weight_grams !== undefined && body.weight_grams !== "") patch.weight_grams = _strictMinorInt(body.weight_grams, "admin.variant", "weight_grams");
635
+ if (body.requires_shipping !== undefined) patch.requires_shipping = String(body.requires_shipping) === "1";
636
+ if (Object.keys(patch).length === 0) return _redirect(res, back + "?err=1");
637
+ var v = await catalog.variants.update(vid, patch);
638
+ if (!v) return _redirect(res, back + "?err=1");
639
+ } catch (e) {
640
+ if (!(e instanceof TypeError)) throw e;
641
+ return _redirect(res, back + "?err=1");
642
+ }
643
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".variant.update", outcome: "success", metadata: { id: vid } });
644
+ _redirect(res, back + "?saved=1");
645
+ },
646
+ ));
647
+
453
648
  router.delete("/admin/variants/:id", W("variant.delete", async function (req, res) {
454
649
  var ok = await catalog.variants.delete(req.params.id);
455
650
  if (!ok) return _problem(res, 404, "variant-not-found");
@@ -457,6 +652,56 @@ function mount(router, deps) {
457
652
  return { id: req.params.id };
458
653
  }));
459
654
 
655
+ // Browser confirm interstitial for variant delete — deleting a variant
656
+ // removes its prices + media via the FK cascade, so the console confirms
657
+ // first (the CSP forbids a client confirm() dialog). Reached by a GET
658
+ // link from the detail screen; bearer clients DELETE directly.
659
+ router.get("/admin/variants/:id/delete/confirm-page", _pageOrApi(true,
660
+ R(async function (_req, res) {
661
+ return _problem(res, 405, "use-canonical-endpoint", "DELETE /admin/variants/:id (or POST .../delete) directly for the JSON API");
662
+ }),
663
+ async function (req, res) {
664
+ var vid = req.params.id;
665
+ var v = null;
666
+ try { v = await catalog.variants.get(vid); }
667
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
668
+ var productId = v ? v.product_id : null;
669
+ var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
670
+ if (!v) return _redirect(res, back + "?err=1");
671
+ _sendHtml(res, 200, renderAdminConfirm({
672
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "products",
673
+ heading: "Delete this variant?",
674
+ consequence: "Deleting the variant is permanent — its prices and any media attached to it are removed too.",
675
+ detail: "Variant: " + (v.title || v.sku) + " (" + v.sku + ").",
676
+ action: "/admin/variants/" + _htmlEscape(vid) + "/delete",
677
+ confirm_label: "Delete variant",
678
+ cancel_href: back,
679
+ }));
680
+ },
681
+ ));
682
+
683
+ // Browser POST for variant delete (HTML forms can't DELETE). Bearer
684
+ // clients keep using DELETE /admin/variants/:id.
685
+ router.post("/admin/variants/:id/delete", _pageOrApi(false,
686
+ W("variant.delete", async function (req, res) {
687
+ var ok = await catalog.variants.delete(req.params.id);
688
+ if (!ok) return _problem(res, 404, "variant-not-found");
689
+ _json(res, 200, { ok: true });
690
+ return { id: req.params.id };
691
+ }),
692
+ async function (req, res) {
693
+ var vid = req.params.id;
694
+ var productId = await _variantProductId(vid);
695
+ var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
696
+ var ok = false;
697
+ try { ok = await catalog.variants.delete(vid); }
698
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
699
+ if (!ok) return _redirect(res, back + "?err=1");
700
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".variant.delete", outcome: "success", metadata: { id: vid } });
701
+ _redirect(res, back + "?saved=1");
702
+ },
703
+ ));
704
+
460
705
  // ---- prices ---------------------------------------------------------
461
706
 
462
707
  router.post("/admin/variants/:id/prices", W("price.set", async function (req, res) {
@@ -465,6 +710,37 @@ function mount(router, deps) {
465
710
  return p;
466
711
  }));
467
712
 
713
+ // Browser POST alias for setting a price from the product detail screen.
714
+ // The amount comes in as a form string — coerce it with the strict
715
+ // integer reader (refuses "29.99" / "" / "50abc") before handing to the
716
+ // primitive. Bad input → ?err=1 notice on the product detail.
717
+ router.post("/admin/variants/:id/prices/set", _pageOrApi(false,
718
+ W("price.set", async function (req, res) {
719
+ var p = await catalog.prices.set(req.params.id, req.body || {});
720
+ _json(res, 201, p);
721
+ return p;
722
+ }),
723
+ async function (req, res) {
724
+ var vid = req.params.id;
725
+ var body = req.body || {};
726
+ var productId = await _variantProductId(vid);
727
+ var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
728
+ if (!productId) return _redirect(res, back + "?err=1");
729
+ try {
730
+ var amount = _strictMinorInt(body.amount_minor, "admin.price", "amount_minor");
731
+ await catalog.prices.set(vid, {
732
+ currency: typeof body.currency === "string" ? body.currency.trim().toUpperCase() : body.currency,
733
+ amount_minor: amount,
734
+ });
735
+ } catch (e) {
736
+ if (!(e instanceof TypeError)) throw e;
737
+ return _redirect(res, back + "?err=1");
738
+ }
739
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".price.set", outcome: "success", metadata: { id: vid } });
740
+ _redirect(res, back + "?saved=1");
741
+ },
742
+ ));
743
+
468
744
  router.get("/admin/variants/:id/prices", R(async function (req, res) {
469
745
  var url = req.url ? new URL(req.url, "http://localhost") : null;
470
746
  var currency = url && url.searchParams.get("currency");
@@ -607,6 +883,36 @@ function mount(router, deps) {
607
883
  return m;
608
884
  }));
609
885
 
886
+ // Browser POST alias: attach an existing R2 object to a product from the
887
+ // product detail screen. The product id comes from the path; the
888
+ // primitive validates the r2_key + content_type. Bad input → ?err=1.
889
+ router.post("/admin/products/:id/media/attach", _pageOrApi(false,
890
+ W("media.attach", async function (req, res) {
891
+ var body = Object.assign({}, req.body || {}, { product_id: req.params.id });
892
+ var m = await catalog.media.attach(body);
893
+ _json(res, 201, m);
894
+ return m;
895
+ }),
896
+ async function (req, res) {
897
+ var id = req.params.id;
898
+ var enc = encodeURIComponent(id);
899
+ var body = req.body || {};
900
+ try {
901
+ await catalog.media.attach({
902
+ product_id: id,
903
+ r2_key: typeof body.r2_key === "string" ? body.r2_key.trim() : body.r2_key,
904
+ content_type: typeof body.content_type === "string" ? body.content_type.trim() : body.content_type,
905
+ alt_text: typeof body.alt_text === "string" ? body.alt_text : undefined,
906
+ });
907
+ } catch (e) {
908
+ if (!(e instanceof TypeError)) throw e;
909
+ return _redirect(res, "/admin/products/" + enc + "?err=1");
910
+ }
911
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.attach", outcome: "success", metadata: { id: id } });
912
+ _redirect(res, "/admin/products/" + enc + "?saved=1");
913
+ },
914
+ ));
915
+
610
916
  // --- media upload (r2 bridge) ---------------------------------------
611
917
  // POST /admin/media/upload — fetches `source_url` via b.httpClient
612
918
  // (SSRF gate + size cap), uploads to R2 through the bridge, then
@@ -614,8 +920,13 @@ function mount(router, deps) {
614
920
  // r2_bridge is wired (operator hasn't set D1_BRIDGE_URL +
615
921
  // D1_BRIDGE_SECRET).
616
922
  if (r2) {
617
- router.post("/admin/media/upload", W("media.upload", async function (req, res) {
618
- var body = req.body || {};
923
+ // Fetch store → attach, shared by the JSON upload route and the
924
+ // browser POST alias. Throws TypeError on bad input (mapped to 400);
925
+ // returns `{ status, code, detail }` for an operational failure the
926
+ // caller renders as a problem (JSON) or ?err=1 (browser), or `{ rec }`
927
+ // on success.
928
+ async function _performMediaUpload(body) {
929
+ body = body || {};
619
930
  if (typeof body.source_url !== "string" || !body.source_url.length) {
620
931
  throw new TypeError("admin.media.upload: body.source_url required");
621
932
  }
@@ -641,11 +952,10 @@ function mount(router, deps) {
641
952
  headers: { "accept": body.content_type + ",*/*;q=0.5" },
642
953
  });
643
954
  } catch (e) {
644
- return _problem(res, 502, "source-fetch-failed", (e && e.message) || String(e));
955
+ return { status: 502, code: "source-fetch-failed", detail: (e && e.message) || String(e) };
645
956
  }
646
957
  if (fetched.statusCode < 200 || fetched.statusCode >= 300) {
647
- return _problem(res, 502, "source-fetch-status",
648
- "source_url returned HTTP " + fetched.statusCode);
958
+ return { status: 502, code: "source-fetch-status", detail: "source_url returned HTTP " + fetched.statusCode };
649
959
  }
650
960
  var fetchedCT = String(fetched.headers && (fetched.headers["content-type"] || fetched.headers["Content-Type"]) || "");
651
961
  // Loose match — the declared content_type must be a prefix of
@@ -655,13 +965,13 @@ function mount(router, deps) {
655
965
  var declared = body.content_type.split(";")[0].trim().toLowerCase();
656
966
  var served = fetchedCT.split(";")[0].trim().toLowerCase();
657
967
  if (served && declared !== served) {
658
- return _problem(res, 422, "content-type-mismatch",
659
- "source_url served `" + served + "` but operator declared `" + declared + "`");
968
+ return { status: 422, code: "content-type-mismatch",
969
+ detail: "source_url served `" + served + "` but operator declared `" + declared + "`" };
660
970
  }
661
971
  var buf = fetched.body && Buffer.isBuffer(fetched.body) ? fetched.body
662
972
  : Buffer.from(fetched.body || "");
663
973
  if (buf.length === 0) {
664
- return _problem(res, 422, "source-empty", "source_url returned an empty body");
974
+ return { status: 422, code: "source-empty", detail: "source_url returned an empty body" };
665
975
  }
666
976
  // Generate the R2 key. The extension is inferred from the
667
977
  // declared content-type so the operator can preview the asset
@@ -672,7 +982,7 @@ function mount(router, deps) {
672
982
  try {
673
983
  await r2.put(key, buf, body.content_type);
674
984
  } catch (e) {
675
- return _problem(res, 502, "r2-upload-failed", (e && e.message) || String(e));
985
+ return { status: 502, code: "r2-upload-failed", detail: (e && e.message) || String(e) };
676
986
  }
677
987
  var m;
678
988
  try {
@@ -690,15 +1000,42 @@ function mount(router, deps) {
690
1000
  // The R2 write succeeded but the DB row didn't land — surface
691
1001
  // the orphan key so the operator can reconcile or re-attach.
692
1002
  var problem = e instanceof TypeError ? 400 : 500;
693
- return _problem(res, problem, "media-attach-failed",
694
- (e && e.message || String(e)) + " (orphan r2_key=" + key + ")");
1003
+ return { status: problem, code: "media-attach-failed",
1004
+ detail: (e && e.message || String(e)) + " (orphan r2_key=" + key + ")" };
695
1005
  }
696
1006
  // Expose the public asset URL alongside the media row so the
697
1007
  // admin UI can preview without an extra round-trip.
698
- var rec = Object.assign({}, m, { asset_url: assetPrefix + key });
699
- _json(res, 201, rec);
700
- return rec;
1008
+ return { rec: Object.assign({}, m, { asset_url: assetPrefix + key }) };
1009
+ }
1010
+
1011
+ router.post("/admin/media/upload", W("media.upload", async function (req, res) {
1012
+ var out = await _performMediaUpload(req.body || {});
1013
+ if (out.rec) { _json(res, 201, out.rec); return out.rec; }
1014
+ return _problem(res, out.status, out.code, out.detail);
701
1015
  }));
1016
+
1017
+ // Browser POST alias: upload-from-URL scoped to a product (id from the
1018
+ // path). On success PRGs back to the detail; an operational failure or
1019
+ // bad input lands a ?err=1 notice rather than a problem-details JSON.
1020
+ router.post("/admin/products/:id/media/upload", _pageOrApi(false,
1021
+ W("media.upload", async function (req, res) {
1022
+ var body = Object.assign({}, req.body || {}, { product_id: req.params.id });
1023
+ var out = await _performMediaUpload(body);
1024
+ if (out.rec) { _json(res, 201, out.rec); return out.rec; }
1025
+ return _problem(res, out.status, out.code, out.detail);
1026
+ }),
1027
+ async function (req, res) {
1028
+ var id = req.params.id;
1029
+ var enc = encodeURIComponent(id);
1030
+ var body = Object.assign({}, req.body || {}, { product_id: id });
1031
+ var out;
1032
+ try { out = await _performMediaUpload(body); }
1033
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/products/" + enc + "?err=1"); }
1034
+ if (!out.rec) return _redirect(res, "/admin/products/" + enc + "?err=1");
1035
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.upload", outcome: "success", metadata: { id: id } });
1036
+ _redirect(res, "/admin/products/" + enc + "?saved=1");
1037
+ },
1038
+ ));
702
1039
  }
703
1040
 
704
1041
  router.delete("/admin/media/:id", W("media.delete", async function (req, res) {
@@ -708,6 +1045,57 @@ function mount(router, deps) {
708
1045
  return { id: req.params.id };
709
1046
  }));
710
1047
 
1048
+ // Browser confirm interstitial for media delete — removing a media row
1049
+ // detaches the asset from the product (the R2 object itself is left in
1050
+ // the bucket for the operator to reclaim), so the console confirms.
1051
+ router.get("/admin/media/:id/delete/confirm-page", _pageOrApi(true,
1052
+ R(async function (_req, res) {
1053
+ return _problem(res, 405, "use-canonical-endpoint", "DELETE /admin/media/:id (or POST .../delete) directly for the JSON API");
1054
+ }),
1055
+ async function (req, res) {
1056
+ var mid = req.params.id;
1057
+ var m = null;
1058
+ try { m = await catalog.media.get(mid); }
1059
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
1060
+ var productId = m ? m.product_id : null;
1061
+ var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
1062
+ if (!m) return _redirect(res, back + "?err=1");
1063
+ _sendHtml(res, 200, renderAdminConfirm({
1064
+ shop_name: deps.shop_name, nav_available: navAvailable, active: "products",
1065
+ heading: "Delete this media?",
1066
+ consequence: "This detaches the asset from the product. The stored object remains in your bucket — reclaim it there if no longer needed.",
1067
+ detail: m.r2_key ? "Key: " + m.r2_key : "This media row will be removed.",
1068
+ action: "/admin/media/" + _htmlEscape(mid) + "/delete",
1069
+ confirm_label: "Delete media",
1070
+ cancel_href: back,
1071
+ }));
1072
+ },
1073
+ ));
1074
+
1075
+ // Browser POST for media delete (HTML forms can't DELETE).
1076
+ router.post("/admin/media/:id/delete", _pageOrApi(false,
1077
+ W("media.delete", async function (req, res) {
1078
+ var ok = await catalog.media.delete(req.params.id);
1079
+ if (!ok) return _problem(res, 404, "media-not-found");
1080
+ _json(res, 200, { ok: true });
1081
+ return { id: req.params.id };
1082
+ }),
1083
+ async function (req, res) {
1084
+ var mid = req.params.id;
1085
+ var m = null;
1086
+ try { m = await catalog.media.get(mid); }
1087
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
1088
+ var productId = m ? m.product_id : null;
1089
+ var back = productId ? "/admin/products/" + encodeURIComponent(productId) : "/admin/products";
1090
+ var ok = false;
1091
+ try { ok = await catalog.media.delete(mid); }
1092
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
1093
+ if (!ok) return _redirect(res, back + "?err=1");
1094
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.delete", outcome: "success", metadata: { id: mid } });
1095
+ _redirect(res, back + "?saved=1");
1096
+ },
1097
+ ));
1098
+
711
1099
  // ---- bulk catalog import --------------------------------------------
712
1100
 
713
1101
  // POST /admin/catalog/import — Content-Type: text/csv body. The CSV
@@ -3238,10 +3626,13 @@ function renderAdminProducts(opts) {
3238
3626
  var action = p.status === "archived"
3239
3627
  ? "<form method=\"post\" action=\"/admin/products/" + _htmlEscape(p.id) + "/restore\"><button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>"
3240
3628
  : "<form method=\"post\" action=\"/admin/products/" + _htmlEscape(p.id) + "/archive\"><button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>";
3241
- return "<tr><td><strong>" + _htmlEscape(p.title) + "</strong></td>" +
3629
+ return "<tr><td><a href=\"/admin/products/" + _htmlEscape(p.id) + "\"><strong>" + _htmlEscape(p.title) + "</strong></a></td>" +
3242
3630
  "<td><code class=\"order-id\">" + _htmlEscape(p.slug) + "</code></td>" +
3243
3631
  "<td><span class=\"status-pill " + cls + "\">" + _htmlEscape(p.status) + "</span></td>" +
3244
- "<td>" + action + "</td></tr>";
3632
+ "<td><div class=\"actions-row mt-0\">" +
3633
+ "<a class=\"btn btn--ghost\" href=\"/admin/products/" + _htmlEscape(p.id) + "\">Manage</a>" +
3634
+ action +
3635
+ "</div></td></tr>";
3245
3636
  }).join("");
3246
3637
  var table = products.length
3247
3638
  ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Slug</th><th scope=\"col\">Status</th><th scope=\"col\">Action</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
@@ -4618,6 +5009,209 @@ function renderAdminCollection(opts) {
4618
5009
  return _renderAdminShell(opts.shop_name, "Collection " + col.slug, body, "collections", opts.nav_available);
4619
5010
  }
4620
5011
 
5012
+ // Product detail / management screen — the console's full editor for a
5013
+ // single catalog product: its fields (slug / title / description /
5014
+ // status), its variants (create / edit / delete), each variant's price
5015
+ // (set, with the current price + an append-only price history) and the
5016
+ // product's media (list / attach-by-key / upload-by-URL when the R2
5017
+ // bridge is wired / delete). Mirrors `renderAdminCollection`: an edit
5018
+ // form posting to a /edit POST alias (HTML forms can't PATCH), inline
5019
+ // sub-entity tables with per-row forms, and destructive actions routed
5020
+ // through a server-rendered confirm interstitial (the CSP forbids a
5021
+ // client confirm() dialog).
5022
+ function renderAdminProduct(opts) {
5023
+ opts = opts || {};
5024
+ var p = opts.product;
5025
+ if (!p) {
5026
+ var nf = "<section><h2>Product</h2><p class=\"empty\">Product not found.</p>" +
5027
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/products\">Back to products</a></div></section>";
5028
+ return _renderAdminShell(opts.shop_name, "Product", nf, "products", opts.nav_available);
5029
+ }
5030
+ var variants = opts.variants || [];
5031
+ var mediaRows = opts.media || [];
5032
+ var pricesByVar = opts.prices_by_variant || {}; // { variantId: { currencies: [{ currency, current, history }] } }
5033
+ var assetPrefix = typeof opts.asset_prefix === "string" ? opts.asset_prefix : "/assets/";
5034
+ var uploadWired = !!opts.upload_available;
5035
+ var defaultCurrency = (typeof opts.default_currency === "string" && /^[A-Z]{3}$/.test(opts.default_currency)) ? opts.default_currency : "USD";
5036
+ var pid = _htmlEscape(p.id);
5037
+
5038
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
5039
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
5040
+
5041
+ // ---- product fields edit ---------------------------------------------
5042
+ var statusOpts = ["draft", "active", "archived"].map(function (s) {
5043
+ return "<option value=\"" + s + "\"" + (s === p.status ? " selected" : "") + ">" + s + "</option>";
5044
+ }).join("");
5045
+ var editForm =
5046
+ "<div class=\"panel mw-40\">" +
5047
+ "<h3 class=\"subhead\">Details</h3>" +
5048
+ "<form method=\"post\" action=\"/admin/products/" + pid + "/edit\">" +
5049
+ _setupField("Slug", "slug", p.slug, "text", "Lowercase, hyphenated — the storefront URL.", " maxlength=\"200\" required") +
5050
+ _setupField("Title", "title", p.title, "text", "", " maxlength=\"500\" required") +
5051
+ "<label class=\"form-field\"><span>Description</span>" +
5052
+ "<textarea name=\"description\" maxlength=\"100000\" rows=\"4\">" + _htmlEscape(p.description || "") + "</textarea></label>" +
5053
+ "<label class=\"form-field\"><span>Status</span><select name=\"status\">" + statusOpts + "</select></label>" +
5054
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save</button></div>" +
5055
+ "</form>" +
5056
+ "</div>";
5057
+
5058
+ // ---- variants table + per-variant price manager ----------------------
5059
+ var variantBlocks = variants.map(function (v) {
5060
+ var vid = _htmlEscape(v.id);
5061
+ var optionPairs = Object.keys(v.options || {}).map(function (k) {
5062
+ return _htmlEscape(k) + "=" + _htmlEscape(String(v.options[k]));
5063
+ }).join(", ");
5064
+ var optionsText = Object.keys(v.options || {}).map(function (k) {
5065
+ return k + "=" + String(v.options[k]);
5066
+ }).join(", ");
5067
+
5068
+ // Price rows — current + history per currency the variant is priced in.
5069
+ var priceModel = pricesByVar[v.id] || { currencies: [] };
5070
+ var priceCurrencyBlocks = (priceModel.currencies || []).map(function (pc) {
5071
+ var current = pc.current
5072
+ ? "<strong>" + _htmlEscape(pricing.format(pc.current.amount_minor, pc.currency)) + "</strong>"
5073
+ : "<span class=\"u-mute\">no active price</span>";
5074
+ var histRows = (pc.history || []).map(function (h) {
5075
+ return "<tr>" +
5076
+ "<td class=\"num\">" + _htmlEscape(pricing.format(h.amount_minor, pc.currency)) + "</td>" +
5077
+ "<td>" + _htmlEscape(_fmtDate(h.effective_from)) + "</td>" +
5078
+ "<td>" + (h.effective_until == null ? "<span class=\"status-pill paid\">current</span>" : _htmlEscape(_fmtDate(h.effective_until))) + "</td>" +
5079
+ "</tr>";
5080
+ }).join("");
5081
+ var histTable = (pc.history && pc.history.length)
5082
+ ? "<table><thead><tr><th scope=\"col\" class=\"num\">Amount</th><th scope=\"col\">From</th><th scope=\"col\">Until</th></tr></thead><tbody>" + histRows + "</tbody></table>"
5083
+ : "";
5084
+ return "<div class=\"m-04\">" +
5085
+ "<span class=\"u-mute\">" + _htmlEscape(pc.currency) + "</span> · " + current +
5086
+ histTable +
5087
+ "</div>";
5088
+ }).join("");
5089
+ var priceSection =
5090
+ "<div class=\"panel-sub\">" +
5091
+ "<h4 class=\"subhead subhead--sp\">Price</h4>" +
5092
+ (priceCurrencyBlocks || "<p class=\"empty\">No price set yet.</p>") +
5093
+ "<form method=\"post\" action=\"/admin/variants/" + vid + "/prices/set\" class=\"price-set-form\">" +
5094
+ "<div class=\"actions-row\">" +
5095
+ "<input type=\"text\" name=\"currency\" value=\"" + _htmlEscape(defaultCurrency) + "\" class=\"input-code\" aria-label=\"Currency\" maxlength=\"3\" required>" +
5096
+ "<input type=\"number\" name=\"amount_minor\" min=\"0\" step=\"1\" placeholder=\"amount (minor units)\" aria-label=\"Amount in minor units\" required>" +
5097
+ "<button class=\"btn btn--ghost\" type=\"submit\">Set price</button>" +
5098
+ "</div>" +
5099
+ "<small class=\"u-mute\">Amount in the currency's minor units — e.g. 2999 = $29.99. Setting a price is append-only; the prior price is retained in history.</small>" +
5100
+ "</form>" +
5101
+ "</div>";
5102
+
5103
+ var editVariant =
5104
+ "<form method=\"post\" action=\"/admin/variants/" + vid + "/edit\">" +
5105
+ "<div class=\"actions-row\">" +
5106
+ "<label class=\"form-field mb-0\"><span>SKU</span><input type=\"text\" name=\"sku\" value=\"" + _htmlEscape(v.sku) + "\" maxlength=\"128\" required></label>" +
5107
+ "<label class=\"form-field mb-0\"><span>Title</span><input type=\"text\" name=\"title\" value=\"" + _htmlEscape(v.title || "") + "\" maxlength=\"500\"></label>" +
5108
+ "</div>" +
5109
+ "<div class=\"actions-row\">" +
5110
+ "<label class=\"form-field mb-0\"><span>Options (k=v, comma-sep)</span><input type=\"text\" name=\"options\" value=\"" + _htmlEscape(optionsText) + "\" maxlength=\"2000\"></label>" +
5111
+ "<label class=\"form-field mb-0\"><span>Weight (g)</span><input type=\"number\" name=\"weight_grams\" value=\"" + _htmlEscape(String(v.weight_grams || 0)) + "\" min=\"0\" step=\"1\"></label>" +
5112
+ "</div>" +
5113
+ "<label class=\"form-field\"><span>Requires shipping</span><select name=\"requires_shipping\">" +
5114
+ "<option value=\"1\"" + (v.requires_shipping ? " selected" : "") + ">Yes (physical)</option>" +
5115
+ "<option value=\"0\"" + (v.requires_shipping ? "" : " selected") + ">No (digital)</option>" +
5116
+ "</select></label>" +
5117
+ "<div class=\"actions-row\">" +
5118
+ "<button class=\"btn btn--ghost\" type=\"submit\">Save variant</button>" +
5119
+ "<a class=\"btn btn--danger\" href=\"/admin/variants/" + vid + "/delete/confirm-page?product_id=" + pid + "\">Delete</a>" +
5120
+ "</div>" +
5121
+ "</form>";
5122
+
5123
+ return "<div class=\"panel mt-1\">" +
5124
+ "<div class=\"actions-row mt-0\">" +
5125
+ "<strong>" + _htmlEscape(v.title || v.sku) + "</strong>" +
5126
+ "<code class=\"order-id\">" + _htmlEscape(v.sku) + "</code>" +
5127
+ (optionPairs ? "<span class=\"u-mute\">" + optionPairs + "</span>" : "") +
5128
+ "</div>" +
5129
+ editVariant +
5130
+ priceSection +
5131
+ "</div>";
5132
+ }).join("");
5133
+ var variantsBody = variants.length ? variantBlocks : "<p class=\"empty\">No variants yet — add the first one below.</p>";
5134
+
5135
+ var addVariant =
5136
+ "<div class=\"panel mt-1 mw-40\">" +
5137
+ "<h3 class=\"subhead\">Add a variant</h3>" +
5138
+ "<form method=\"post\" action=\"/admin/products/" + pid + "/variants/create\">" +
5139
+ _setupField("SKU", "sku", "", "text", "Unique stock-keeping unit — letters, digits, . _ -", " maxlength=\"128\" required") +
5140
+ _setupField("Title", "title", "", "text", "Optional — e.g. “Large / Blue”.", " maxlength=\"500\"") +
5141
+ _setupField("Options", "options", "", "text", "Attribute map as k=v pairs, comma-separated — e.g. size=L, color=blue.", " maxlength=\"2000\"") +
5142
+ _setupField("Weight (grams)", "weight_grams", "0", "number", "Used for shipping rate calculation.", " min=\"0\" step=\"1\"") +
5143
+ "<label class=\"form-field\"><span>Requires shipping</span><select name=\"requires_shipping\">" +
5144
+ "<option value=\"1\">Yes (physical)</option><option value=\"0\">No (digital)</option>" +
5145
+ "</select></label>" +
5146
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add variant</button></div>" +
5147
+ "</form>" +
5148
+ "</div>";
5149
+
5150
+ var variantsSection = "<section class=\"mt\"><h3 class=\"fs-105\">Variants</h3>" + variantsBody + addVariant + "</section>";
5151
+
5152
+ // ---- media -----------------------------------------------------------
5153
+ var mediaCards = mediaRows.map(function (m) {
5154
+ var mid = _htmlEscape(m.id);
5155
+ var url = assetPrefix + m.r2_key;
5156
+ var isImage = /^image\//.test(m.content_type || "");
5157
+ var thumb = isImage
5158
+ ? "<img class=\"media-thumb\" src=\"" + _htmlEscape(url) + "\" alt=\"" + _htmlEscape(m.alt_text || "") + "\" loading=\"lazy\">"
5159
+ : "<span class=\"media-thumb media-thumb--file\">" + _htmlEscape((m.content_type || "file").split("/")[0]) + "</span>";
5160
+ return "<div class=\"media-card\">" +
5161
+ thumb +
5162
+ "<code class=\"order-id\">" + _htmlEscape(m.r2_key) + "</code>" +
5163
+ "<span class=\"u-mute\">" + _htmlEscape(m.content_type || "") + (m.alt_text ? " · " + _htmlEscape(m.alt_text) : "") + "</span>" +
5164
+ "<a class=\"btn btn--danger\" href=\"/admin/media/" + mid + "/delete/confirm-page?product_id=" + pid + "\">Delete</a>" +
5165
+ "</div>";
5166
+ }).join("");
5167
+ var mediaGrid = mediaRows.length
5168
+ ? "<div class=\"media-grid\">" + mediaCards + "</div>"
5169
+ : "<p class=\"empty\">No media attached yet.</p>";
5170
+
5171
+ var attachForm =
5172
+ "<div class=\"panel mt-1 mw-40\">" +
5173
+ "<h3 class=\"subhead\">Attach media by R2 key</h3>" +
5174
+ "<p class=\"meta\">Reference an object already in your media bucket. The key is its path under the bucket (no leading slash, no “..”).</p>" +
5175
+ "<form method=\"post\" action=\"/admin/products/" + pid + "/media/attach\">" +
5176
+ _setupField("R2 key", "r2_key", "", "text", "e.g. media/01j…png", " maxlength=\"1024\" required") +
5177
+ _setupField("Content type", "content_type", "", "text", "MIME type — e.g. image/png.", " maxlength=\"255\" required") +
5178
+ _setupField("Alt text", "alt_text", "", "text", "Describes the image for screen readers + SEO.", " maxlength=\"500\"") +
5179
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Attach media</button></div>" +
5180
+ "</form>" +
5181
+ "</div>";
5182
+
5183
+ var uploadForm = uploadWired
5184
+ ? "<div class=\"panel mt-1 mw-40\">" +
5185
+ "<h3 class=\"subhead\">Upload media from a URL</h3>" +
5186
+ "<p class=\"meta\">Fetches the source (SSRF-gated), stores it in your bucket, and attaches it to this product in one step.</p>" +
5187
+ "<form method=\"post\" action=\"/admin/products/" + pid + "/media/upload\">" +
5188
+ _setupField("Source URL", "source_url", "", "url", "A public https URL to the asset bytes.", " maxlength=\"2000\" required") +
5189
+ _setupField("Content type", "content_type", "", "text", "MIME type the source serves — e.g. image/png.", " maxlength=\"255\" required") +
5190
+ _setupField("Alt text", "alt_text", "", "text", "Describes the image for screen readers + SEO.", " maxlength=\"500\"") +
5191
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Upload + attach</button></div>" +
5192
+ "</form>" +
5193
+ "</div>"
5194
+ : "";
5195
+
5196
+ var mediaSection = "<section class=\"mt\"><h3 class=\"fs-105\">Media</h3>" + mediaGrid + attachForm + uploadForm + "</section>";
5197
+
5198
+ // ---- head + assembly -------------------------------------------------
5199
+ var statusCls = p.status === "active" ? "paid" : (p.status === "archived" ? "refunded" : "pending");
5200
+ var archiveAction = p.status === "archived"
5201
+ ? "<form method=\"post\" action=\"/admin/products/" + pid + "/restore\" class=\"form-inline\"><button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>"
5202
+ : "<form method=\"post\" action=\"/admin/products/" + pid + "/archive\" class=\"form-inline\"><button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>";
5203
+ var head =
5204
+ "<p class=\"meta\"><a href=\"/admin/products\">&larr; Products</a> · " +
5205
+ "<code class=\"order-id\">" + _htmlEscape(p.slug) + "</code> · " +
5206
+ "<span class=\"status-pill " + statusCls + "\">" + _htmlEscape(p.status) + "</span>" +
5207
+ " · <a href=\"/products/" + _htmlEscape(encodeURIComponent(p.slug)) + "\" target=\"_blank\" rel=\"noreferrer\">View on storefront &rarr;</a></p>";
5208
+
5209
+ var body = "<section><h2>" + _htmlEscape(p.title) + "</h2>" + saved + notice + head +
5210
+ "<div class=\"actions-row mt-0\">" + archiveAction + "</div>" +
5211
+ editForm + "</section>" + variantsSection + mediaSection;
5212
+ return _renderAdminShell(opts.shop_name, "Product " + p.slug, body, "products", opts.nav_available);
5213
+ }
5214
+
4621
5215
  module.exports = {
4622
5216
  mount: mount,
4623
5217
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
@@ -4627,6 +5221,7 @@ module.exports = {
4627
5221
  renderAdminSetup: renderAdminSetup,
4628
5222
  renderAdminIntegrations: renderAdminIntegrations,
4629
5223
  renderAdminProducts: renderAdminProducts,
5224
+ renderAdminProduct: renderAdminProduct,
4630
5225
  renderAdminInventory: renderAdminInventory,
4631
5226
  renderAdminOrders: renderAdminOrders,
4632
5227
  renderAdminOrder: renderAdminOrder,