@blamejs/blamejs-shop 0.1.20 → 0.1.23

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 (34) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +7 -3
  3. package/SECURITY.md +12 -0
  4. package/lib/admin.js +1035 -138
  5. package/lib/checkout.js +192 -22
  6. package/lib/collections.js +4 -3
  7. package/lib/customers.js +102 -0
  8. package/lib/giftcards.js +40 -0
  9. package/lib/order.js +22 -0
  10. package/lib/storefront.js +400 -53
  11. package/lib/vendor/MANIFEST.json +3 -3
  12. package/lib/vendor/blamejs/CHANGELOG.md +10 -0
  13. package/lib/vendor/blamejs/README.md +4 -0
  14. package/lib/vendor/blamejs/api-snapshot.json +129 -2
  15. package/lib/vendor/blamejs/index.js +12 -0
  16. package/lib/vendor/blamejs/lib/json-merge-patch.js +93 -0
  17. package/lib/vendor/blamejs/lib/json-patch.js +206 -0
  18. package/lib/vendor/blamejs/lib/json-path.js +638 -0
  19. package/lib/vendor/blamejs/lib/json-pointer.js +109 -0
  20. package/lib/vendor/blamejs/lib/jtd.js +234 -0
  21. package/lib/vendor/blamejs/lib/link-header.js +169 -0
  22. package/lib/vendor/blamejs/package.json +1 -1
  23. package/lib/vendor/blamejs/release-notes/v0.12.57.json +18 -0
  24. package/lib/vendor/blamejs/release-notes/v0.12.58.json +22 -0
  25. package/lib/vendor/blamejs/release-notes/v0.12.60.json +18 -0
  26. package/lib/vendor/blamejs/release-notes/v0.12.61.json +18 -0
  27. package/lib/vendor/blamejs/release-notes/v0.12.62.json +18 -0
  28. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -0
  29. package/lib/vendor/blamejs/test/layer-0-primitives/json-merge-patch.test.js +81 -0
  30. package/lib/vendor/blamejs/test/layer-0-primitives/json-patch.test.js +184 -0
  31. package/lib/vendor/blamejs/test/layer-0-primitives/json-path.test.js +113 -0
  32. package/lib/vendor/blamejs/test/layer-0-primitives/jtd.test.js +52 -0
  33. package/lib/vendor/blamejs/test/layer-0-primitives/link-header.test.js +96 -0
  34. package/package.json +4 -2
package/lib/admin.js CHANGED
@@ -31,11 +31,42 @@
31
31
  */
32
32
 
33
33
  var pricing = require("./pricing");
34
+ var collectionsModule = require("./collections");
34
35
 
35
36
  var b = require("./vendor/blamejs");
36
37
 
37
38
  var AUDIT_NAMESPACE = "shop_admin";
38
39
 
40
+ // The console stylesheet ships as an external /assets file — an inline
41
+ // <style> block (or inline style="" attributes) is refused by the strict
42
+ // `style-src 'self'` CSP that governs container-served routes, which is
43
+ // why the console renders unstyled when those are inlined. `'self'`
44
+ // allows this file; the deploy's R2 asset sync uploads it. The `?v=` is
45
+ // the per-release cache-buster; the sha384 Subresource Integrity digest
46
+ // (b.crypto.sri, W3C SRI 1.0) makes the browser reject a tampered or
47
+ // stale object. Same-origin, so no `crossorigin` is needed. Kept local
48
+ // to this module per the admin renderer's no-cross-module convention; a
49
+ // missing asset (e.g. a unit-test cwd) omits the attribute, never throws.
50
+ var _assetFs = require("node:fs"); // allow:non-shop-require — one sync read of the bundled in-repo stylesheet to compute its SRI hash via b.crypto.sri (the primitive needs the bytes); no remote/write
51
+ var _ASSET_VERSION = require("../package.json").version;
52
+ var _SRI_CACHE = {};
53
+ function _assetSri(relUnderThemeAssets) {
54
+ if (Object.prototype.hasOwnProperty.call(_SRI_CACHE, relUnderThemeAssets)) {
55
+ return _SRI_CACHE[relUnderThemeAssets];
56
+ }
57
+ var sri = null;
58
+ try {
59
+ sri = b.crypto.sri(_assetFs.readFileSync(__dirname + "/../themes/default/assets/" + relUnderThemeAssets), { algorithm: "sha384" });
60
+ } catch (_e) { sri = null; }
61
+ _SRI_CACHE[relUnderThemeAssets] = sri;
62
+ return sri;
63
+ }
64
+ function _adminStylesheetLink() {
65
+ var sri = _assetSri("css/admin.css");
66
+ return "<link rel=\"stylesheet\" href=\"/assets/themes/default/css/admin.css?v=" + _ASSET_VERSION + "\"" +
67
+ (sri ? " integrity=\"" + sri + "\"" : "") + ">";
68
+ }
69
+
39
70
  // Conservative content-type → file-extension map for the upload route.
40
71
  // Unknown types fall back to no extension; the R2 object metadata still
41
72
  // carries the full content-type so the asset serves correctly either
@@ -221,11 +252,12 @@ function mount(router, deps) {
221
252
  var catalogImport = deps.catalogImport || null; // bulk-import route disabled when absent
222
253
  var reviews = deps.reviews || null; // moderation endpoints disabled when absent
223
254
  var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
255
+ var customers = deps.customers || null; // read-only customers console disabled when absent
224
256
 
225
257
  // Which optional console sections are wired — gates their nav links so a
226
258
  // signed-in admin is never sent to a route that wasn't mounted. Passed
227
259
  // into every authed render call as `nav_available`.
228
- var navAvailable = { returns: !!returns, reviews: !!reviews, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks };
260
+ var navAvailable = { returns: !!returns, reviews: !!reviews, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards };
229
261
 
230
262
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
231
263
 
@@ -755,6 +787,48 @@ function mount(router, deps) {
755
787
  },
756
788
  ));
757
789
 
790
+ // ---- customers (read-only) ------------------------------------------
791
+
792
+ // Operator-facing customer roster, newest first. READ-ONLY — no create /
793
+ // edit / delete (the storefront owns account mutation via passkey / OIDC
794
+ // ceremonies). The raw email is never stored, so the table shows the
795
+ // display name, a short id, the join date, the sign-in method (passkey
796
+ // count + linked OAuth providers), and the order count. Order counts +
797
+ // sign-in methods are resolved with bounded aggregate queries over the
798
+ // page's ids — no per-row N+1. Endpoint omitted when no customers
799
+ // primitive is wired.
800
+ if (customers) {
801
+ router.get("/admin/customers", _pageOrApi(true,
802
+ R(async function (req, res) {
803
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
804
+ var cursor = url && url.searchParams.get("cursor");
805
+ var limitS = url && url.searchParams.get("limit");
806
+ var limit = limitS == null ? 50 : parseInt(limitS, 10);
807
+ var page = await customers.list({ cursor: cursor || undefined, limit: limit });
808
+ _json(res, 200, page);
809
+ }),
810
+ async function (req, res) {
811
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
812
+ var cursor = url && url.searchParams.get("cursor");
813
+ var page = await customers.list({ cursor: cursor || undefined, limit: 100 });
814
+ var rows = page.rows || [];
815
+ var ids = rows.map(function (c) { return c.id; });
816
+ // One grouped query for order counts, one IN-bounded pair for
817
+ // sign-in methods — never a trip per row.
818
+ var counts = await order.countsByCustomer(ids);
819
+ var methods = await customers.signInMethodsByCustomer(ids);
820
+ _sendHtml(res, 200, renderAdminCustomers({
821
+ shop_name: deps.shop_name, nav_available: navAvailable,
822
+ customers: rows,
823
+ order_counts: counts,
824
+ passkey_counts: methods.passkeys,
825
+ oauth_providers: methods.oauth,
826
+ next_cursor: page.next_cursor || null,
827
+ }));
828
+ },
829
+ ));
830
+ }
831
+
758
832
  // ---- refunds --------------------------------------------------------
759
833
 
760
834
  if (payment) {
@@ -1350,6 +1424,393 @@ function mount(router, deps) {
1350
1424
  ));
1351
1425
  }
1352
1426
 
1427
+ // ---- collections ----------------------------------------------------
1428
+
1429
+ // Operator-side console for manual + smart product collections (the
1430
+ // customer-facing /collections pages already ship). Manual collections
1431
+ // get a member manager (add by product id, remove, reorder); smart
1432
+ // collections get a rule editor (field/op/value rows) plus a live
1433
+ // preview of the products the rules currently match. Endpoints are
1434
+ // omitted entirely when no collections primitive is wired.
1435
+ var collections = deps.collections || null;
1436
+ if (collections) {
1437
+ // Form rule rows arrive as parallel arrays (rule_field[], rule_op[],
1438
+ // rule_value[]) — or scalars when the operator submits a single row.
1439
+ // Normalise to arrays, zip into a { all: [...] } rule set, and coerce
1440
+ // numeric / array op values so the primitive's validator sees the
1441
+ // shape it expects. Bad shapes throw TypeError, surfaced as a 400
1442
+ // re-render the same way every other create form does.
1443
+ function _asArray(v) {
1444
+ if (v == null) return [];
1445
+ return Array.isArray(v) ? v : [v];
1446
+ }
1447
+ function _rulesFromForm(body) {
1448
+ var fields = _asArray(body.rule_field);
1449
+ var ops = _asArray(body.rule_op);
1450
+ var values = _asArray(body.rule_value);
1451
+ var all = [];
1452
+ for (var i = 0; i < fields.length; i += 1) {
1453
+ var field = fields[i];
1454
+ var op = ops[i];
1455
+ var raw = values[i];
1456
+ // Skip wholly blank rows (the editor renders a spare empty row).
1457
+ if ((field == null || field === "") && (raw == null || raw === "")) continue;
1458
+ var value = _coerceRuleValue(field, op, raw);
1459
+ all.push({ field: field, op: op, value: value });
1460
+ }
1461
+ if (all.length === 0) {
1462
+ throw new TypeError("rules: add at least one rule (field, op, value)");
1463
+ }
1464
+ return { all: all };
1465
+ }
1466
+ function _coerceRuleValue(field, op, raw) {
1467
+ var s = raw == null ? "" : String(raw);
1468
+ // `in` / `not_in` take a comma-separated list; numeric fields parse
1469
+ // each entry as an integer, others stay strings.
1470
+ if (op === "in" || op === "not_in") {
1471
+ return s.split(",").map(function (part) {
1472
+ var t = part.trim();
1473
+ return _isNumericField(field) ? _strictInt(t, "rule value") : t;
1474
+ });
1475
+ }
1476
+ // `between` takes "lo,hi" — both strict integers.
1477
+ if (op === "between") {
1478
+ var parts = s.split(",");
1479
+ if (parts.length !== 2) throw new TypeError("rules: 'between' value must be \"lo,hi\"");
1480
+ return [_strictInt(parts[0].trim(), "rule lo"), _strictInt(parts[1].trim(), "rule hi")];
1481
+ }
1482
+ // Numeric comparison ops + numeric-field eq/neq parse as integers.
1483
+ var numericOp = op === "gt" || op === "gte" || op === "lt" || op === "lte";
1484
+ if (numericOp || (_isNumericField(field) && (op === "eq" || op === "neq"))) {
1485
+ return _strictInt(s, "rule value");
1486
+ }
1487
+ return s;
1488
+ }
1489
+ function _isNumericField(field) {
1490
+ return field === "price_minor" || field === "inventory_count" || field === "created_at";
1491
+ }
1492
+ // Strict integer parse — refuses "12abc" / "" / floats, unlike
1493
+ // parseInt's loose prefix match. Money / count fields must be exact.
1494
+ function _strictInt(s, label) {
1495
+ if (typeof s !== "string" || !/^-?\d+$/.test(s.trim())) {
1496
+ throw new TypeError("collections: " + label + " must be an integer");
1497
+ }
1498
+ var n = Number(s.trim());
1499
+ if (!Number.isSafeInteger(n)) throw new TypeError("collections: " + label + " out of range");
1500
+ return n;
1501
+ }
1502
+
1503
+ function _cleanCreateMessage(e) {
1504
+ return (e && e.message || "Couldn't create that collection.").replace(/^collections[.:]\s*/, "");
1505
+ }
1506
+
1507
+ // Map the ?active= query to a collections.list filter: 1/true →
1508
+ // active-only, 0/false → archived-only, absent → all.
1509
+ function _collectionsFilter(activeS) {
1510
+ if (activeS === "1" || activeS === "true") return { active_only: true };
1511
+ if (activeS === "0" || activeS === "false") return { archived_only: true };
1512
+ return {};
1513
+ }
1514
+
1515
+ async function _listForBrowser(filter) {
1516
+ var rows = await collections.list(filter || {});
1517
+ // Annotate each row with its current size: manual → member count,
1518
+ // smart → matched-preview count. Both compose productsIn; the loop
1519
+ // is bounded by the collection count (operators have tens, not
1520
+ // thousands), so this is not an N+1 over an unbounded set.
1521
+ for (var i = 0; i < rows.length; i += 1) {
1522
+ var count = null;
1523
+ try {
1524
+ var p = await collections.productsIn({ slug: rows[i].slug, limit: 200 });
1525
+ count = (p.rows || []).length;
1526
+ } catch (_e) { count = null; }
1527
+ rows[i]._count = count;
1528
+ }
1529
+ return rows;
1530
+ }
1531
+
1532
+ // List content-negotiates: bearer → JSON (the raw list, unchanged for
1533
+ // tooling); signed-in browser → the console table with an
1534
+ // all/active/archived filter.
1535
+ router.get("/admin/collections", _pageOrApi(true,
1536
+ R(async function (req, res) {
1537
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1538
+ var activeS = url && url.searchParams.get("active");
1539
+ var rows = await collections.list(_collectionsFilter(activeS));
1540
+ _json(res, 200, { rows: rows });
1541
+ }),
1542
+ async function (req, res) {
1543
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1544
+ var activeS = url && url.searchParams.get("active");
1545
+ var rows = await _listForBrowser(_collectionsFilter(activeS));
1546
+ _sendHtml(res, 200, renderAdminCollections({
1547
+ shop_name: deps.shop_name, nav_available: navAvailable, collections: rows,
1548
+ active_filter: activeS,
1549
+ created: url && url.searchParams.get("created"),
1550
+ archived: url && url.searchParams.get("archived"),
1551
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the collection." : null,
1552
+ }));
1553
+ },
1554
+ ));
1555
+
1556
+ // Create content-negotiates: bearer → JSON 201 (manual or smart per
1557
+ // body.type); browser form → create, then PRG. A bad shape (TypeError)
1558
+ // re-renders the list with the validator's message rather than 500.
1559
+ router.post("/admin/collections", _pageOrApi(false,
1560
+ W("collection.create", async function (req, res) {
1561
+ var body = req.body || {};
1562
+ var col;
1563
+ if (body.type === "smart") col = await collections.defineSmart(body);
1564
+ else col = await collections.defineManual(body);
1565
+ _json(res, 201, col);
1566
+ return { id: col.slug };
1567
+ }),
1568
+ async function (req, res) {
1569
+ var body = req.body || {};
1570
+ var type = body.type === "smart" ? "smart" : "manual";
1571
+ try {
1572
+ if (type === "smart") {
1573
+ await collections.defineSmart({
1574
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
1575
+ title: body.title,
1576
+ description: body.description,
1577
+ rules: _rulesFromForm(body),
1578
+ sort_strategy: body.sort_strategy || "newest",
1579
+ });
1580
+ } else {
1581
+ await collections.defineManual({
1582
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
1583
+ title: body.title,
1584
+ description: body.description,
1585
+ sort_strategy: body.sort_strategy || "manual",
1586
+ });
1587
+ }
1588
+ } catch (e) {
1589
+ if (!(e instanceof TypeError)) throw e;
1590
+ var rows = await _listForBrowser({});
1591
+ return _sendHtml(res, 400, renderAdminCollections({
1592
+ shop_name: deps.shop_name, nav_available: navAvailable, collections: rows,
1593
+ notice: _cleanCreateMessage(e), form_type: type,
1594
+ }));
1595
+ }
1596
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.create", outcome: "success" });
1597
+ _redirect(res, "/admin/collections?created=1");
1598
+ },
1599
+ ));
1600
+
1601
+ async function _detailModel(slug) {
1602
+ var col = await collections.get(slug);
1603
+ if (!col) return null;
1604
+ var preview = null, members = null;
1605
+ if (col.type === "manual") {
1606
+ var pm = await collections.productsIn({ slug: slug, limit: 200 });
1607
+ members = pm.rows || [];
1608
+ } else {
1609
+ var ps = await collections.productsIn({ slug: slug, limit: 50 });
1610
+ preview = ps.rows || [];
1611
+ }
1612
+ return { collection: col, members: members, preview: preview };
1613
+ }
1614
+
1615
+ // Detail content-negotiates: bearer → JSON (collection + members /
1616
+ // preview); browser → the member manager (manual) or rule editor +
1617
+ // preview (smart). A bad / unknown slug is a 404 page, never a 500.
1618
+ router.get("/admin/collections/:slug", _pageOrApi(true,
1619
+ R(async function (req, res) {
1620
+ var model;
1621
+ try { model = await _detailModel(req.params.slug); }
1622
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "collection-not-found", e.message); throw e; }
1623
+ if (!model) return _problem(res, 404, "collection-not-found");
1624
+ _json(res, 200, model);
1625
+ }),
1626
+ async function (req, res) {
1627
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1628
+ var model;
1629
+ try { model = await _detailModel(req.params.slug); }
1630
+ catch (e) { if (!(e instanceof TypeError)) throw e; model = null; }
1631
+ if (!model) return _sendHtml(res, 404, renderAdminCollections({
1632
+ shop_name: deps.shop_name, nav_available: navAvailable, collections: [], notice: "Collection not found.",
1633
+ }));
1634
+ _sendHtml(res, 200, renderAdminCollection({
1635
+ shop_name: deps.shop_name, nav_available: navAvailable,
1636
+ collection: model.collection, members: model.members, preview: model.preview,
1637
+ rule_fields: collectionsModule.RULE_FIELDS, rule_ops: collectionsModule.RULE_OPS,
1638
+ sort_strategies: collectionsModule.SORT_STRATEGIES,
1639
+ saved: url && url.searchParams.get("saved"),
1640
+ updated: url && url.searchParams.get("updated"),
1641
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
1642
+ }));
1643
+ },
1644
+ ));
1645
+
1646
+ // Edit content-negotiates: bearer PATCH (the JSON contract) + browser
1647
+ // POST /edit (HTML forms can't PATCH). Both update title / description
1648
+ // / sort_strategy, and rules for smart. A bad shape is a 400 / notice.
1649
+ router.patch("/admin/collections/:slug", W("collection.update", async function (req, res) {
1650
+ var col;
1651
+ try { col = await collections.update(req.params.slug, req.body || {}); }
1652
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1653
+ if (!col) return _problem(res, 404, "collection-not-found");
1654
+ _json(res, 200, col);
1655
+ return { id: col.slug };
1656
+ }));
1657
+
1658
+ router.post("/admin/collections/:slug/edit", _pageOrApi(false,
1659
+ W("collection.update", async function (req, res) {
1660
+ var col;
1661
+ try { col = await collections.update(req.params.slug, req.body || {}); }
1662
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1663
+ if (!col) return _problem(res, 404, "collection-not-found");
1664
+ _json(res, 200, col);
1665
+ return { id: col.slug };
1666
+ }),
1667
+ async function (req, res) {
1668
+ var slug = req.params.slug;
1669
+ var body = req.body || {};
1670
+ var enc = encodeURIComponent(slug);
1671
+ try {
1672
+ var existing = await collections.get(slug);
1673
+ if (!existing) return _redirect(res, "/admin/collections/" + enc + "?err=1");
1674
+ var patch = {};
1675
+ if (body.title !== undefined) patch.title = body.title;
1676
+ if (body.description !== undefined) patch.description = body.description;
1677
+ if (body.sort_strategy) patch.sort_strategy = body.sort_strategy;
1678
+ if (existing.type === "smart" && (body.rule_field !== undefined || body.rule_op !== undefined)) {
1679
+ patch.rules = _rulesFromForm(body);
1680
+ }
1681
+ if (Object.keys(patch).length === 0) return _redirect(res, "/admin/collections/" + enc + "?err=1");
1682
+ await collections.update(slug, patch);
1683
+ } catch (e) {
1684
+ if (!(e instanceof TypeError)) throw e;
1685
+ return _redirect(res, "/admin/collections/" + enc + "?err=1");
1686
+ }
1687
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.update", outcome: "success", metadata: { slug: slug } });
1688
+ _redirect(res, "/admin/collections/" + enc + "?updated=1");
1689
+ },
1690
+ ));
1691
+
1692
+ // Archive content-negotiates: bearer DELETE (soft archive) + browser
1693
+ // POST /archive. An unknown / already-archived slug is a no-op notice
1694
+ // (?err=1), never a false success.
1695
+ router.delete("/admin/collections/:slug", W("collection.archive", async function (req, res) {
1696
+ var col;
1697
+ try { col = await collections.archive(req.params.slug); }
1698
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1699
+ if (!col) return _problem(res, 404, "collection-not-found");
1700
+ _json(res, 200, col);
1701
+ return { id: col.slug };
1702
+ }));
1703
+
1704
+ router.post("/admin/collections/:slug/archive", _pageOrApi(false,
1705
+ W("collection.archive", async function (req, res) {
1706
+ var col;
1707
+ try { col = await collections.archive(req.params.slug); }
1708
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1709
+ if (!col) return _problem(res, 404, "collection-not-found");
1710
+ _json(res, 200, col);
1711
+ return { id: col.slug };
1712
+ }),
1713
+ async function (req, res) {
1714
+ var slug = req.params.slug;
1715
+ var col = null;
1716
+ try { col = await collections.archive(slug); }
1717
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
1718
+ // archive() returns the row whether or not it flipped (already-
1719
+ // archived re-returns the row); treat a missing row as the only
1720
+ // err. An already-archived re-archive is idempotent, not an error.
1721
+ if (!col) return _redirect(res, "/admin/collections?err=1");
1722
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.archive", outcome: "success", metadata: { slug: slug } });
1723
+ _redirect(res, "/admin/collections?archived=1");
1724
+ },
1725
+ ));
1726
+
1727
+ // Manual member ops — browser POST routes (the JSON API composes
1728
+ // addProduct / removeProduct / reorderProducts directly). Each PRGs
1729
+ // back to the detail; a bad shape / unknown product is a ?err=1 notice.
1730
+ function _memberRedirect(res, slug, ok) {
1731
+ var enc = encodeURIComponent(slug);
1732
+ return _redirect(res, "/admin/collections/" + enc + (ok ? "?updated=1" : "?err=1"));
1733
+ }
1734
+
1735
+ router.post("/admin/collections/:slug/members/add", _pageOrApi(false,
1736
+ W("collection.member_add", async function (req, res) {
1737
+ var body = req.body || {};
1738
+ var m;
1739
+ try { m = await collections.addProduct({ collection_slug: req.params.slug, product_id: body.product_id }); }
1740
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1741
+ _json(res, 201, m);
1742
+ return { id: req.params.slug };
1743
+ }),
1744
+ async function (req, res) {
1745
+ var slug = req.params.slug;
1746
+ var body = req.body || {};
1747
+ try {
1748
+ await collections.addProduct({ collection_slug: slug, product_id: (body.product_id || "").trim() });
1749
+ } catch (e) {
1750
+ if (!(e instanceof TypeError)) throw e;
1751
+ return _memberRedirect(res, slug, false);
1752
+ }
1753
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.member_add", outcome: "success", metadata: { slug: slug } });
1754
+ _memberRedirect(res, slug, true);
1755
+ },
1756
+ ));
1757
+
1758
+ router.post("/admin/collections/:slug/members/remove", _pageOrApi(false,
1759
+ W("collection.member_remove", async function (req, res) {
1760
+ var body = req.body || {};
1761
+ var ok;
1762
+ try { ok = await collections.removeProduct({ collection_slug: req.params.slug, product_id: body.product_id }); }
1763
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1764
+ if (!ok) return _problem(res, 404, "collection-member-not-found");
1765
+ _json(res, 200, { ok: true });
1766
+ return { id: req.params.slug };
1767
+ }),
1768
+ async function (req, res) {
1769
+ var slug = req.params.slug;
1770
+ var body = req.body || {};
1771
+ var ok = false;
1772
+ try {
1773
+ ok = await collections.removeProduct({ collection_slug: slug, product_id: (body.product_id || "").trim() });
1774
+ } catch (e) { if (!(e instanceof TypeError)) throw e; }
1775
+ if (!ok) return _memberRedirect(res, slug, false);
1776
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.member_remove", outcome: "success", metadata: { slug: slug } });
1777
+ _memberRedirect(res, slug, true);
1778
+ },
1779
+ ));
1780
+
1781
+ router.post("/admin/collections/:slug/members/reorder", _pageOrApi(false,
1782
+ W("collection.reorder", async function (req, res) {
1783
+ var body = req.body || {};
1784
+ var ids = Array.isArray(body.ordered_product_ids) ? body.ordered_product_ids : null;
1785
+ if (!ids) return _problem(res, 400, "bad-request", "ordered_product_ids array required");
1786
+ try { await collections.reorderProducts({ collection_slug: req.params.slug, ordered_product_ids: ids }); }
1787
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1788
+ _json(res, 200, { ok: true });
1789
+ return { id: req.params.slug };
1790
+ }),
1791
+ async function (req, res) {
1792
+ var slug = req.params.slug;
1793
+ var body = req.body || {};
1794
+ // The reorder form posts the full ordered id list — a single hidden
1795
+ // field of comma-joined ids, or repeated ordered_product_id rows.
1796
+ var ids;
1797
+ if (body.ordered_product_ids != null && typeof body.ordered_product_ids === "string") {
1798
+ ids = body.ordered_product_ids.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
1799
+ } else {
1800
+ ids = _asArray(body.ordered_product_id).map(function (s) { return String(s).trim(); }).filter(Boolean);
1801
+ }
1802
+ try {
1803
+ await collections.reorderProducts({ collection_slug: slug, ordered_product_ids: ids });
1804
+ } catch (e) {
1805
+ if (!(e instanceof TypeError)) throw e;
1806
+ return _memberRedirect(res, slug, false);
1807
+ }
1808
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.reorder", outcome: "success", metadata: { slug: slug } });
1809
+ _memberRedirect(res, slug, true);
1810
+ },
1811
+ ));
1812
+ }
1813
+
1353
1814
  // ---- analytics ------------------------------------------------------
1354
1815
 
1355
1816
  var analytics = deps.analytics || null;
@@ -1548,6 +2009,132 @@ function mount(router, deps) {
1548
2009
  }
1549
2010
  }
1550
2011
 
2012
+ // ---- gift cards -----------------------------------------------------
2013
+ //
2014
+ // The ledger console: list issued cards (masked by code_hint + the
2015
+ // original/remaining balance + status), drill into one card's ledger
2016
+ // transactions (issue / redeem / adjust), and — when the ledger is
2017
+ // wired — issue a new card from the console. The bearer JSON contract
2018
+ // is unchanged; the signed-in browser session gets the rendered page.
2019
+ // `giftcards` owns the card rows; `giftCardLedger` (optional) owns the
2020
+ // append-only transaction history shown on the detail view.
2021
+ var giftcards = deps.giftcards || null;
2022
+ var giftCardLedger = deps.giftCardLedger || null;
2023
+ if (giftcards) {
2024
+ // Issue a card. Bearer → JSON (the issued plaintext code is
2025
+ // returned ONCE for the operator to deliver); browser form →
2026
+ // issue, then PRG to the detail page so the code shows once.
2027
+ router.post("/admin/gift-cards", _pageOrApi(false,
2028
+ W("gift_card.issue", async function (req, res) {
2029
+ var issued = await _issueGiftCard(req.body || {});
2030
+ _json(res, 201, issued);
2031
+ return { id: issued.id };
2032
+ }),
2033
+ async function (req, res) {
2034
+ var issued;
2035
+ try {
2036
+ issued = await _issueGiftCard(req.body || {});
2037
+ } catch (e) {
2038
+ if (!(e instanceof TypeError)) throw e;
2039
+ var rows = await giftcards.list({});
2040
+ return _sendHtml(res, 400, renderAdminGiftCards({
2041
+ shop_name: deps.shop_name, nav_available: navAvailable, cards: rows,
2042
+ notice: e.message.replace(/^giftcards?[.:]\s*/i, ""),
2043
+ }));
2044
+ }
2045
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".gift_card.issue", outcome: "success", metadata: { id: issued.id } });
2046
+ _redirect(res, "/admin/gift-cards/" + issued.id + "?issued=" + encodeURIComponent(issued.code));
2047
+ },
2048
+ ));
2049
+
2050
+ router.get("/admin/gift-cards", _pageOrApi(true,
2051
+ R(async function (req, res) {
2052
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
2053
+ var status = url && url.searchParams.get("status");
2054
+ var rows = await giftcards.list(status ? { status: status } : {});
2055
+ _json(res, 200, { rows: rows });
2056
+ }),
2057
+ async function (req, res) {
2058
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
2059
+ var status = url && url.searchParams.get("status");
2060
+ var rows = await giftcards.list(status ? { status: status } : {});
2061
+ _sendHtml(res, 200, renderAdminGiftCards({
2062
+ shop_name: deps.shop_name, nav_available: navAvailable, cards: rows,
2063
+ status_filter: status,
2064
+ ledger_enabled: !!giftCardLedger,
2065
+ }));
2066
+ },
2067
+ ));
2068
+
2069
+ router.get("/admin/gift-cards/:id", _pageOrApi(true,
2070
+ R(async function (req, res) {
2071
+ var card = await giftcards.getById(req.params.id);
2072
+ if (!card) return _problem(res, 404, "gift-card-not-found");
2073
+ var history = [];
2074
+ if (giftCardLedger) {
2075
+ try { history = (await giftCardLedger.history(req.params.id, { limit: 500 })).rows; }
2076
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
2077
+ }
2078
+ _json(res, 200, { card: card, ledger: history });
2079
+ }),
2080
+ async function (req, res) {
2081
+ var card = await giftcards.getById(req.params.id);
2082
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
2083
+ var issuedCode = url && url.searchParams.get("issued");
2084
+ if (!card) {
2085
+ return _sendHtml(res, 404, renderAdminGiftCard({
2086
+ shop_name: deps.shop_name, nav_available: navAvailable, card: null,
2087
+ }));
2088
+ }
2089
+ var history = [];
2090
+ if (giftCardLedger) {
2091
+ try { history = (await giftCardLedger.history(req.params.id, { limit: 500 })).rows; }
2092
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
2093
+ }
2094
+ _sendHtml(res, 200, renderAdminGiftCard({
2095
+ shop_name: deps.shop_name, nav_available: navAvailable, card: card,
2096
+ ledger: history, issued_code: issuedCode,
2097
+ }));
2098
+ },
2099
+ ));
2100
+ }
2101
+
2102
+ // Issue a card from operator input, recording the opening balance as
2103
+ // a `manual` credit in the ledger (when wired) so the transaction
2104
+ // history starts from the issuance event. Strict integer parsing of
2105
+ // the amount; the giftcards primitive enforces the rest.
2106
+ async function _issueGiftCard(body) {
2107
+ var input = { currency: typeof body.currency === "string" ? body.currency.trim().toUpperCase() : body.currency };
2108
+ if (body.amount_minor != null && body.amount_minor !== "") {
2109
+ var n = typeof body.amount_minor === "number" ? body.amount_minor : parseInt(String(body.amount_minor), 10);
2110
+ if (!Number.isInteger(n)) throw new TypeError("giftcards: amount_minor must be an integer (minor units)");
2111
+ input.amount_minor = n;
2112
+ }
2113
+ if (body.issued_to_email) input.issued_to_email = String(body.issued_to_email).trim();
2114
+ if (body.expires_at != null && body.expires_at !== "") {
2115
+ var ex = typeof body.expires_at === "number" ? body.expires_at : parseInt(String(body.expires_at), 10);
2116
+ if (!Number.isInteger(ex)) throw new TypeError("giftcards: expires_at must be an integer epoch-ms");
2117
+ input.expires_at = ex;
2118
+ }
2119
+ var issued = await giftcards.issue(input);
2120
+ if (giftCardLedger) {
2121
+ // Opening credit so the ledger history starts from issuance.
2122
+ // Best-effort: a ledger write failure must not lose the issued
2123
+ // card (the card row + plaintext code already exist) — the
2124
+ // operator can reconcile, and the card balance is authoritative
2125
+ // on the card row. Swallow only the ledger fault.
2126
+ try {
2127
+ await giftCardLedger.credit({
2128
+ gift_card_id: issued.id,
2129
+ amount_minor: input.amount_minor,
2130
+ source: "manual",
2131
+ source_ref: "admin-issue",
2132
+ });
2133
+ } catch (_e) { /* drop-silent — card issued; ledger seed is advisory */ }
2134
+ }
2135
+ return issued;
2136
+ }
2137
+
1551
2138
  // ---- admin web pages (browser session + setup wizard) ---------------
1552
2139
  //
1553
2140
  // The operator signs in by pasting the ADMIN_API_KEY once; that sets a
@@ -1692,97 +2279,13 @@ var DASHBOARD_LAYOUT =
1692
2279
  " <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n" +
1693
2280
  " <meta name=\"robots\" content=\"noindex,nofollow\">\n" +
1694
2281
  " <title>Admin dashboard — {{shop_name}}</title>\n" +
1695
- " <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n" +
1696
- " <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n" +
1697
- " <link href=\"https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600;700&family=Inter:wght@400;500;600&display=swap\" rel=\"stylesheet\">\n" +
1698
- " <style>\n" +
1699
- " :root { --ink:#191919; --ink-2:#414141; --mute:#727272; --hair:#d9d9d9; --paper:#ffffff; --bg:#fafafa; --accent:#fa4f09; --accent-d:#d8410a; }\n" +
1700
- " * { box-sizing: border-box; }\n" +
1701
- " html, body { margin:0; padding:0; background:var(--bg); }\n" +
1702
- " body { font-family:'Inter',ui-sans-serif,system-ui,sans-serif; color:var(--ink); font-size:15px; line-height:1.55; }\n" +
1703
- " h1, h2, h3 { font-family:'Montserrat',sans-serif; font-weight:700; letter-spacing:-0.01em; margin:0 0 .65rem; }\n" +
1704
- " .admin-header { background:var(--ink); color:var(--paper); border-bottom:3px solid var(--accent); }\n" +
1705
- " .admin-header__inner { max-width:80rem; margin:0 auto; padding:1.2rem 1.5rem; display:flex; align-items:center; justify-content:space-between; }\n" +
1706
- " .admin-header h1 { color:var(--paper); font-size:1.1rem; margin:0; font-weight:600; letter-spacing:.02em; text-transform:uppercase; }\n" +
1707
- " .admin-header .brand-accent { color:var(--accent); }\n" +
1708
- " main { max-width:80rem; margin:0 auto; padding:2.5rem 1.5rem 5rem; }\n" +
1709
- " section { margin-bottom:2.5rem; }\n" +
1710
- " section h2 { font-size:1.1rem; text-transform:uppercase; letter-spacing:.05em; color:var(--mute); font-weight:600; margin-bottom:1rem; }\n" +
1711
- " .stat-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(12rem, 1fr)); gap:1rem; }\n" +
1712
- " .stat-card { background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.25rem 1.4rem; }\n" +
1713
- " .stat-card .label { font-size:.75rem; text-transform:uppercase; letter-spacing:.06em; color:var(--mute); font-weight:600; }\n" +
1714
- " .stat-card .value { font-family:'Montserrat',sans-serif; font-weight:700; font-size:1.8rem; color:var(--ink); margin-top:.35rem; line-height:1.1; }\n" +
1715
- " .stat-card .value.accent { color:var(--accent); }\n" +
1716
- " .panel { background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.5rem; }\n" +
1717
- " .two-col { display:grid; grid-template-columns: 2fr 1fr; gap:1.5rem; align-items:start; }\n" +
1718
- " @media (max-width: 56rem) { .two-col { grid-template-columns: 1fr; } }\n" +
1719
- " table { width:100%; border-collapse:collapse; font-size:.9rem; }\n" +
1720
- " thead th { text-align:left; padding:.65rem .75rem; border-bottom:2px solid var(--ink); font-family:'Montserrat',sans-serif; font-weight:600; font-size:.72rem; letter-spacing:.05em; text-transform:uppercase; color:var(--mute); }\n" +
1721
- " tbody td { padding:.65rem .75rem; border-bottom:1px solid var(--hair); }\n" +
1722
- " tbody tr:last-child td { border-bottom:none; }\n" +
1723
- " td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; }\n" +
1724
- " .status-pill { display:inline-block; padding:.15rem .55rem; border-radius:999px; font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.04em; background:var(--bg); color:var(--ink-2); border:1px solid var(--hair); }\n" +
1725
- " .status-pill.paid, .status-pill.fulfilling, .status-pill.shipped, .status-pill.delivered { background:#e9f5ec; color:#1f6b3a; border-color:#bfe1c9; }\n" +
1726
- " .status-pill.refunded { background:#fff1eb; color:var(--accent-d); border-color:#f6c5af; }\n" +
1727
- " .status-pill.cancelled { background:#f4f4f4; color:var(--mute); }\n" +
1728
- " .status-pill.pending { background:#fff8e1; color:#7a5d0f; border-color:#f1e1a8; }\n" +
1729
- " .spark { width:100%; height:8rem; background:var(--bg); border:1px solid var(--hair); border-radius:6px; padding:.5rem; }\n" +
1730
- " .spark svg { display:block; width:100%; height:100%; }\n" +
1731
- " .empty { color:var(--mute); font-style:italic; padding:1rem 0; text-align:center; }\n" +
1732
- " .meta { color:var(--mute); font-size:.85rem; margin-bottom:1rem; }\n" +
1733
- " .order-id { font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.78rem; color:var(--ink-2); }\n" +
1734
- " .form-field { display:block; margin-bottom:1.1rem; }\n" +
1735
- " .form-field span { display:block; font-size:.78rem; text-transform:uppercase; letter-spacing:.05em; color:var(--mute); font-weight:600; margin-bottom:.35rem; }\n" +
1736
- " .form-field input { width:100%; max-width:28rem; padding:.6rem .75rem; border:1px solid var(--hair); border-radius:6px; font:inherit; }\n" +
1737
- " .form-field input:focus { outline:2px solid var(--accent); outline-offset:1px; border-color:var(--accent); }\n" +
1738
- " .form-field small { display:block; color:var(--mute); font-size:.78rem; margin-top:.3rem; }\n" +
1739
- " .btn { display:inline-flex; align-items:center; gap:.4rem; background:var(--accent); color:var(--paper); border:1px solid var(--accent); padding:.6rem 1.1rem; border-radius:6px; font-family:'Montserrat',sans-serif; font-weight:700; font-size:.82rem; letter-spacing:.04em; text-transform:uppercase; text-decoration:none; cursor:pointer; }\n" +
1740
- " .btn:hover { background:var(--accent-d); border-color:var(--accent-d); }\n" +
1741
- " .btn--ghost { background:transparent; color:var(--ink); border-color:var(--ink); }\n" +
1742
- " .btn--ghost:hover { background:var(--ink); color:var(--paper); }\n" +
1743
- " .btn--danger { background:transparent; color:var(--accent-d); border-color:var(--accent-d); }\n" +
1744
- " .btn--danger:hover { background:var(--accent-d); color:var(--paper); }\n" +
1745
- " .order-filters { display:flex; flex-wrap:wrap; gap:.5rem; margin-bottom:1.25rem; }\n" +
1746
- " .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" +
1747
- " .chip:hover { border-color:var(--accent); }\n" +
1748
- " .chip--on { background:var(--ink); color:var(--paper); border-color:var(--ink); }\n" +
1749
- " .order-totals { width:100%; }\n" +
1750
- " .order-totals td { padding:.3rem 0; }\n" +
1751
- " .order-actions { display:flex; flex-wrap:wrap; gap:.6rem; }\n" +
1752
- " .return-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1.25rem; }\n" +
1753
- " .return-action { border:1px solid var(--hair); border-radius:8px; padding:1rem; }\n" +
1754
- " .return-action h4 { margin:0 0 .6rem; font-size:.9rem; }\n" +
1755
- " .review-card { margin-bottom:1rem; }\n" +
1756
- " .review-card__head { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:.5rem; }\n" +
1757
- " .review-card__body { margin:.25rem 0 .75rem; white-space:pre-wrap; }\n" +
1758
- " .review-stars { color:#c9821f; letter-spacing:.1em; }\n" +
1759
- " .review-reject { display:inline-flex; gap:.4rem; align-items:center; }\n" +
1760
- " .review-reject input { padding:.45rem .6rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
1761
- " .inv-row-form { display:flex; gap:.4rem; align-items:center; }\n" +
1762
- " .inv-row-form input { padding:.4rem .5rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
1763
- " tr.row--low td { background:#fff8e1; }\n" +
1764
- " .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
1765
- " .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" +
1766
- " .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
1767
- " .nav-card h3 { margin:0 0 .35rem; font-size:1.05rem; }\n" +
1768
- " .nav-card p { margin:0; color:var(--mute); font-size:.88rem; }\n" +
1769
- " .banner { padding:.9rem 1.1rem; border-radius:8px; margin-bottom:1.5rem; font-size:.92rem; }\n" +
1770
- " .banner--warn { background:#fff8e1; border:1px solid #f1e1a8; color:#7a5d0f; }\n" +
1771
- " .banner--ok { background:#e9f5ec; border:1px solid #bfe1c9; color:#1f6b3a; }\n" +
1772
- " .banner--err { background:#fff1eb; border:1px solid #f6c5af; color:var(--accent-d); }\n" +
1773
- " .actions-row { display:flex; gap:.75rem; flex-wrap:wrap; align-items:center; margin-top:1.5rem; }\n" +
1774
- " .admin-nav { background:var(--paper); border-bottom:1px solid var(--hair); }\n" +
1775
- " .admin-nav__inner { max-width:80rem; margin:0 auto; padding:0 1.5rem; display:flex; gap:.1rem; flex-wrap:wrap; }\n" +
1776
- " .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" +
1777
- " .admin-nav a:hover { color:var(--ink); }\n" +
1778
- " .admin-nav a.active { color:var(--accent); border-bottom-color:var(--accent); }\n" +
1779
- " </style>\n" +
2282
+ " RAW_ADMIN_CSS\n" +
1780
2283
  "</head>\n" +
1781
2284
  "<body>\n" +
1782
2285
  " <header class=\"admin-header\">\n" +
1783
2286
  " <div class=\"admin-header__inner\">\n" +
1784
2287
  " <h1>{{shop_name}} <span class=\"brand-accent\">/ admin</span></h1>\n" +
1785
- " <span style=\"font-size:.8rem; color:var(--mute);\">{{window_label}}</span>\n" +
2288
+ " <span class=\"window-label\">{{window_label}}</span>\n" +
1786
2289
  " </div>\n" +
1787
2290
  " </header>\n" +
1788
2291
  " {{nav}}\n" +
@@ -1903,11 +2406,11 @@ function renderDashboard(opts) {
1903
2406
  var twoCol =
1904
2407
  "<section><h2>Catalog + activity</h2><div class=\"two-col\">" +
1905
2408
  " <div class=\"panel\">" +
1906
- " <h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Top SKUs by units sold</h3>" +
2409
+ " <h3 class=\"subhead\">Top SKUs by units sold</h3>" +
1907
2410
  " <table><thead><tr><th>SKU</th><th class=\"num\">Units</th><th class=\"num\">Revenue</th></tr></thead><tbody>" + topRows + "</tbody></table>" +
1908
2411
  " </div>" +
1909
2412
  " <div class=\"panel\">" +
1910
- " <h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Recent orders</h3>" +
2413
+ " <h3 class=\"subhead\">Recent orders</h3>" +
1911
2414
  " <table><thead><tr><th>Order</th><th>Status</th><th class=\"num\">Total</th></tr></thead><tbody>" + recentRows + "</tbody></table>" +
1912
2415
  " </div>" +
1913
2416
  "</div></section>";
@@ -1943,9 +2446,12 @@ var ADMIN_NAV_ITEMS = [
1943
2446
  { key: "products", href: "/admin/products", label: "Products" },
1944
2447
  { key: "inventory", href: "/admin/inventory", label: "Inventory" },
1945
2448
  { key: "orders", href: "/admin/orders", label: "Orders" },
2449
+ { key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
1946
2450
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
1947
2451
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
1948
2452
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
2453
+ { key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
2454
+ { key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
1949
2455
  { key: "webhooks", href: "/admin/webhooks", label: "Webhooks", requires: "webhooks" },
1950
2456
  { key: "integrations", href: "/admin/integrations", label: "Integrations" },
1951
2457
  { key: "setup", href: "/admin/setup", label: "Setup" },
@@ -1970,7 +2476,9 @@ function _renderAdminShell(shopName, subtitle, bodyHtml, active, available) {
1970
2476
  window_label: subtitle || "",
1971
2477
  nav: "RAW_NAV",
1972
2478
  body: "RAW_BODY",
1973
- }).replace("RAW_NAV", _adminNav(active, available)).replace("RAW_BODY", bodyHtml);
2479
+ }).replace("RAW_ADMIN_CSS", _adminStylesheetLink())
2480
+ .replace("RAW_NAV", _adminNav(active, available))
2481
+ .replace("RAW_BODY", bodyHtml);
1974
2482
  }
1975
2483
 
1976
2484
  function renderAdminLogin(opts) {
@@ -1978,18 +2486,24 @@ function renderAdminLogin(opts) {
1978
2486
  var err = opts.error
1979
2487
  ? "<div class=\"banner banner--err\">That key didn't match. Check the ADMIN_API_KEY this deployment was started with.</div>"
1980
2488
  : "";
2489
+ var shopName = opts.shop_name || "blamejs.shop";
1981
2490
  var body =
1982
- "<section style=\"max-width:30rem;\">" +
1983
- "<h2>Sign in</h2>" + err +
1984
- "<form method=\"post\" action=\"/admin/login\">" +
1985
- "<label class=\"form-field\"><span>Admin API key</span>" +
1986
- "<input type=\"password\" name=\"token\" autocomplete=\"off\" autofocus required>" +
1987
- "<small>Paste the ADMIN_API_KEY this deployment was started with.</small>" +
1988
- "</label>" +
1989
- "<button type=\"submit\" class=\"btn\">Sign in</button>" +
1990
- "</form>" +
1991
- "</section>";
1992
- return _renderAdminShell(opts.shop_name, "Sign in", body, null);
2491
+ "<div class=\"signin-wrap\">" +
2492
+ "<div class=\"signin-card\">" +
2493
+ "<div class=\"signin-mark\"><span class=\"dot\"></span>" + _htmlEscape(shopName) + " admin</div>" +
2494
+ "<h2>Sign in</h2>" +
2495
+ "<p class=\"signin-lede\">This console manages your shop's catalog, orders, and settings.</p>" +
2496
+ err +
2497
+ "<form method=\"post\" action=\"/admin/login\">" +
2498
+ "<label class=\"form-field\"><span>Admin API key</span>" +
2499
+ "<input type=\"password\" name=\"token\" autocomplete=\"off\" autofocus required>" +
2500
+ "<small>The ADMIN_API_KEY this deployment was started with.</small>" +
2501
+ "</label>" +
2502
+ "<button type=\"submit\" class=\"btn\">Sign in</button>" +
2503
+ "</form>" +
2504
+ "</div>" +
2505
+ "</div>";
2506
+ return _renderAdminShell(shopName, "Sign in", body, null);
1993
2507
  }
1994
2508
 
1995
2509
  function renderAdminLanding(opts) {
@@ -2023,14 +2537,14 @@ function renderAdminSetup(opts) {
2023
2537
  var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved. Your shop details are live.</div>" : "";
2024
2538
  var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
2025
2539
  var body =
2026
- "<section style=\"max-width:34rem;\">" +
2540
+ "<section class=\"mw-34\">" +
2027
2541
  "<h2>Shop setup</h2>" +
2028
2542
  "<p class=\"meta\">Set the basics customers see across the storefront. You can change these any time.</p>" +
2029
2543
  saved + notice +
2030
2544
  "<form method=\"post\" action=\"/admin/setup\">" +
2031
2545
  _setupField("Shop name", "shop_name", v.shop_name, "text", "Shown in the header, page titles, and emails.", " maxlength=\"80\" required") +
2032
2546
  _setupField("Contact email", "contact_email", v.contact_email, "email", "Where customer replies and operational mail land.", " maxlength=\"160\"") +
2033
- _setupField("Default currency", "currency", v.currency, "text", "3-letter ISO 4217 code (e.g. USD, EUR, GBP).", " maxlength=\"3\" style=\"text-transform:uppercase;max-width:8rem;\"") +
2547
+ _setupField("Default currency", "currency", v.currency, "text", "3-letter ISO 4217 code (e.g. USD, EUR, GBP).", " maxlength=\"3\" class=\"input-code\"") +
2034
2548
  _setupField("Support URL", "support_url", v.support_url, "url", "Linked from the storefront footer (help centre, contact page).", " maxlength=\"300\"") +
2035
2549
  "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Save shop details</button>" +
2036
2550
  "<a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
@@ -2089,7 +2603,7 @@ function renderAdminIntegrations(opts) {
2089
2603
  "<thead><tr><th>Integration</th><th>Status</th><th>To enable</th></tr></thead>" +
2090
2604
  "<tbody>" + rows + "</tbody>" +
2091
2605
  "</table></div>" +
2092
- "<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>" +
2606
+ "<p class=\"meta mt-125\">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>" +
2093
2607
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
2094
2608
  "</section>";
2095
2609
  return _renderAdminShell(opts.shop_name, "Integrations", body, "integrations", opts.nav_available);
@@ -2115,8 +2629,8 @@ function renderAdminProducts(opts) {
2115
2629
  : "<p class=\"empty\">No products yet — create your first one below.</p>";
2116
2630
  var body =
2117
2631
  "<section><h2>Products</h2>" + created + notice + table +
2118
- "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
2119
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">New product</h3>" +
2632
+ "<div class=\"panel mt mw-34\">" +
2633
+ "<h3 class=\"subhead\">New product</h3>" +
2120
2634
  "<form method=\"post\" action=\"/admin/products\">" +
2121
2635
  _setupField("Title", "title", "", "text", "", " maxlength=\"200\" required") +
2122
2636
  _setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — the storefront URL.", " maxlength=\"200\" required") +
@@ -2220,12 +2734,12 @@ function renderAdminOrder(opts) {
2220
2734
  var actionForms = transitions.map(function (t) {
2221
2735
  if (t.on === "refund") {
2222
2736
  if (!opts.can_refund) return ""; // no payment intent — nothing to refund here
2223
- return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund\" style=\"display:inline;\">" +
2737
+ return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/refund\" class=\"form-inline\">" +
2224
2738
  "<button class=\"btn btn--danger\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
2225
2739
  "</form>";
2226
2740
  }
2227
2741
  var danger = (t.on === "cancel");
2228
- return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/transition\" style=\"display:inline;\">" +
2742
+ return "<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(o.id) + "/transition\" class=\"form-inline\">" +
2229
2743
  "<input type=\"hidden\" name=\"event\" value=\"" + _htmlEscape(t.on) + "\">" +
2230
2744
  "<button class=\"btn" + (danger ? " btn--danger" : "") + "\" type=\"submit\">" + _htmlEscape(t.label) + "</button>" +
2231
2745
  "</form>";
@@ -2233,7 +2747,7 @@ function renderAdminOrder(opts) {
2233
2747
  var actions = actionForms || "<span class=\"meta\">This order is in a final state — no further changes.</span>";
2234
2748
 
2235
2749
  var body =
2236
- "<section style=\"max-width:48rem;\">" +
2750
+ "<section class=\"mw-48\">" +
2237
2751
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">&larr; Orders</a></div>" +
2238
2752
  "<h2>Order <code class=\"order-id\">" + _htmlEscape(o.id.slice(0, 8)) + "</code> " +
2239
2753
  "<span class=\"status-pill " + _htmlEscape(o.status) + "\">" + _htmlEscape(o.status) + "</span></h2>" +
@@ -2241,19 +2755,73 @@ function renderAdminOrder(opts) {
2241
2755
  (o.payment_intent_id ? " · payment <code class=\"order-id\">" + _htmlEscape(o.payment_intent_id) + "</code>" : "") + "</p>" +
2242
2756
  moved + notice +
2243
2757
  "<div class=\"two-col\">" +
2244
- "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Items</h3>" + linesTable + "</div>" +
2245
- "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Ship to</h3>" +
2758
+ "<div class=\"panel\"><h3 class=\"subhead\">Items</h3>" + linesTable + "</div>" +
2759
+ "<div class=\"panel\"><h3 class=\"subhead\">Ship to</h3>" +
2246
2760
  (shipLines || "<span class=\"meta\">No shipping address.</span>") +
2247
- "<h3 style=\"font-size:.95rem; margin:1.25rem 0 .75rem;\">Totals</h3>" + totals +
2761
+ "<h3 class=\"subhead subhead--sp\">Totals</h3>" + totals +
2248
2762
  "</div>" +
2249
2763
  "</div>" +
2250
- "<div class=\"panel\" style=\"margin-top:1.5rem;\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Actions</h3>" +
2764
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
2251
2765
  "<div class=\"order-actions\">" + actions + "</div>" +
2252
2766
  "</div>" +
2253
2767
  "</section>";
2254
2768
  return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders", opts.nav_available);
2255
2769
  }
2256
2770
 
2771
+ // Read-only customer roster. The raw email is never stored (lookups are
2772
+ // keyed on a SHA3-512 namespace hash), so the table surfaces the display
2773
+ // name, a short id, the join date, the sign-in method (passkey count +
2774
+ // linked OAuth providers), and the order count. Order counts + sign-in
2775
+ // methods arrive as { customer_id: … } maps resolved by the route's
2776
+ // bounded aggregate queries — the renderer just looks them up, no work
2777
+ // per row beyond the lookup. No row actions: the storefront owns account
2778
+ // mutation via the passkey / OIDC ceremonies.
2779
+ function renderAdminCustomers(opts) {
2780
+ opts = opts || {};
2781
+ var customers = opts.customers || [];
2782
+ var orderCounts = opts.order_counts || {};
2783
+ var passkeyCounts = opts.passkey_counts || {};
2784
+ var oauthProviders = opts.oauth_providers || {};
2785
+
2786
+ var rows = customers.map(function (c) {
2787
+ var passkeys = passkeyCounts[c.id] || 0;
2788
+ var providers = oauthProviders[c.id] || [];
2789
+ // Sign-in method chips: one per passkey-count + one per linked OAuth
2790
+ // provider. A customer mid-registration (no credential yet) shows none.
2791
+ var methodCells = [];
2792
+ if (passkeys > 0) {
2793
+ methodCells.push("<span class=\"chip\">" + _htmlEscape(passkeys === 1 ? "1 passkey" : passkeys + " passkeys") + "</span>");
2794
+ }
2795
+ for (var i = 0; i < providers.length; i += 1) {
2796
+ methodCells.push("<span class=\"chip\">" + _htmlEscape(providers[i]) + "</span>");
2797
+ }
2798
+ var method = methodCells.length ? methodCells.join(" ") : "<span class=\"meta\">—</span>";
2799
+ var orders = orderCounts[c.id] || 0;
2800
+ return "<tr>" +
2801
+ "<td><strong>" + _htmlEscape(c.display_name) + "</strong></td>" +
2802
+ "<td><code class=\"order-id\">" + _htmlEscape(String(c.id).slice(0, 8)) + "</code></td>" +
2803
+ "<td>" + _htmlEscape(_fmtDate(c.created_at)) + "</td>" +
2804
+ "<td>" + method + "</td>" +
2805
+ "<td class=\"num\">" + _htmlEscape(String(orders)) + "</td>" +
2806
+ "</tr>";
2807
+ }).join("");
2808
+
2809
+ var table = customers.length
2810
+ ? "<div class=\"panel\"><table><thead><tr><th>Name</th><th>ID</th><th>Joined</th><th>Sign-in method</th><th class=\"num\">Orders</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
2811
+ : "<p class=\"empty\">No customers yet.</p>";
2812
+
2813
+ // Cursor pager — a Next link when the page filled and more rows remain.
2814
+ // The opaque cursor is HMAC-tagged by customers.list; encode it for the URL.
2815
+ var pager = opts.next_cursor
2816
+ ? "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/customers?cursor=" + _htmlEscape(encodeURIComponent(opts.next_cursor)) + "\">Next page <span aria-hidden=\"true\">→</span></a></div>"
2817
+ : "";
2818
+
2819
+ var body = "<section><h2>Customers</h2>" +
2820
+ "<p class=\"meta\">Accounts are passwordless — customers enrol a passkey or sign in with a federated provider. Email addresses aren't stored in the clear, so they're not shown here.</p>" +
2821
+ table + pager + "</section>";
2822
+ return _renderAdminShell(opts.shop_name, "Customers", body, "customers", opts.nav_available);
2823
+ }
2824
+
2257
2825
  // The RMA states an operator can filter the returns queue by — drives the
2258
2826
  // filter chips, lifecycle order then terminal.
2259
2827
  var RETURN_STATUS_FILTERS = ["pending", "approved", "received", "refunded", "rejected"];
@@ -2332,7 +2900,7 @@ function renderAdminReturn(opts) {
2332
2900
  "<form method=\"post\" action=\"/admin/returns/" + _htmlEscape(r.id) + "/approve\" class=\"return-action\">" +
2333
2901
  "<h4>Approve</h4>" +
2334
2902
  _setupField("Refund amount (minor units)", "refund_amount_minor", "", "number", "e.g. 4999 for $49.99.", " min=\"0\" required") +
2335
- _setupField("Refund currency", "refund_currency", r.refund_currency || "USD", "text", "3-letter ISO 4217.", " maxlength=\"3\" style=\"text-transform:uppercase;max-width:8rem;\"") +
2903
+ _setupField("Refund currency", "refund_currency", r.refund_currency || "USD", "text", "3-letter ISO 4217.", " maxlength=\"3\" class=\"input-code\"") +
2336
2904
  _setupField("Operator notes", "operator_notes", "", "text", "", " maxlength=\"500\"") +
2337
2905
  "<button class=\"btn\" type=\"submit\">Approve return</button>" +
2338
2906
  "</form>");
@@ -2367,7 +2935,7 @@ function renderAdminReturn(opts) {
2367
2935
  : "<span class=\"meta\">This return is in a final state — no further changes.</span>";
2368
2936
 
2369
2937
  var body =
2370
- "<section style=\"max-width:48rem;\">" +
2938
+ "<section class=\"mw-48\">" +
2371
2939
  "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/returns\">&larr; Returns</a></div>" +
2372
2940
  "<h2>Return <code class=\"order-id\">" + _htmlEscape(r.rma_code || r.id.slice(0, 8)) + "</code> " +
2373
2941
  "<span class=\"status-pill " + _returnPillClass(r.status) + "\">" + _htmlEscape(r.status) + "</span></h2>" +
@@ -2375,8 +2943,8 @@ function renderAdminReturn(opts) {
2375
2943
  " · order <a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(r.order_id) + "\">" + _htmlEscape(String(r.order_id).slice(0, 8)) + "</a></p>" +
2376
2944
  moved + notice +
2377
2945
  "<div class=\"two-col\">" +
2378
- "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Items</h3>" + linesTable + "</div>" +
2379
- "<div class=\"panel\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Details</h3>" +
2946
+ "<div class=\"panel\"><h3 class=\"subhead\">Items</h3>" + linesTable + "</div>" +
2947
+ "<div class=\"panel\"><h3 class=\"subhead\">Details</h3>" +
2380
2948
  _field("Reason", r.reason) +
2381
2949
  _field("Customer detail", r.reason_detail) +
2382
2950
  _field("Customer notes", r.customer_notes) +
@@ -2385,7 +2953,7 @@ function renderAdminReturn(opts) {
2385
2953
  (r.rejected_reason ? _field("Rejection reason", r.rejected_reason) : "") +
2386
2954
  "</div>" +
2387
2955
  "</div>" +
2388
- "<div class=\"panel\" style=\"margin-top:1.5rem;\"><h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Actions</h3>" +
2956
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
2389
2957
  actions +
2390
2958
  "</div>" +
2391
2959
  "</section>";
@@ -2418,7 +2986,7 @@ function renderAdminReviews(opts) {
2418
2986
  // sense from its current status (a rejected review can be published, a
2419
2987
  // published one taken down, a pending one either way).
2420
2988
  var cards = list.map(function (rv) {
2421
- var pub = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/publish\" style=\"display:inline;\">" +
2989
+ var pub = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/publish\" class=\"form-inline\">" +
2422
2990
  "<button class=\"btn\" type=\"submit\">Publish</button></form>";
2423
2991
  var rej = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/reject\" class=\"review-reject\">" +
2424
2992
  "<input type=\"text\" name=\"reason\" placeholder=\"Reason (shown in the log)\" maxlength=\"300\" required>" +
@@ -2469,8 +3037,8 @@ function renderAdminInventory(opts) {
2469
3037
  "<td class=\"num\"><strong>" + _htmlEscape(String(available)) + "</strong></td>" +
2470
3038
  "<td>" +
2471
3039
  "<form method=\"post\" action=\"/admin/inventory/" + _htmlEscape(r.sku) + "/restock\" class=\"inv-row-form\">" +
2472
- "<input type=\"number\" name=\"qty\" min=\"1\" placeholder=\"+ qty\" style=\"width:6rem;\">" +
2473
- "<input type=\"number\" name=\"threshold\" min=\"0\" value=\"" + _htmlEscape(thVal) + "\" placeholder=\"alert ≤\" title=\"low-stock threshold (blank clears)\" style=\"width:6rem;\">" +
3040
+ "<input type=\"number\" name=\"qty\" min=\"1\" placeholder=\"+ qty\" class=\"input-narrow\">" +
3041
+ "<input type=\"number\" name=\"threshold\" min=\"0\" value=\"" + _htmlEscape(thVal) + "\" placeholder=\"alert ≤\" title=\"low-stock threshold (blank clears)\" class=\"input-narrow\">" +
2474
3042
  "<button class=\"btn btn--ghost\" type=\"submit\">Save</button>" +
2475
3043
  "</form>" +
2476
3044
  "</td></tr>";
@@ -2481,8 +3049,8 @@ function renderAdminInventory(opts) {
2481
3049
  : "<p class=\"empty\">No inventory rows" + (opts.low ? " below threshold" : " yet") + ".</p>";
2482
3050
 
2483
3051
  var createForm =
2484
- "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
2485
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Track a new SKU</h3>" +
3052
+ "<div class=\"panel mt mw-34\">" +
3053
+ "<h3 class=\"subhead\">Track a new SKU</h3>" +
2486
3054
  "<form method=\"post\" action=\"/admin/inventory\">" +
2487
3055
  _setupField("SKU", "sku", "", "text", "Must match a variant SKU.", " maxlength=\"128\" required") +
2488
3056
  _setupField("Starting stock on hand", "stock_on_hand", "0", "number", "", " min=\"0\"") +
@@ -2519,7 +3087,7 @@ function renderAdminSubscriptionPlans(opts) {
2519
3087
  var price = pricing.format(p.amount_minor, String(p.currency || "").toUpperCase()) + " / " + every;
2520
3088
  var isActive = p.active === 1 || p.active === true;
2521
3089
  var archiveCell = isActive
2522
- ? "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive\" style=\"display:inline;\">" +
3090
+ ? "<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive\" class=\"form-inline\">" +
2523
3091
  "<button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>"
2524
3092
  : "<span class=\"meta\">—</span>";
2525
3093
  return "<tr>" +
@@ -2541,8 +3109,8 @@ function renderAdminSubscriptionPlans(opts) {
2541
3109
  }).join("");
2542
3110
 
2543
3111
  var createForm =
2544
- "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
2545
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Create a plan</h3>" +
3112
+ "<div class=\"panel mt mw-34\">" +
3113
+ "<h3 class=\"subhead\">Create a plan</h3>" +
2546
3114
  "<p class=\"meta\">Pre-create the recurring Price in Stripe, then mirror it here so the storefront can render the plan without a network hop.</p>" +
2547
3115
  "<form method=\"post\" action=\"/admin/subscription-plans\">" +
2548
3116
  _setupField("Stripe price id", "stripe_price_id", "", "text", "The recurring Price id from your Stripe dashboard (e.g. price_…).", " maxlength=\"255\" required") +
@@ -2560,6 +3128,121 @@ function renderAdminSubscriptionPlans(opts) {
2560
3128
  return _renderAdminShell(opts.shop_name, "Subscription plans", bodyHtml, "subscriptions", opts.nav_available);
2561
3129
  }
2562
3130
 
3131
+ // Gift-card ledger console — list issued cards. The code is masked to
3132
+ // its `code_hint` (last 4 plaintext chars) prefixed with the standard
3133
+ // •••• fill; the plaintext bearer code is never stored and so never
3134
+ // rendered here. Original (issued) + remaining balance + lifecycle
3135
+ // status + issued date per row, newest first.
3136
+ function renderAdminGiftCards(opts) {
3137
+ opts = opts || {};
3138
+ var rows = opts.cards || [];
3139
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
3140
+
3141
+ var sf = opts.status_filter;
3142
+ var STATUS = ["active", "redeemed", "expired", "voided"];
3143
+ var chipHtml = "<a class=\"chip" + (sf == null ? " chip--on" : "") + "\" href=\"/admin/gift-cards\">All</a>";
3144
+ for (var s = 0; s < STATUS.length; s += 1) {
3145
+ chipHtml += "<a class=\"chip" + (sf === STATUS[s] ? " chip--on" : "") +
3146
+ "\" href=\"/admin/gift-cards?status=" + STATUS[s] + "\">" + STATUS[s] + "</a>";
3147
+ }
3148
+ var chips = "<div class=\"order-filters\">" + chipHtml + "</div>";
3149
+
3150
+ // Card lifecycle → status-pill class (reuse the order pills): active
3151
+ // = paid (green), redeemed = neutral, expired/voided = cancelled.
3152
+ function _pill(status) {
3153
+ var cls = status === "active" ? "paid" : (status === "redeemed" ? "pending" : "cancelled");
3154
+ return "<span class=\"status-pill " + cls + "\">" + _htmlEscape(status) + "</span>";
3155
+ }
3156
+
3157
+ var bodyRows = rows.map(function (gc) {
3158
+ var cur = String(gc.currency || "").toUpperCase();
3159
+ var issued = pricing.format(gc.issued_minor, cur);
3160
+ var remaining = pricing.format(gc.balance_minor, cur);
3161
+ return "<tr>" +
3162
+ "<td><code class=\"order-id\">••••" + _htmlEscape(gc.code_hint) + "</code></td>" +
3163
+ "<td class=\"num\">" + _htmlEscape(issued) + "</td>" +
3164
+ "<td class=\"num\"><strong>" + _htmlEscape(remaining) + "</strong></td>" +
3165
+ "<td>" + _pill(gc.status) + "</td>" +
3166
+ "<td>" + _htmlEscape(_fmtDate(gc.created_at)) + "</td>" +
3167
+ "<td><a class=\"btn btn--ghost\" href=\"/admin/gift-cards/" + _htmlEscape(gc.id) + "\">Ledger</a></td>" +
3168
+ "</tr>";
3169
+ }).join("");
3170
+
3171
+ var table = rows.length
3172
+ ? "<div class=\"panel\"><table><thead><tr><th>Code</th><th class=\"num\">Issued</th><th class=\"num\">Remaining</th><th>Status</th><th>Issued on</th><th>Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3173
+ : "<p class=\"empty\">No gift cards" + (sf ? " " + _htmlEscape(sf) : " yet") + ".</p>";
3174
+
3175
+ // The issue form composes the giftcards primitive's issue() — the
3176
+ // plaintext code is shown once on the card's detail page after a
3177
+ // successful issue, never again.
3178
+ var issueForm =
3179
+ "<div class=\"panel mt mw-34\">" +
3180
+ "<h3 class=\"subhead\">Issue a card</h3>" +
3181
+ "<p class=\"meta\">Generates a bearer code. You'll see the code once, on the next screen — deliver it to the recipient; it can't be shown again.</p>" +
3182
+ "<form method=\"post\" action=\"/admin/gift-cards\">" +
3183
+ _setupField("Amount (minor units)", "amount_minor", "", "number", "In the currency's smallest unit — e.g. 5000 = $50.00.", " min=\"1\" required") +
3184
+ _setupField("Currency", "currency", "", "text", "3-letter ISO 4217 (e.g. USD).", " maxlength=\"3\" required") +
3185
+ _setupField("Recipient email (optional)", "issued_to_email", "", "email", "Stored as a one-way hash so the recipient can claim it after sign-in.", " maxlength=\"160\"") +
3186
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Issue card</button></div>" +
3187
+ "</form>" +
3188
+ "</div>";
3189
+
3190
+ var body = "<section><h2>Gift cards</h2>" + notice + chips + table + issueForm + "</section>";
3191
+ return _renderAdminShell(opts.shop_name, "Gift cards", body, "giftcards", opts.nav_available);
3192
+ }
3193
+
3194
+ // Single-card detail: the masked code, the balances + status, the
3195
+ // (optionally shown-once) freshly-issued plaintext code, and the
3196
+ // append-only ledger of transactions against the card.
3197
+ function renderAdminGiftCard(opts) {
3198
+ opts = opts || {};
3199
+ var gc = opts.card;
3200
+ if (!gc) {
3201
+ var nf = "<section><h2>Gift card</h2><p class=\"empty\">Gift card not found.</p>" +
3202
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/gift-cards\">Back to gift cards</a></div></section>";
3203
+ return _renderAdminShell(opts.shop_name, "Gift card", nf, "giftcards", opts.nav_available);
3204
+ }
3205
+ var cur = String(gc.currency || "").toUpperCase();
3206
+ var issuedBanner = opts.issued_code
3207
+ ? "<div class=\"banner banner--ok\">Copy the code now — it is shown once and cannot be retrieved again: " +
3208
+ "<code class=\"order-id\">" + _htmlEscape(opts.issued_code) + "</code></div>"
3209
+ : "";
3210
+
3211
+ var statusCls = gc.status === "active" ? "paid" : (gc.status === "redeemed" ? "pending" : "cancelled");
3212
+ var summary =
3213
+ "<div class=\"panel\"><dl class=\"detail-grid\">" +
3214
+ "<div><dt>Code</dt><dd><code class=\"order-id\">••••" + _htmlEscape(gc.code_hint) + "</code></dd></div>" +
3215
+ "<div><dt>Issued</dt><dd>" + _htmlEscape(pricing.format(gc.issued_minor, cur)) + "</dd></div>" +
3216
+ "<div><dt>Remaining</dt><dd><strong>" + _htmlEscape(pricing.format(gc.balance_minor, cur)) + "</strong></dd></div>" +
3217
+ "<div><dt>Status</dt><dd><span class=\"status-pill " + statusCls + "\">" + _htmlEscape(gc.status) + "</span></dd></div>" +
3218
+ "<div><dt>Issued on</dt><dd>" + _htmlEscape(_fmtDate(gc.created_at)) + "</dd></div>" +
3219
+ "<div><dt>Expires</dt><dd>" + (gc.expires_at ? _htmlEscape(_fmtDate(gc.expires_at)) : "<span class=\"meta\">never</span>") + "</dd></div>" +
3220
+ "</dl></div>";
3221
+
3222
+ var ledger = opts.ledger || [];
3223
+ var ledgerRows = ledger.map(function (row) {
3224
+ var detail = row.order_id
3225
+ ? "<code class=\"order-id\">" + _htmlEscape(String(row.order_id).slice(0, 8)) + "</code>"
3226
+ : (row.source ? _htmlEscape(row.source) + (row.source_ref ? " · " + _htmlEscape(row.source_ref) : "") : "<span class=\"meta\">—</span>");
3227
+ var sign = row.kind === "credit" ? "+" : "−";
3228
+ return "<tr>" +
3229
+ "<td>" + _htmlEscape(row.kind) + "</td>" +
3230
+ "<td class=\"num\">" + sign + _htmlEscape(pricing.format(row.amount_minor, cur)) + "</td>" +
3231
+ "<td class=\"num\">" + _htmlEscape(pricing.format(row.balance_after_minor, cur)) + "</td>" +
3232
+ "<td>" + detail + "</td>" +
3233
+ "<td>" + _htmlEscape(_fmtDate(row.occurred_at)) + "</td>" +
3234
+ "</tr>";
3235
+ }).join("");
3236
+ var ledgerTable = ledger.length
3237
+ ? "<div class=\"panel\"><table><thead><tr><th>Type</th><th class=\"num\">Amount</th><th class=\"num\">Balance after</th><th>Detail</th><th>When</th></tr></thead><tbody>" + ledgerRows + "</tbody></table></div>"
3238
+ : "<p class=\"empty\">No ledger transactions recorded for this card yet.</p>";
3239
+
3240
+ var body = "<section><h2>Gift card</h2>" + issuedBanner + summary +
3241
+ "<h3 class=\"subhead subhead--sp-lg\">Ledger</h3>" + ledgerTable +
3242
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/gift-cards\">Back to gift cards</a></div></section>";
3243
+ return _renderAdminShell(opts.shop_name, "Gift card", body, "giftcards", opts.nav_available);
3244
+ }
3245
+
2563
3246
  function renderAdminWebhooks(opts) {
2564
3247
  opts = opts || {};
2565
3248
  var rows = opts.endpoints || [];
@@ -2580,8 +3263,8 @@ function renderAdminWebhooks(opts) {
2580
3263
  "<td class=\"num\">" + _htmlEscape(String(e.rate_limit_per_minute)) + "/min</td>" +
2581
3264
  "<td>" +
2582
3265
  "<a class=\"btn btn--ghost\" href=\"/admin/webhooks/" + _htmlEscape(e.id) + "/deliveries\">Deliveries</a> " +
2583
- "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/toggle\" style=\"display:inline;\"><button class=\"btn btn--ghost\" type=\"submit\">" + (isActive ? "Disable" : "Enable") + "</button></form> " +
2584
- "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/delete\" style=\"display:inline;\"><button class=\"btn btn--danger\" type=\"submit\">Delete</button></form>" +
3266
+ "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/toggle\" class=\"form-inline\"><button class=\"btn btn--ghost\" type=\"submit\">" + (isActive ? "Disable" : "Enable") + "</button></form> " +
3267
+ "<form method=\"post\" action=\"/admin/webhooks/" + _htmlEscape(e.id) + "/delete\" class=\"form-inline\"><button class=\"btn btn--danger\" type=\"submit\">Delete</button></form>" +
2585
3268
  "</td></tr>";
2586
3269
  }).join("");
2587
3270
 
@@ -2590,20 +3273,20 @@ function renderAdminWebhooks(opts) {
2590
3273
  : "<p class=\"empty\">No webhook endpoints yet.</p>";
2591
3274
 
2592
3275
  var eventChecks = known.map(function (ev) {
2593
- return "<label style=\"display:block; margin:.3rem 0;\"><input type=\"checkbox\" name=\"evt_" + _htmlEscape(ev) + "\"> <code>" + _htmlEscape(ev) + "</code></label>";
3276
+ return "<label class=\"kv\"><input type=\"checkbox\" name=\"evt_" + _htmlEscape(ev) + "\"> <code>" + _htmlEscape(ev) + "</code></label>";
2594
3277
  }).join("");
2595
3278
 
2596
3279
  var createForm =
2597
- "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:40rem;\">" +
2598
- "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Add an endpoint</h3>" +
3280
+ "<div class=\"panel mt mw-40\">" +
3281
+ "<h3 class=\"subhead\">Add an endpoint</h3>" +
2599
3282
  "<p class=\"meta\">Deliveries are signed (HMAC-SHA3-512); the signing secret is shown once, right after you create the endpoint. Only https:// URLs are accepted.</p>" +
2600
3283
  "<form method=\"post\" action=\"/admin/webhooks\">" +
2601
3284
  _setupField("Endpoint URL", "url", "", "url", "Where deliveries are POSTed (https:// only).", " maxlength=\"2048\" required") +
2602
- "<fieldset style=\"border:1px solid var(--hair); border-radius:.5rem; padding:.75rem 1rem; margin:1rem 0;\">" +
2603
- "<legend style=\"padding:0 .4rem; font-size:.85rem;\">Events</legend>" +
2604
- "<label style=\"display:block; margin:.3rem 0;\"><input type=\"checkbox\" name=\"events_all\"> <strong>All events (*)</strong></label>" +
3285
+ "<fieldset class=\"box\">" +
3286
+ "<legend class=\"legend-sm\">Events</legend>" +
3287
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"events_all\"> <strong>All events (*)</strong></label>" +
2605
3288
  eventChecks +
2606
- "<small style=\"color:var(--mute);\">Pick specific events, or check “All events” to subscribe to everything.</small>" +
3289
+ "<small class=\"u-mute\">Pick specific events, or check “All events” to subscribe to everything.</small>" +
2607
3290
  "</fieldset>" +
2608
3291
  "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create endpoint</button></div>" +
2609
3292
  "</form>" +
@@ -2617,13 +3300,13 @@ function renderAdminWebhookSecret(opts) {
2617
3300
  opts = opts || {};
2618
3301
  var e = opts.endpoint || {};
2619
3302
  var body =
2620
- "<section style=\"max-width:42rem;\">" +
3303
+ "<section class=\"mw-42\">" +
2621
3304
  "<h2>Endpoint created</h2>" +
2622
3305
  "<div class=\"banner banner--ok\">Copy the signing secret now — it is shown once and cannot be retrieved again.</div>" +
2623
3306
  "<div class=\"panel\">" +
2624
3307
  "<p class=\"meta\">Endpoint</p><p><code class=\"order-id\">" + _htmlEscape(e.url || "") + "</code></p>" +
2625
3308
  "<p class=\"meta\">Signing secret (HMAC-SHA3-512, key id <code>v1</code>)</p>" +
2626
- "<pre style=\"white-space:pre-wrap; word-break:break-all; background:var(--bg); border:1px solid var(--hair); border-radius:.5rem; padding:.75rem;\"><code>" + _htmlEscape(e.secret || "") + "</code></pre>" +
3309
+ "<pre class=\"code-block\"><code>" + _htmlEscape(e.secret || "") + "</code></pre>" +
2627
3310
  "<p class=\"meta\">Verify each delivery's signature with this secret using your framework's webhook verifier.</p>" +
2628
3311
  "</div>" +
2629
3312
  "<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/webhooks\">Done</a></div>" +
@@ -2650,7 +3333,7 @@ function renderAdminWebhookDeliveries(opts) {
2650
3333
  : "<span class=\"status-pill " + (d.last_error ? "refunded" : "pending") + "\">" + (d.last_error ? "failed" : "pending") + "</span>";
2651
3334
  var code = d.last_status != null ? _htmlEscape(String(d.last_status)) : "—";
2652
3335
  var retry = ok ? "<span class=\"meta\">—</span>"
2653
- : "<form method=\"post\" action=\"/admin/webhooks/deliveries/" + _htmlEscape(d.id) + "/retry\" style=\"display:inline;\"><button class=\"btn btn--ghost\" type=\"submit\">Retry</button></form>";
3336
+ : "<form method=\"post\" action=\"/admin/webhooks/deliveries/" + _htmlEscape(d.id) + "/retry\" class=\"form-inline\"><button class=\"btn btn--ghost\" type=\"submit\">Retry</button></form>";
2654
3337
  return "<tr>" +
2655
3338
  "<td>" + _htmlEscape(d.event_type) + "</td>" +
2656
3339
  "<td>" + statusCell + "</td>" +
@@ -2672,6 +3355,215 @@ function renderAdminWebhookDeliveries(opts) {
2672
3355
  return _renderAdminShell(opts.shop_name, "Deliveries", body, "webhooks", opts.nav_available);
2673
3356
  }
2674
3357
 
3358
+ function renderAdminCollections(opts) {
3359
+ opts = opts || {};
3360
+ var rows = opts.collections || [];
3361
+ var created = opts.created ? "<div class=\"banner banner--ok\">Collection created.</div>" : "";
3362
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Collection archived.</div>" : "";
3363
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
3364
+
3365
+ var af = opts.active_filter;
3366
+ var chips = "<div class=\"order-filters\">" +
3367
+ "<a class=\"chip" + (af == null ? " chip--on" : "") + "\" href=\"/admin/collections\">All</a>" +
3368
+ "<a class=\"chip" + (af === "1" ? " chip--on" : "") + "\" href=\"/admin/collections?active=1\">Active</a>" +
3369
+ "<a class=\"chip" + (af === "0" ? " chip--on" : "") + "\" href=\"/admin/collections?active=0\">Archived</a>" +
3370
+ "</div>";
3371
+
3372
+ var bodyRows = rows.map(function (c) {
3373
+ var isArchived = c.archived_at != null;
3374
+ var typeLabel = c.type === "smart" ? "smart" : "manual";
3375
+ var countLabel = c._count == null ? "—" : String(c._count);
3376
+ var countTitle = c.type === "smart" ? "matched products" : "members";
3377
+ return "<tr>" +
3378
+ "<td><a href=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "\"><strong>" + _htmlEscape(c.title) + "</strong></a></td>" +
3379
+ "<td><code class=\"order-id\">" + _htmlEscape(c.slug) + "</code></td>" +
3380
+ "<td><span class=\"status-pill " + (c.type === "smart" ? "pending" : "paid") + "\">" + _htmlEscape(typeLabel) + "</span></td>" +
3381
+ "<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
3382
+ "<td class=\"num\" title=\"" + _htmlEscape(countTitle) + "\">" + _htmlEscape(countLabel) + "</td>" +
3383
+ "<td><div class=\"actions-row\">" +
3384
+ "<a class=\"btn btn--ghost\" href=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "\">Manage</a>" +
3385
+ (isArchived ? "" :
3386
+ "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "/archive\" class=\"form-inline\">" +
3387
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
3388
+ "</div></td>" +
3389
+ "</tr>";
3390
+ }).join("");
3391
+
3392
+ var table = rows.length
3393
+ ? "<div class=\"panel\"><table><thead><tr><th>Title</th><th>Slug</th><th>Type</th><th>Status</th><th class=\"num\">Products</th><th>Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
3394
+ : "<p class=\"empty\">No collections" + (af === "0" ? " archived" : af === "1" ? " active" : " yet") + ".</p>";
3395
+
3396
+ // The create form toggles between a manual and a smart shape. Manual
3397
+ // needs only title + slug; smart adds one starter rule row (the detail
3398
+ // page's rule editor adds more after the collection exists).
3399
+ var startType = opts.form_type === "smart" ? "smart" : "manual";
3400
+ var fieldOpts = collectionsModule.RULE_FIELDS.map(function (f) {
3401
+ return "<option value=\"" + _htmlEscape(f) + "\">" + _htmlEscape(f) + "</option>";
3402
+ }).join("");
3403
+ var opOpts = collectionsModule.RULE_OPS.map(function (o) {
3404
+ return "<option value=\"" + _htmlEscape(o) + "\">" + _htmlEscape(o) + "</option>";
3405
+ }).join("");
3406
+
3407
+ var createForm =
3408
+ "<div class=\"panel mt mw-40\">" +
3409
+ "<h3 class=\"subhead\">Create a collection</h3>" +
3410
+ "<p class=\"meta\">Manual collections are handpicked; smart collections match products by a rule set. Slug is the storefront URL (/collections/&lt;slug&gt;).</p>" +
3411
+ "<form method=\"post\" action=\"/admin/collections\">" +
3412
+ "<label class=\"form-field\"><span>Type</span><select name=\"type\">" +
3413
+ "<option value=\"manual\"" + (startType === "manual" ? " selected" : "") + ">manual</option>" +
3414
+ "<option value=\"smart\"" + (startType === "smart" ? " selected" : "") + ">smart</option>" +
3415
+ "</select></label>" +
3416
+ _setupField("Title", "title", "", "text", "", " maxlength=\"500\" required") +
3417
+ _setupField("Slug", "slug", "", "text", "Lowercase, hyphenated.", " maxlength=\"200\" required") +
3418
+ _setupField("Description (optional)", "description", "", "text", "", " maxlength=\"2000\"") +
3419
+ "<fieldset class=\"box\">" +
3420
+ "<legend class=\"legend-sm\">Smart rule (used only for smart collections)</legend>" +
3421
+ "<div class=\"actions-row\">" +
3422
+ "<select name=\"rule_field\"><option value=\"\">field…</option>" + fieldOpts + "</select>" +
3423
+ "<select name=\"rule_op\"><option value=\"\">op…</option>" + opOpts + "</select>" +
3424
+ "<input type=\"text\" name=\"rule_value\" placeholder=\"value (lists + between: comma-separated)\" maxlength=\"500\">" +
3425
+ "</div>" +
3426
+ "<small class=\"u-mute\">Numeric fields (price_minor, inventory_count, created_at) compare as integers. Add more rules after creating.</small>" +
3427
+ "</fieldset>" +
3428
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create collection</button></div>" +
3429
+ "</form>" +
3430
+ "</div>";
3431
+
3432
+ var bodyHtml = "<section><h2>Collections</h2>" + created + archived + notice + chips + table + createForm + "</section>";
3433
+ return _renderAdminShell(opts.shop_name, "Collections", bodyHtml, "collections", opts.nav_available);
3434
+ }
3435
+
3436
+ function renderAdminCollection(opts) {
3437
+ opts = opts || {};
3438
+ var col = opts.collection;
3439
+ if (!col) {
3440
+ var nf = "<section><h2>Collection</h2><p class=\"empty\">Collection not found.</p>" +
3441
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/collections\">Back to collections</a></div></section>";
3442
+ return _renderAdminShell(opts.shop_name, "Collection", nf, "collections", opts.nav_available);
3443
+ }
3444
+ var updated = (opts.updated || opts.saved) ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
3445
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
3446
+ var enc = encodeURIComponent(col.slug);
3447
+
3448
+ var sortStrategies = opts.sort_strategies || collectionsModule.SORT_STRATEGIES;
3449
+ var sortOpts = sortStrategies.filter(function (s) {
3450
+ // `manual` sort only applies to manual collections (the primitive
3451
+ // refuses it on smart) — drop it from the smart editor's options.
3452
+ return !(col.type === "smart" && s === "manual");
3453
+ }).map(function (s) {
3454
+ return "<option value=\"" + _htmlEscape(s) + "\"" + (s === col.sort_strategy ? " selected" : "") + ">" + _htmlEscape(s) + "</option>";
3455
+ }).join("");
3456
+
3457
+ var editForm =
3458
+ "<div class=\"panel mw-40\">" +
3459
+ "<h3 class=\"subhead\">Details</h3>" +
3460
+ "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/edit\">" +
3461
+ _setupField("Title", "title", col.title, "text", "", " maxlength=\"500\" required") +
3462
+ _setupField("Description", "description", col.description || "", "text", "", " maxlength=\"2000\"") +
3463
+ "<label class=\"form-field\"><span>Sort strategy</span><select name=\"sort_strategy\">" + sortOpts + "</select></label>";
3464
+
3465
+ if (col.type === "smart") {
3466
+ var fields = opts.rule_fields || collectionsModule.RULE_FIELDS;
3467
+ var ops = opts.rule_ops || collectionsModule.RULE_OPS;
3468
+ var existing = (col.rules && Array.isArray(col.rules.all)) ? col.rules.all : [];
3469
+ // Render the existing rules plus one spare empty row so the operator
3470
+ // can append without a separate "add row" round-trip.
3471
+ var ruleRows = existing.concat([{ field: "", op: "", value: "" }]).map(function (rule) {
3472
+ var rv = rule.value;
3473
+ if (Array.isArray(rv)) rv = rv.join(",");
3474
+ else if (rv == null) rv = "";
3475
+ var fieldOpts = "<option value=\"\">field…</option>" + fields.map(function (f) {
3476
+ return "<option value=\"" + _htmlEscape(f) + "\"" + (f === rule.field ? " selected" : "") + ">" + _htmlEscape(f) + "</option>";
3477
+ }).join("");
3478
+ var opOpts = "<option value=\"\">op…</option>" + ops.map(function (o) {
3479
+ return "<option value=\"" + _htmlEscape(o) + "\"" + (o === rule.op ? " selected" : "") + ">" + _htmlEscape(o) + "</option>";
3480
+ }).join("");
3481
+ return "<div class=\"actions-row m-04\">" +
3482
+ "<select name=\"rule_field\">" + fieldOpts + "</select>" +
3483
+ "<select name=\"rule_op\">" + opOpts + "</select>" +
3484
+ "<input type=\"text\" name=\"rule_value\" value=\"" + _htmlEscape(String(rv)) + "\" placeholder=\"value\" maxlength=\"500\">" +
3485
+ "</div>";
3486
+ }).join("");
3487
+ editForm +=
3488
+ "<fieldset class=\"box\">" +
3489
+ "<legend class=\"legend-sm\">Rules (all must match)</legend>" +
3490
+ ruleRows +
3491
+ "<small class=\"u-mute\">Leave a row blank to drop it. Lists (in / not_in) + between use comma-separated values.</small>" +
3492
+ "</fieldset>";
3493
+ }
3494
+ editForm += "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save</button></div></form></div>";
3495
+
3496
+ var detailBody;
3497
+ if (col.type === "manual") {
3498
+ var members = opts.members || [];
3499
+ var memberRows = members.map(function (m) {
3500
+ return "<tr>" +
3501
+ "<td class=\"num\">" + _htmlEscape(String(m.position)) + "</td>" +
3502
+ "<td><code class=\"order-id\">" + _htmlEscape(m.product_id) + "</code></td>" +
3503
+ "<td><form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/remove\" class=\"form-inline\">" +
3504
+ "<input type=\"hidden\" name=\"product_id\" value=\"" + _htmlEscape(m.product_id) + "\">" +
3505
+ "<button class=\"btn btn--danger\" type=\"submit\">Remove</button></form></td>" +
3506
+ "</tr>";
3507
+ }).join("");
3508
+ var memberTable = members.length
3509
+ ? "<div class=\"panel\"><table><thead><tr><th class=\"num\">#</th><th>Product id</th><th></th></tr></thead><tbody>" + memberRows + "</tbody></table></div>"
3510
+ : "<p class=\"empty\">No members yet — add a product below.</p>";
3511
+
3512
+ // Reorder: a single field of the current ids, comma-joined, that the
3513
+ // operator can rewrite into the new order. v1-defensible without
3514
+ // client JS — the primitive normalises positions to 0..N-1.
3515
+ var currentIds = members.map(function (m) { return m.product_id; }).join(",");
3516
+ var reorderForm = members.length > 1
3517
+ ? "<div class=\"panel mt-1 mw-40\">" +
3518
+ "<h3 class=\"subhead\">Reorder members</h3>" +
3519
+ "<p class=\"meta\">Rewrite the comma-separated id list into the order you want. Must list every current member.</p>" +
3520
+ "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/reorder\">" +
3521
+ "<label class=\"form-field\"><span>Ordered product ids</span>" +
3522
+ "<input type=\"text\" name=\"ordered_product_ids\" value=\"" + _htmlEscape(currentIds) + "\"></label>" +
3523
+ "<div class=\"actions-row\"><button class=\"btn btn--ghost\" type=\"submit\">Apply order</button></div>" +
3524
+ "</form>" +
3525
+ "</div>"
3526
+ : "";
3527
+
3528
+ var addForm =
3529
+ "<div class=\"panel mt-1 mw-40\">" +
3530
+ "<h3 class=\"subhead\">Add a member</h3>" +
3531
+ "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/add\">" +
3532
+ _setupField("Product id", "product_id", "", "text", "The catalog product's UUID.", " maxlength=\"64\" required") +
3533
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add product</button></div>" +
3534
+ "</form>" +
3535
+ "</div>";
3536
+
3537
+ detailBody = "<section class=\"mt\"><h3 class=\"fs-105\">Members</h3>" +
3538
+ memberTable + reorderForm + addForm + "</section>";
3539
+ } else {
3540
+ var preview = opts.preview || [];
3541
+ var previewCards = preview.map(function (p) {
3542
+ return "<tr>" +
3543
+ "<td><strong>" + _htmlEscape(p.title || "(untitled)") + "</strong></td>" +
3544
+ "<td><code class=\"order-id\">" + _htmlEscape(p.slug || p.id || "") + "</code></td>" +
3545
+ "<td>" + _htmlEscape(p.status || "") + "</td>" +
3546
+ "</tr>";
3547
+ }).join("");
3548
+ var previewTable = preview.length
3549
+ ? "<div class=\"panel\"><table><thead><tr><th>Title</th><th>Slug</th><th>Status</th></tr></thead><tbody>" + previewCards + "</tbody></table></div>"
3550
+ : "<p class=\"empty\">No products match these rules yet.</p>";
3551
+ detailBody = "<section class=\"mt\"><h3 class=\"fs-105\">Matched products (live preview)</h3>" +
3552
+ "<p class=\"meta\">The first " + _htmlEscape(String(preview.length)) + " products the rules match right now.</p>" +
3553
+ previewTable + "</section>";
3554
+ }
3555
+
3556
+ var head =
3557
+ "<p class=\"meta\"><a href=\"/admin/collections\">&larr; Collections</a> · " +
3558
+ "<code class=\"order-id\">" + _htmlEscape(col.slug) + "</code> · " +
3559
+ "<span class=\"status-pill " + (col.type === "smart" ? "pending" : "paid") + "\">" + _htmlEscape(col.type) + "</span>" +
3560
+ (col.archived_at != null ? " · <span class=\"status-pill cancelled\">archived</span>" : "") +
3561
+ " · <a href=\"/collections/" + _htmlEscape(enc) + "\" target=\"_blank\" rel=\"noreferrer\">View on storefront &rarr;</a></p>";
3562
+
3563
+ var body = "<section><h2>" + _htmlEscape(col.title) + "</h2>" + updated + notice + head + editForm + "</section>" + detailBody;
3564
+ return _renderAdminShell(opts.shop_name, "Collection " + col.slug, body, "collections", opts.nav_available);
3565
+ }
3566
+
2675
3567
  module.exports = {
2676
3568
  mount: mount,
2677
3569
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
@@ -2684,7 +3576,12 @@ module.exports = {
2684
3576
  renderAdminInventory: renderAdminInventory,
2685
3577
  renderAdminOrders: renderAdminOrders,
2686
3578
  renderAdminOrder: renderAdminOrder,
3579
+ renderAdminCustomers: renderAdminCustomers,
2687
3580
  renderAdminReturns: renderAdminReturns,
2688
3581
  renderAdminReturn: renderAdminReturn,
2689
3582
  renderAdminReviews: renderAdminReviews,
3583
+ renderAdminCollections: renderAdminCollections,
3584
+ renderAdminCollection: renderAdminCollection,
3585
+ renderAdminGiftCards: renderAdminGiftCards,
3586
+ renderAdminGiftCard: renderAdminGiftCard,
2690
3587
  };