@blamejs/blamejs-shop 0.1.20 → 0.1.22

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 +4 -0
  2. package/README.md +6 -2
  3. package/SECURITY.md +12 -0
  4. package/lib/admin.js +944 -1
  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,6 +31,7 @@
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
 
@@ -221,11 +222,12 @@ function mount(router, deps) {
221
222
  var catalogImport = deps.catalogImport || null; // bulk-import route disabled when absent
222
223
  var reviews = deps.reviews || null; // moderation endpoints disabled when absent
223
224
  var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
225
+ var customers = deps.customers || null; // read-only customers console disabled when absent
224
226
 
225
227
  // Which optional console sections are wired — gates their nav links so a
226
228
  // signed-in admin is never sent to a route that wasn't mounted. Passed
227
229
  // into every authed render call as `nav_available`.
228
- var navAvailable = { returns: !!returns, reviews: !!reviews, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks };
230
+ var navAvailable = { returns: !!returns, reviews: !!reviews, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards };
229
231
 
230
232
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
231
233
 
@@ -755,6 +757,48 @@ function mount(router, deps) {
755
757
  },
756
758
  ));
757
759
 
760
+ // ---- customers (read-only) ------------------------------------------
761
+
762
+ // Operator-facing customer roster, newest first. READ-ONLY — no create /
763
+ // edit / delete (the storefront owns account mutation via passkey / OIDC
764
+ // ceremonies). The raw email is never stored, so the table shows the
765
+ // display name, a short id, the join date, the sign-in method (passkey
766
+ // count + linked OAuth providers), and the order count. Order counts +
767
+ // sign-in methods are resolved with bounded aggregate queries over the
768
+ // page's ids — no per-row N+1. Endpoint omitted when no customers
769
+ // primitive is wired.
770
+ if (customers) {
771
+ router.get("/admin/customers", _pageOrApi(true,
772
+ R(async function (req, res) {
773
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
774
+ var cursor = url && url.searchParams.get("cursor");
775
+ var limitS = url && url.searchParams.get("limit");
776
+ var limit = limitS == null ? 50 : parseInt(limitS, 10);
777
+ var page = await customers.list({ cursor: cursor || undefined, limit: limit });
778
+ _json(res, 200, page);
779
+ }),
780
+ async function (req, res) {
781
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
782
+ var cursor = url && url.searchParams.get("cursor");
783
+ var page = await customers.list({ cursor: cursor || undefined, limit: 100 });
784
+ var rows = page.rows || [];
785
+ var ids = rows.map(function (c) { return c.id; });
786
+ // One grouped query for order counts, one IN-bounded pair for
787
+ // sign-in methods — never a trip per row.
788
+ var counts = await order.countsByCustomer(ids);
789
+ var methods = await customers.signInMethodsByCustomer(ids);
790
+ _sendHtml(res, 200, renderAdminCustomers({
791
+ shop_name: deps.shop_name, nav_available: navAvailable,
792
+ customers: rows,
793
+ order_counts: counts,
794
+ passkey_counts: methods.passkeys,
795
+ oauth_providers: methods.oauth,
796
+ next_cursor: page.next_cursor || null,
797
+ }));
798
+ },
799
+ ));
800
+ }
801
+
758
802
  // ---- refunds --------------------------------------------------------
759
803
 
760
804
  if (payment) {
@@ -1350,6 +1394,393 @@ function mount(router, deps) {
1350
1394
  ));
1351
1395
  }
1352
1396
 
1397
+ // ---- collections ----------------------------------------------------
1398
+
1399
+ // Operator-side console for manual + smart product collections (the
1400
+ // customer-facing /collections pages already ship). Manual collections
1401
+ // get a member manager (add by product id, remove, reorder); smart
1402
+ // collections get a rule editor (field/op/value rows) plus a live
1403
+ // preview of the products the rules currently match. Endpoints are
1404
+ // omitted entirely when no collections primitive is wired.
1405
+ var collections = deps.collections || null;
1406
+ if (collections) {
1407
+ // Form rule rows arrive as parallel arrays (rule_field[], rule_op[],
1408
+ // rule_value[]) — or scalars when the operator submits a single row.
1409
+ // Normalise to arrays, zip into a { all: [...] } rule set, and coerce
1410
+ // numeric / array op values so the primitive's validator sees the
1411
+ // shape it expects. Bad shapes throw TypeError, surfaced as a 400
1412
+ // re-render the same way every other create form does.
1413
+ function _asArray(v) {
1414
+ if (v == null) return [];
1415
+ return Array.isArray(v) ? v : [v];
1416
+ }
1417
+ function _rulesFromForm(body) {
1418
+ var fields = _asArray(body.rule_field);
1419
+ var ops = _asArray(body.rule_op);
1420
+ var values = _asArray(body.rule_value);
1421
+ var all = [];
1422
+ for (var i = 0; i < fields.length; i += 1) {
1423
+ var field = fields[i];
1424
+ var op = ops[i];
1425
+ var raw = values[i];
1426
+ // Skip wholly blank rows (the editor renders a spare empty row).
1427
+ if ((field == null || field === "") && (raw == null || raw === "")) continue;
1428
+ var value = _coerceRuleValue(field, op, raw);
1429
+ all.push({ field: field, op: op, value: value });
1430
+ }
1431
+ if (all.length === 0) {
1432
+ throw new TypeError("rules: add at least one rule (field, op, value)");
1433
+ }
1434
+ return { all: all };
1435
+ }
1436
+ function _coerceRuleValue(field, op, raw) {
1437
+ var s = raw == null ? "" : String(raw);
1438
+ // `in` / `not_in` take a comma-separated list; numeric fields parse
1439
+ // each entry as an integer, others stay strings.
1440
+ if (op === "in" || op === "not_in") {
1441
+ return s.split(",").map(function (part) {
1442
+ var t = part.trim();
1443
+ return _isNumericField(field) ? _strictInt(t, "rule value") : t;
1444
+ });
1445
+ }
1446
+ // `between` takes "lo,hi" — both strict integers.
1447
+ if (op === "between") {
1448
+ var parts = s.split(",");
1449
+ if (parts.length !== 2) throw new TypeError("rules: 'between' value must be \"lo,hi\"");
1450
+ return [_strictInt(parts[0].trim(), "rule lo"), _strictInt(parts[1].trim(), "rule hi")];
1451
+ }
1452
+ // Numeric comparison ops + numeric-field eq/neq parse as integers.
1453
+ var numericOp = op === "gt" || op === "gte" || op === "lt" || op === "lte";
1454
+ if (numericOp || (_isNumericField(field) && (op === "eq" || op === "neq"))) {
1455
+ return _strictInt(s, "rule value");
1456
+ }
1457
+ return s;
1458
+ }
1459
+ function _isNumericField(field) {
1460
+ return field === "price_minor" || field === "inventory_count" || field === "created_at";
1461
+ }
1462
+ // Strict integer parse — refuses "12abc" / "" / floats, unlike
1463
+ // parseInt's loose prefix match. Money / count fields must be exact.
1464
+ function _strictInt(s, label) {
1465
+ if (typeof s !== "string" || !/^-?\d+$/.test(s.trim())) {
1466
+ throw new TypeError("collections: " + label + " must be an integer");
1467
+ }
1468
+ var n = Number(s.trim());
1469
+ if (!Number.isSafeInteger(n)) throw new TypeError("collections: " + label + " out of range");
1470
+ return n;
1471
+ }
1472
+
1473
+ function _cleanCreateMessage(e) {
1474
+ return (e && e.message || "Couldn't create that collection.").replace(/^collections[.:]\s*/, "");
1475
+ }
1476
+
1477
+ // Map the ?active= query to a collections.list filter: 1/true →
1478
+ // active-only, 0/false → archived-only, absent → all.
1479
+ function _collectionsFilter(activeS) {
1480
+ if (activeS === "1" || activeS === "true") return { active_only: true };
1481
+ if (activeS === "0" || activeS === "false") return { archived_only: true };
1482
+ return {};
1483
+ }
1484
+
1485
+ async function _listForBrowser(filter) {
1486
+ var rows = await collections.list(filter || {});
1487
+ // Annotate each row with its current size: manual → member count,
1488
+ // smart → matched-preview count. Both compose productsIn; the loop
1489
+ // is bounded by the collection count (operators have tens, not
1490
+ // thousands), so this is not an N+1 over an unbounded set.
1491
+ for (var i = 0; i < rows.length; i += 1) {
1492
+ var count = null;
1493
+ try {
1494
+ var p = await collections.productsIn({ slug: rows[i].slug, limit: 200 });
1495
+ count = (p.rows || []).length;
1496
+ } catch (_e) { count = null; }
1497
+ rows[i]._count = count;
1498
+ }
1499
+ return rows;
1500
+ }
1501
+
1502
+ // List content-negotiates: bearer → JSON (the raw list, unchanged for
1503
+ // tooling); signed-in browser → the console table with an
1504
+ // all/active/archived filter.
1505
+ router.get("/admin/collections", _pageOrApi(true,
1506
+ R(async function (req, res) {
1507
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1508
+ var activeS = url && url.searchParams.get("active");
1509
+ var rows = await collections.list(_collectionsFilter(activeS));
1510
+ _json(res, 200, { rows: rows });
1511
+ }),
1512
+ async function (req, res) {
1513
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1514
+ var activeS = url && url.searchParams.get("active");
1515
+ var rows = await _listForBrowser(_collectionsFilter(activeS));
1516
+ _sendHtml(res, 200, renderAdminCollections({
1517
+ shop_name: deps.shop_name, nav_available: navAvailable, collections: rows,
1518
+ active_filter: activeS,
1519
+ created: url && url.searchParams.get("created"),
1520
+ archived: url && url.searchParams.get("archived"),
1521
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the collection." : null,
1522
+ }));
1523
+ },
1524
+ ));
1525
+
1526
+ // Create content-negotiates: bearer → JSON 201 (manual or smart per
1527
+ // body.type); browser form → create, then PRG. A bad shape (TypeError)
1528
+ // re-renders the list with the validator's message rather than 500.
1529
+ router.post("/admin/collections", _pageOrApi(false,
1530
+ W("collection.create", async function (req, res) {
1531
+ var body = req.body || {};
1532
+ var col;
1533
+ if (body.type === "smart") col = await collections.defineSmart(body);
1534
+ else col = await collections.defineManual(body);
1535
+ _json(res, 201, col);
1536
+ return { id: col.slug };
1537
+ }),
1538
+ async function (req, res) {
1539
+ var body = req.body || {};
1540
+ var type = body.type === "smart" ? "smart" : "manual";
1541
+ try {
1542
+ if (type === "smart") {
1543
+ await collections.defineSmart({
1544
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
1545
+ title: body.title,
1546
+ description: body.description,
1547
+ rules: _rulesFromForm(body),
1548
+ sort_strategy: body.sort_strategy || "newest",
1549
+ });
1550
+ } else {
1551
+ await collections.defineManual({
1552
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
1553
+ title: body.title,
1554
+ description: body.description,
1555
+ sort_strategy: body.sort_strategy || "manual",
1556
+ });
1557
+ }
1558
+ } catch (e) {
1559
+ if (!(e instanceof TypeError)) throw e;
1560
+ var rows = await _listForBrowser({});
1561
+ return _sendHtml(res, 400, renderAdminCollections({
1562
+ shop_name: deps.shop_name, nav_available: navAvailable, collections: rows,
1563
+ notice: _cleanCreateMessage(e), form_type: type,
1564
+ }));
1565
+ }
1566
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.create", outcome: "success" });
1567
+ _redirect(res, "/admin/collections?created=1");
1568
+ },
1569
+ ));
1570
+
1571
+ async function _detailModel(slug) {
1572
+ var col = await collections.get(slug);
1573
+ if (!col) return null;
1574
+ var preview = null, members = null;
1575
+ if (col.type === "manual") {
1576
+ var pm = await collections.productsIn({ slug: slug, limit: 200 });
1577
+ members = pm.rows || [];
1578
+ } else {
1579
+ var ps = await collections.productsIn({ slug: slug, limit: 50 });
1580
+ preview = ps.rows || [];
1581
+ }
1582
+ return { collection: col, members: members, preview: preview };
1583
+ }
1584
+
1585
+ // Detail content-negotiates: bearer → JSON (collection + members /
1586
+ // preview); browser → the member manager (manual) or rule editor +
1587
+ // preview (smart). A bad / unknown slug is a 404 page, never a 500.
1588
+ router.get("/admin/collections/:slug", _pageOrApi(true,
1589
+ R(async function (req, res) {
1590
+ var model;
1591
+ try { model = await _detailModel(req.params.slug); }
1592
+ catch (e) { if (e instanceof TypeError) return _problem(res, 404, "collection-not-found", e.message); throw e; }
1593
+ if (!model) return _problem(res, 404, "collection-not-found");
1594
+ _json(res, 200, model);
1595
+ }),
1596
+ async function (req, res) {
1597
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1598
+ var model;
1599
+ try { model = await _detailModel(req.params.slug); }
1600
+ catch (e) { if (!(e instanceof TypeError)) throw e; model = null; }
1601
+ if (!model) return _sendHtml(res, 404, renderAdminCollections({
1602
+ shop_name: deps.shop_name, nav_available: navAvailable, collections: [], notice: "Collection not found.",
1603
+ }));
1604
+ _sendHtml(res, 200, renderAdminCollection({
1605
+ shop_name: deps.shop_name, nav_available: navAvailable,
1606
+ collection: model.collection, members: model.members, preview: model.preview,
1607
+ rule_fields: collectionsModule.RULE_FIELDS, rule_ops: collectionsModule.RULE_OPS,
1608
+ sort_strategies: collectionsModule.SORT_STRATEGIES,
1609
+ saved: url && url.searchParams.get("saved"),
1610
+ updated: url && url.searchParams.get("updated"),
1611
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
1612
+ }));
1613
+ },
1614
+ ));
1615
+
1616
+ // Edit content-negotiates: bearer PATCH (the JSON contract) + browser
1617
+ // POST /edit (HTML forms can't PATCH). Both update title / description
1618
+ // / sort_strategy, and rules for smart. A bad shape is a 400 / notice.
1619
+ router.patch("/admin/collections/:slug", W("collection.update", async function (req, res) {
1620
+ var col;
1621
+ try { col = await collections.update(req.params.slug, req.body || {}); }
1622
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1623
+ if (!col) return _problem(res, 404, "collection-not-found");
1624
+ _json(res, 200, col);
1625
+ return { id: col.slug };
1626
+ }));
1627
+
1628
+ router.post("/admin/collections/:slug/edit", _pageOrApi(false,
1629
+ W("collection.update", async function (req, res) {
1630
+ var col;
1631
+ try { col = await collections.update(req.params.slug, req.body || {}); }
1632
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1633
+ if (!col) return _problem(res, 404, "collection-not-found");
1634
+ _json(res, 200, col);
1635
+ return { id: col.slug };
1636
+ }),
1637
+ async function (req, res) {
1638
+ var slug = req.params.slug;
1639
+ var body = req.body || {};
1640
+ var enc = encodeURIComponent(slug);
1641
+ try {
1642
+ var existing = await collections.get(slug);
1643
+ if (!existing) return _redirect(res, "/admin/collections/" + enc + "?err=1");
1644
+ var patch = {};
1645
+ if (body.title !== undefined) patch.title = body.title;
1646
+ if (body.description !== undefined) patch.description = body.description;
1647
+ if (body.sort_strategy) patch.sort_strategy = body.sort_strategy;
1648
+ if (existing.type === "smart" && (body.rule_field !== undefined || body.rule_op !== undefined)) {
1649
+ patch.rules = _rulesFromForm(body);
1650
+ }
1651
+ if (Object.keys(patch).length === 0) return _redirect(res, "/admin/collections/" + enc + "?err=1");
1652
+ await collections.update(slug, patch);
1653
+ } catch (e) {
1654
+ if (!(e instanceof TypeError)) throw e;
1655
+ return _redirect(res, "/admin/collections/" + enc + "?err=1");
1656
+ }
1657
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.update", outcome: "success", metadata: { slug: slug } });
1658
+ _redirect(res, "/admin/collections/" + enc + "?updated=1");
1659
+ },
1660
+ ));
1661
+
1662
+ // Archive content-negotiates: bearer DELETE (soft archive) + browser
1663
+ // POST /archive. An unknown / already-archived slug is a no-op notice
1664
+ // (?err=1), never a false success.
1665
+ router.delete("/admin/collections/:slug", W("collection.archive", async function (req, res) {
1666
+ var col;
1667
+ try { col = await collections.archive(req.params.slug); }
1668
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1669
+ if (!col) return _problem(res, 404, "collection-not-found");
1670
+ _json(res, 200, col);
1671
+ return { id: col.slug };
1672
+ }));
1673
+
1674
+ router.post("/admin/collections/:slug/archive", _pageOrApi(false,
1675
+ W("collection.archive", async function (req, res) {
1676
+ var col;
1677
+ try { col = await collections.archive(req.params.slug); }
1678
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1679
+ if (!col) return _problem(res, 404, "collection-not-found");
1680
+ _json(res, 200, col);
1681
+ return { id: col.slug };
1682
+ }),
1683
+ async function (req, res) {
1684
+ var slug = req.params.slug;
1685
+ var col = null;
1686
+ try { col = await collections.archive(slug); }
1687
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
1688
+ // archive() returns the row whether or not it flipped (already-
1689
+ // archived re-returns the row); treat a missing row as the only
1690
+ // err. An already-archived re-archive is idempotent, not an error.
1691
+ if (!col) return _redirect(res, "/admin/collections?err=1");
1692
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.archive", outcome: "success", metadata: { slug: slug } });
1693
+ _redirect(res, "/admin/collections?archived=1");
1694
+ },
1695
+ ));
1696
+
1697
+ // Manual member ops — browser POST routes (the JSON API composes
1698
+ // addProduct / removeProduct / reorderProducts directly). Each PRGs
1699
+ // back to the detail; a bad shape / unknown product is a ?err=1 notice.
1700
+ function _memberRedirect(res, slug, ok) {
1701
+ var enc = encodeURIComponent(slug);
1702
+ return _redirect(res, "/admin/collections/" + enc + (ok ? "?updated=1" : "?err=1"));
1703
+ }
1704
+
1705
+ router.post("/admin/collections/:slug/members/add", _pageOrApi(false,
1706
+ W("collection.member_add", async function (req, res) {
1707
+ var body = req.body || {};
1708
+ var m;
1709
+ try { m = await collections.addProduct({ collection_slug: req.params.slug, product_id: body.product_id }); }
1710
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1711
+ _json(res, 201, m);
1712
+ return { id: req.params.slug };
1713
+ }),
1714
+ async function (req, res) {
1715
+ var slug = req.params.slug;
1716
+ var body = req.body || {};
1717
+ try {
1718
+ await collections.addProduct({ collection_slug: slug, product_id: (body.product_id || "").trim() });
1719
+ } catch (e) {
1720
+ if (!(e instanceof TypeError)) throw e;
1721
+ return _memberRedirect(res, slug, false);
1722
+ }
1723
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.member_add", outcome: "success", metadata: { slug: slug } });
1724
+ _memberRedirect(res, slug, true);
1725
+ },
1726
+ ));
1727
+
1728
+ router.post("/admin/collections/:slug/members/remove", _pageOrApi(false,
1729
+ W("collection.member_remove", async function (req, res) {
1730
+ var body = req.body || {};
1731
+ var ok;
1732
+ try { ok = await collections.removeProduct({ collection_slug: req.params.slug, product_id: body.product_id }); }
1733
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1734
+ if (!ok) return _problem(res, 404, "collection-member-not-found");
1735
+ _json(res, 200, { ok: true });
1736
+ return { id: req.params.slug };
1737
+ }),
1738
+ async function (req, res) {
1739
+ var slug = req.params.slug;
1740
+ var body = req.body || {};
1741
+ var ok = false;
1742
+ try {
1743
+ ok = await collections.removeProduct({ collection_slug: slug, product_id: (body.product_id || "").trim() });
1744
+ } catch (e) { if (!(e instanceof TypeError)) throw e; }
1745
+ if (!ok) return _memberRedirect(res, slug, false);
1746
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.member_remove", outcome: "success", metadata: { slug: slug } });
1747
+ _memberRedirect(res, slug, true);
1748
+ },
1749
+ ));
1750
+
1751
+ router.post("/admin/collections/:slug/members/reorder", _pageOrApi(false,
1752
+ W("collection.reorder", async function (req, res) {
1753
+ var body = req.body || {};
1754
+ var ids = Array.isArray(body.ordered_product_ids) ? body.ordered_product_ids : null;
1755
+ if (!ids) return _problem(res, 400, "bad-request", "ordered_product_ids array required");
1756
+ try { await collections.reorderProducts({ collection_slug: req.params.slug, ordered_product_ids: ids }); }
1757
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1758
+ _json(res, 200, { ok: true });
1759
+ return { id: req.params.slug };
1760
+ }),
1761
+ async function (req, res) {
1762
+ var slug = req.params.slug;
1763
+ var body = req.body || {};
1764
+ // The reorder form posts the full ordered id list — a single hidden
1765
+ // field of comma-joined ids, or repeated ordered_product_id rows.
1766
+ var ids;
1767
+ if (body.ordered_product_ids != null && typeof body.ordered_product_ids === "string") {
1768
+ ids = body.ordered_product_ids.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
1769
+ } else {
1770
+ ids = _asArray(body.ordered_product_id).map(function (s) { return String(s).trim(); }).filter(Boolean);
1771
+ }
1772
+ try {
1773
+ await collections.reorderProducts({ collection_slug: slug, ordered_product_ids: ids });
1774
+ } catch (e) {
1775
+ if (!(e instanceof TypeError)) throw e;
1776
+ return _memberRedirect(res, slug, false);
1777
+ }
1778
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".collection.reorder", outcome: "success", metadata: { slug: slug } });
1779
+ _memberRedirect(res, slug, true);
1780
+ },
1781
+ ));
1782
+ }
1783
+
1353
1784
  // ---- analytics ------------------------------------------------------
1354
1785
 
1355
1786
  var analytics = deps.analytics || null;
@@ -1548,6 +1979,132 @@ function mount(router, deps) {
1548
1979
  }
1549
1980
  }
1550
1981
 
1982
+ // ---- gift cards -----------------------------------------------------
1983
+ //
1984
+ // The ledger console: list issued cards (masked by code_hint + the
1985
+ // original/remaining balance + status), drill into one card's ledger
1986
+ // transactions (issue / redeem / adjust), and — when the ledger is
1987
+ // wired — issue a new card from the console. The bearer JSON contract
1988
+ // is unchanged; the signed-in browser session gets the rendered page.
1989
+ // `giftcards` owns the card rows; `giftCardLedger` (optional) owns the
1990
+ // append-only transaction history shown on the detail view.
1991
+ var giftcards = deps.giftcards || null;
1992
+ var giftCardLedger = deps.giftCardLedger || null;
1993
+ if (giftcards) {
1994
+ // Issue a card. Bearer → JSON (the issued plaintext code is
1995
+ // returned ONCE for the operator to deliver); browser form →
1996
+ // issue, then PRG to the detail page so the code shows once.
1997
+ router.post("/admin/gift-cards", _pageOrApi(false,
1998
+ W("gift_card.issue", async function (req, res) {
1999
+ var issued = await _issueGiftCard(req.body || {});
2000
+ _json(res, 201, issued);
2001
+ return { id: issued.id };
2002
+ }),
2003
+ async function (req, res) {
2004
+ var issued;
2005
+ try {
2006
+ issued = await _issueGiftCard(req.body || {});
2007
+ } catch (e) {
2008
+ if (!(e instanceof TypeError)) throw e;
2009
+ var rows = await giftcards.list({});
2010
+ return _sendHtml(res, 400, renderAdminGiftCards({
2011
+ shop_name: deps.shop_name, nav_available: navAvailable, cards: rows,
2012
+ notice: e.message.replace(/^giftcards?[.:]\s*/i, ""),
2013
+ }));
2014
+ }
2015
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".gift_card.issue", outcome: "success", metadata: { id: issued.id } });
2016
+ _redirect(res, "/admin/gift-cards/" + issued.id + "?issued=" + encodeURIComponent(issued.code));
2017
+ },
2018
+ ));
2019
+
2020
+ router.get("/admin/gift-cards", _pageOrApi(true,
2021
+ R(async function (req, res) {
2022
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
2023
+ var status = url && url.searchParams.get("status");
2024
+ var rows = await giftcards.list(status ? { status: status } : {});
2025
+ _json(res, 200, { rows: rows });
2026
+ }),
2027
+ async function (req, res) {
2028
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
2029
+ var status = url && url.searchParams.get("status");
2030
+ var rows = await giftcards.list(status ? { status: status } : {});
2031
+ _sendHtml(res, 200, renderAdminGiftCards({
2032
+ shop_name: deps.shop_name, nav_available: navAvailable, cards: rows,
2033
+ status_filter: status,
2034
+ ledger_enabled: !!giftCardLedger,
2035
+ }));
2036
+ },
2037
+ ));
2038
+
2039
+ router.get("/admin/gift-cards/:id", _pageOrApi(true,
2040
+ R(async function (req, res) {
2041
+ var card = await giftcards.getById(req.params.id);
2042
+ if (!card) return _problem(res, 404, "gift-card-not-found");
2043
+ var history = [];
2044
+ if (giftCardLedger) {
2045
+ try { history = (await giftCardLedger.history(req.params.id, { limit: 500 })).rows; }
2046
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
2047
+ }
2048
+ _json(res, 200, { card: card, ledger: history });
2049
+ }),
2050
+ async function (req, res) {
2051
+ var card = await giftcards.getById(req.params.id);
2052
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
2053
+ var issuedCode = url && url.searchParams.get("issued");
2054
+ if (!card) {
2055
+ return _sendHtml(res, 404, renderAdminGiftCard({
2056
+ shop_name: deps.shop_name, nav_available: navAvailable, card: null,
2057
+ }));
2058
+ }
2059
+ var history = [];
2060
+ if (giftCardLedger) {
2061
+ try { history = (await giftCardLedger.history(req.params.id, { limit: 500 })).rows; }
2062
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
2063
+ }
2064
+ _sendHtml(res, 200, renderAdminGiftCard({
2065
+ shop_name: deps.shop_name, nav_available: navAvailable, card: card,
2066
+ ledger: history, issued_code: issuedCode,
2067
+ }));
2068
+ },
2069
+ ));
2070
+ }
2071
+
2072
+ // Issue a card from operator input, recording the opening balance as
2073
+ // a `manual` credit in the ledger (when wired) so the transaction
2074
+ // history starts from the issuance event. Strict integer parsing of
2075
+ // the amount; the giftcards primitive enforces the rest.
2076
+ async function _issueGiftCard(body) {
2077
+ var input = { currency: typeof body.currency === "string" ? body.currency.trim().toUpperCase() : body.currency };
2078
+ if (body.amount_minor != null && body.amount_minor !== "") {
2079
+ var n = typeof body.amount_minor === "number" ? body.amount_minor : parseInt(String(body.amount_minor), 10);
2080
+ if (!Number.isInteger(n)) throw new TypeError("giftcards: amount_minor must be an integer (minor units)");
2081
+ input.amount_minor = n;
2082
+ }
2083
+ if (body.issued_to_email) input.issued_to_email = String(body.issued_to_email).trim();
2084
+ if (body.expires_at != null && body.expires_at !== "") {
2085
+ var ex = typeof body.expires_at === "number" ? body.expires_at : parseInt(String(body.expires_at), 10);
2086
+ if (!Number.isInteger(ex)) throw new TypeError("giftcards: expires_at must be an integer epoch-ms");
2087
+ input.expires_at = ex;
2088
+ }
2089
+ var issued = await giftcards.issue(input);
2090
+ if (giftCardLedger) {
2091
+ // Opening credit so the ledger history starts from issuance.
2092
+ // Best-effort: a ledger write failure must not lose the issued
2093
+ // card (the card row + plaintext code already exist) — the
2094
+ // operator can reconcile, and the card balance is authoritative
2095
+ // on the card row. Swallow only the ledger fault.
2096
+ try {
2097
+ await giftCardLedger.credit({
2098
+ gift_card_id: issued.id,
2099
+ amount_minor: input.amount_minor,
2100
+ source: "manual",
2101
+ source_ref: "admin-issue",
2102
+ });
2103
+ } catch (_e) { /* drop-silent — card issued; ledger seed is advisory */ }
2104
+ }
2105
+ return issued;
2106
+ }
2107
+
1551
2108
  // ---- admin web pages (browser session + setup wizard) ---------------
1552
2109
  //
1553
2110
  // The operator signs in by pasting the ADMIN_API_KEY once; that sets a
@@ -1943,9 +2500,12 @@ var ADMIN_NAV_ITEMS = [
1943
2500
  { key: "products", href: "/admin/products", label: "Products" },
1944
2501
  { key: "inventory", href: "/admin/inventory", label: "Inventory" },
1945
2502
  { key: "orders", href: "/admin/orders", label: "Orders" },
2503
+ { key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
1946
2504
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
1947
2505
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
1948
2506
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
2507
+ { key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
2508
+ { key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
1949
2509
  { key: "webhooks", href: "/admin/webhooks", label: "Webhooks", requires: "webhooks" },
1950
2510
  { key: "integrations", href: "/admin/integrations", label: "Integrations" },
1951
2511
  { key: "setup", href: "/admin/setup", label: "Setup" },
@@ -2254,6 +2814,60 @@ function renderAdminOrder(opts) {
2254
2814
  return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders", opts.nav_available);
2255
2815
  }
2256
2816
 
2817
+ // Read-only customer roster. The raw email is never stored (lookups are
2818
+ // keyed on a SHA3-512 namespace hash), so the table surfaces the display
2819
+ // name, a short id, the join date, the sign-in method (passkey count +
2820
+ // linked OAuth providers), and the order count. Order counts + sign-in
2821
+ // methods arrive as { customer_id: … } maps resolved by the route's
2822
+ // bounded aggregate queries — the renderer just looks them up, no work
2823
+ // per row beyond the lookup. No row actions: the storefront owns account
2824
+ // mutation via the passkey / OIDC ceremonies.
2825
+ function renderAdminCustomers(opts) {
2826
+ opts = opts || {};
2827
+ var customers = opts.customers || [];
2828
+ var orderCounts = opts.order_counts || {};
2829
+ var passkeyCounts = opts.passkey_counts || {};
2830
+ var oauthProviders = opts.oauth_providers || {};
2831
+
2832
+ var rows = customers.map(function (c) {
2833
+ var passkeys = passkeyCounts[c.id] || 0;
2834
+ var providers = oauthProviders[c.id] || [];
2835
+ // Sign-in method chips: one per passkey-count + one per linked OAuth
2836
+ // provider. A customer mid-registration (no credential yet) shows none.
2837
+ var methodCells = [];
2838
+ if (passkeys > 0) {
2839
+ methodCells.push("<span class=\"chip\">" + _htmlEscape(passkeys === 1 ? "1 passkey" : passkeys + " passkeys") + "</span>");
2840
+ }
2841
+ for (var i = 0; i < providers.length; i += 1) {
2842
+ methodCells.push("<span class=\"chip\">" + _htmlEscape(providers[i]) + "</span>");
2843
+ }
2844
+ var method = methodCells.length ? methodCells.join(" ") : "<span class=\"meta\">—</span>";
2845
+ var orders = orderCounts[c.id] || 0;
2846
+ return "<tr>" +
2847
+ "<td><strong>" + _htmlEscape(c.display_name) + "</strong></td>" +
2848
+ "<td><code class=\"order-id\">" + _htmlEscape(String(c.id).slice(0, 8)) + "</code></td>" +
2849
+ "<td>" + _htmlEscape(_fmtDate(c.created_at)) + "</td>" +
2850
+ "<td>" + method + "</td>" +
2851
+ "<td class=\"num\">" + _htmlEscape(String(orders)) + "</td>" +
2852
+ "</tr>";
2853
+ }).join("");
2854
+
2855
+ var table = customers.length
2856
+ ? "<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>"
2857
+ : "<p class=\"empty\">No customers yet.</p>";
2858
+
2859
+ // Cursor pager — a Next link when the page filled and more rows remain.
2860
+ // The opaque cursor is HMAC-tagged by customers.list; encode it for the URL.
2861
+ var pager = opts.next_cursor
2862
+ ? "<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>"
2863
+ : "";
2864
+
2865
+ var body = "<section><h2>Customers</h2>" +
2866
+ "<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>" +
2867
+ table + pager + "</section>";
2868
+ return _renderAdminShell(opts.shop_name, "Customers", body, "customers", opts.nav_available);
2869
+ }
2870
+
2257
2871
  // The RMA states an operator can filter the returns queue by — drives the
2258
2872
  // filter chips, lifecycle order then terminal.
2259
2873
  var RETURN_STATUS_FILTERS = ["pending", "approved", "received", "refunded", "rejected"];
@@ -2560,6 +3174,121 @@ function renderAdminSubscriptionPlans(opts) {
2560
3174
  return _renderAdminShell(opts.shop_name, "Subscription plans", bodyHtml, "subscriptions", opts.nav_available);
2561
3175
  }
2562
3176
 
3177
+ // Gift-card ledger console — list issued cards. The code is masked to
3178
+ // its `code_hint` (last 4 plaintext chars) prefixed with the standard
3179
+ // •••• fill; the plaintext bearer code is never stored and so never
3180
+ // rendered here. Original (issued) + remaining balance + lifecycle
3181
+ // status + issued date per row, newest first.
3182
+ function renderAdminGiftCards(opts) {
3183
+ opts = opts || {};
3184
+ var rows = opts.cards || [];
3185
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
3186
+
3187
+ var sf = opts.status_filter;
3188
+ var STATUS = ["active", "redeemed", "expired", "voided"];
3189
+ var chipHtml = "<a class=\"chip" + (sf == null ? " chip--on" : "") + "\" href=\"/admin/gift-cards\">All</a>";
3190
+ for (var s = 0; s < STATUS.length; s += 1) {
3191
+ chipHtml += "<a class=\"chip" + (sf === STATUS[s] ? " chip--on" : "") +
3192
+ "\" href=\"/admin/gift-cards?status=" + STATUS[s] + "\">" + STATUS[s] + "</a>";
3193
+ }
3194
+ var chips = "<div class=\"order-filters\">" + chipHtml + "</div>";
3195
+
3196
+ // Card lifecycle → status-pill class (reuse the order pills): active
3197
+ // = paid (green), redeemed = neutral, expired/voided = cancelled.
3198
+ function _pill(status) {
3199
+ var cls = status === "active" ? "paid" : (status === "redeemed" ? "pending" : "cancelled");
3200
+ return "<span class=\"status-pill " + cls + "\">" + _htmlEscape(status) + "</span>";
3201
+ }
3202
+
3203
+ var bodyRows = rows.map(function (gc) {
3204
+ var cur = String(gc.currency || "").toUpperCase();
3205
+ var issued = pricing.format(gc.issued_minor, cur);
3206
+ var remaining = pricing.format(gc.balance_minor, cur);
3207
+ return "<tr>" +
3208
+ "<td><code class=\"order-id\">••••" + _htmlEscape(gc.code_hint) + "</code></td>" +
3209
+ "<td class=\"num\">" + _htmlEscape(issued) + "</td>" +
3210
+ "<td class=\"num\"><strong>" + _htmlEscape(remaining) + "</strong></td>" +
3211
+ "<td>" + _pill(gc.status) + "</td>" +
3212
+ "<td>" + _htmlEscape(_fmtDate(gc.created_at)) + "</td>" +
3213
+ "<td><a class=\"btn btn--ghost\" href=\"/admin/gift-cards/" + _htmlEscape(gc.id) + "\">Ledger</a></td>" +
3214
+ "</tr>";
3215
+ }).join("");
3216
+
3217
+ var table = rows.length
3218
+ ? "<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>"
3219
+ : "<p class=\"empty\">No gift cards" + (sf ? " " + _htmlEscape(sf) : " yet") + ".</p>";
3220
+
3221
+ // The issue form composes the giftcards primitive's issue() — the
3222
+ // plaintext code is shown once on the card's detail page after a
3223
+ // successful issue, never again.
3224
+ var issueForm =
3225
+ "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:34rem;\">" +
3226
+ "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Issue a card</h3>" +
3227
+ "<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>" +
3228
+ "<form method=\"post\" action=\"/admin/gift-cards\">" +
3229
+ _setupField("Amount (minor units)", "amount_minor", "", "number", "In the currency's smallest unit — e.g. 5000 = $50.00.", " min=\"1\" required") +
3230
+ _setupField("Currency", "currency", "", "text", "3-letter ISO 4217 (e.g. USD).", " maxlength=\"3\" required") +
3231
+ _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\"") +
3232
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Issue card</button></div>" +
3233
+ "</form>" +
3234
+ "</div>";
3235
+
3236
+ var body = "<section><h2>Gift cards</h2>" + notice + chips + table + issueForm + "</section>";
3237
+ return _renderAdminShell(opts.shop_name, "Gift cards", body, "giftcards", opts.nav_available);
3238
+ }
3239
+
3240
+ // Single-card detail: the masked code, the balances + status, the
3241
+ // (optionally shown-once) freshly-issued plaintext code, and the
3242
+ // append-only ledger of transactions against the card.
3243
+ function renderAdminGiftCard(opts) {
3244
+ opts = opts || {};
3245
+ var gc = opts.card;
3246
+ if (!gc) {
3247
+ var nf = "<section><h2>Gift card</h2><p class=\"empty\">Gift card not found.</p>" +
3248
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/gift-cards\">Back to gift cards</a></div></section>";
3249
+ return _renderAdminShell(opts.shop_name, "Gift card", nf, "giftcards", opts.nav_available);
3250
+ }
3251
+ var cur = String(gc.currency || "").toUpperCase();
3252
+ var issuedBanner = opts.issued_code
3253
+ ? "<div class=\"banner banner--ok\">Copy the code now — it is shown once and cannot be retrieved again: " +
3254
+ "<code class=\"order-id\">" + _htmlEscape(opts.issued_code) + "</code></div>"
3255
+ : "";
3256
+
3257
+ var statusCls = gc.status === "active" ? "paid" : (gc.status === "redeemed" ? "pending" : "cancelled");
3258
+ var summary =
3259
+ "<div class=\"panel\"><dl class=\"detail-grid\">" +
3260
+ "<div><dt>Code</dt><dd><code class=\"order-id\">••••" + _htmlEscape(gc.code_hint) + "</code></dd></div>" +
3261
+ "<div><dt>Issued</dt><dd>" + _htmlEscape(pricing.format(gc.issued_minor, cur)) + "</dd></div>" +
3262
+ "<div><dt>Remaining</dt><dd><strong>" + _htmlEscape(pricing.format(gc.balance_minor, cur)) + "</strong></dd></div>" +
3263
+ "<div><dt>Status</dt><dd><span class=\"status-pill " + statusCls + "\">" + _htmlEscape(gc.status) + "</span></dd></div>" +
3264
+ "<div><dt>Issued on</dt><dd>" + _htmlEscape(_fmtDate(gc.created_at)) + "</dd></div>" +
3265
+ "<div><dt>Expires</dt><dd>" + (gc.expires_at ? _htmlEscape(_fmtDate(gc.expires_at)) : "<span class=\"meta\">never</span>") + "</dd></div>" +
3266
+ "</dl></div>";
3267
+
3268
+ var ledger = opts.ledger || [];
3269
+ var ledgerRows = ledger.map(function (row) {
3270
+ var detail = row.order_id
3271
+ ? "<code class=\"order-id\">" + _htmlEscape(String(row.order_id).slice(0, 8)) + "</code>"
3272
+ : (row.source ? _htmlEscape(row.source) + (row.source_ref ? " · " + _htmlEscape(row.source_ref) : "") : "<span class=\"meta\">—</span>");
3273
+ var sign = row.kind === "credit" ? "+" : "−";
3274
+ return "<tr>" +
3275
+ "<td>" + _htmlEscape(row.kind) + "</td>" +
3276
+ "<td class=\"num\">" + sign + _htmlEscape(pricing.format(row.amount_minor, cur)) + "</td>" +
3277
+ "<td class=\"num\">" + _htmlEscape(pricing.format(row.balance_after_minor, cur)) + "</td>" +
3278
+ "<td>" + detail + "</td>" +
3279
+ "<td>" + _htmlEscape(_fmtDate(row.occurred_at)) + "</td>" +
3280
+ "</tr>";
3281
+ }).join("");
3282
+ var ledgerTable = ledger.length
3283
+ ? "<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>"
3284
+ : "<p class=\"empty\">No ledger transactions recorded for this card yet.</p>";
3285
+
3286
+ var body = "<section><h2>Gift card</h2>" + issuedBanner + summary +
3287
+ "<h3 style=\"font-size:.95rem; margin:1.5rem 0 .75rem;\">Ledger</h3>" + ledgerTable +
3288
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/gift-cards\">Back to gift cards</a></div></section>";
3289
+ return _renderAdminShell(opts.shop_name, "Gift card", body, "giftcards", opts.nav_available);
3290
+ }
3291
+
2563
3292
  function renderAdminWebhooks(opts) {
2564
3293
  opts = opts || {};
2565
3294
  var rows = opts.endpoints || [];
@@ -2672,6 +3401,215 @@ function renderAdminWebhookDeliveries(opts) {
2672
3401
  return _renderAdminShell(opts.shop_name, "Deliveries", body, "webhooks", opts.nav_available);
2673
3402
  }
2674
3403
 
3404
+ function renderAdminCollections(opts) {
3405
+ opts = opts || {};
3406
+ var rows = opts.collections || [];
3407
+ var created = opts.created ? "<div class=\"banner banner--ok\">Collection created.</div>" : "";
3408
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Collection archived.</div>" : "";
3409
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
3410
+
3411
+ var af = opts.active_filter;
3412
+ var chips = "<div class=\"order-filters\">" +
3413
+ "<a class=\"chip" + (af == null ? " chip--on" : "") + "\" href=\"/admin/collections\">All</a>" +
3414
+ "<a class=\"chip" + (af === "1" ? " chip--on" : "") + "\" href=\"/admin/collections?active=1\">Active</a>" +
3415
+ "<a class=\"chip" + (af === "0" ? " chip--on" : "") + "\" href=\"/admin/collections?active=0\">Archived</a>" +
3416
+ "</div>";
3417
+
3418
+ var bodyRows = rows.map(function (c) {
3419
+ var isArchived = c.archived_at != null;
3420
+ var typeLabel = c.type === "smart" ? "smart" : "manual";
3421
+ var countLabel = c._count == null ? "—" : String(c._count);
3422
+ var countTitle = c.type === "smart" ? "matched products" : "members";
3423
+ return "<tr>" +
3424
+ "<td><a href=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "\"><strong>" + _htmlEscape(c.title) + "</strong></a></td>" +
3425
+ "<td><code class=\"order-id\">" + _htmlEscape(c.slug) + "</code></td>" +
3426
+ "<td><span class=\"status-pill " + (c.type === "smart" ? "pending" : "paid") + "\">" + _htmlEscape(typeLabel) + "</span></td>" +
3427
+ "<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
3428
+ "<td class=\"num\" title=\"" + _htmlEscape(countTitle) + "\">" + _htmlEscape(countLabel) + "</td>" +
3429
+ "<td><div class=\"actions-row\">" +
3430
+ "<a class=\"btn btn--ghost\" href=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "\">Manage</a>" +
3431
+ (isArchived ? "" :
3432
+ "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(encodeURIComponent(c.slug)) + "/archive\" style=\"display:inline;\">" +
3433
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
3434
+ "</div></td>" +
3435
+ "</tr>";
3436
+ }).join("");
3437
+
3438
+ var table = rows.length
3439
+ ? "<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>"
3440
+ : "<p class=\"empty\">No collections" + (af === "0" ? " archived" : af === "1" ? " active" : " yet") + ".</p>";
3441
+
3442
+ // The create form toggles between a manual and a smart shape. Manual
3443
+ // needs only title + slug; smart adds one starter rule row (the detail
3444
+ // page's rule editor adds more after the collection exists).
3445
+ var startType = opts.form_type === "smart" ? "smart" : "manual";
3446
+ var fieldOpts = collectionsModule.RULE_FIELDS.map(function (f) {
3447
+ return "<option value=\"" + _htmlEscape(f) + "\">" + _htmlEscape(f) + "</option>";
3448
+ }).join("");
3449
+ var opOpts = collectionsModule.RULE_OPS.map(function (o) {
3450
+ return "<option value=\"" + _htmlEscape(o) + "\">" + _htmlEscape(o) + "</option>";
3451
+ }).join("");
3452
+
3453
+ var createForm =
3454
+ "<div class=\"panel\" style=\"margin-top:1.5rem; max-width:40rem;\">" +
3455
+ "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Create a collection</h3>" +
3456
+ "<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>" +
3457
+ "<form method=\"post\" action=\"/admin/collections\">" +
3458
+ "<label class=\"form-field\"><span>Type</span><select name=\"type\">" +
3459
+ "<option value=\"manual\"" + (startType === "manual" ? " selected" : "") + ">manual</option>" +
3460
+ "<option value=\"smart\"" + (startType === "smart" ? " selected" : "") + ">smart</option>" +
3461
+ "</select></label>" +
3462
+ _setupField("Title", "title", "", "text", "", " maxlength=\"500\" required") +
3463
+ _setupField("Slug", "slug", "", "text", "Lowercase, hyphenated.", " maxlength=\"200\" required") +
3464
+ _setupField("Description (optional)", "description", "", "text", "", " maxlength=\"2000\"") +
3465
+ "<fieldset style=\"border:1px solid var(--hair); border-radius:.5rem; padding:.75rem 1rem; margin:1rem 0;\">" +
3466
+ "<legend style=\"padding:0 .4rem; font-size:.85rem;\">Smart rule (used only for smart collections)</legend>" +
3467
+ "<div class=\"actions-row\">" +
3468
+ "<select name=\"rule_field\"><option value=\"\">field…</option>" + fieldOpts + "</select>" +
3469
+ "<select name=\"rule_op\"><option value=\"\">op…</option>" + opOpts + "</select>" +
3470
+ "<input type=\"text\" name=\"rule_value\" placeholder=\"value (lists + between: comma-separated)\" maxlength=\"500\">" +
3471
+ "</div>" +
3472
+ "<small style=\"color:var(--mute);\">Numeric fields (price_minor, inventory_count, created_at) compare as integers. Add more rules after creating.</small>" +
3473
+ "</fieldset>" +
3474
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create collection</button></div>" +
3475
+ "</form>" +
3476
+ "</div>";
3477
+
3478
+ var bodyHtml = "<section><h2>Collections</h2>" + created + archived + notice + chips + table + createForm + "</section>";
3479
+ return _renderAdminShell(opts.shop_name, "Collections", bodyHtml, "collections", opts.nav_available);
3480
+ }
3481
+
3482
+ function renderAdminCollection(opts) {
3483
+ opts = opts || {};
3484
+ var col = opts.collection;
3485
+ if (!col) {
3486
+ var nf = "<section><h2>Collection</h2><p class=\"empty\">Collection not found.</p>" +
3487
+ "<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/collections\">Back to collections</a></div></section>";
3488
+ return _renderAdminShell(opts.shop_name, "Collection", nf, "collections", opts.nav_available);
3489
+ }
3490
+ var updated = (opts.updated || opts.saved) ? "<div class=\"banner banner--ok\">Saved.</div>" : "";
3491
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
3492
+ var enc = encodeURIComponent(col.slug);
3493
+
3494
+ var sortStrategies = opts.sort_strategies || collectionsModule.SORT_STRATEGIES;
3495
+ var sortOpts = sortStrategies.filter(function (s) {
3496
+ // `manual` sort only applies to manual collections (the primitive
3497
+ // refuses it on smart) — drop it from the smart editor's options.
3498
+ return !(col.type === "smart" && s === "manual");
3499
+ }).map(function (s) {
3500
+ return "<option value=\"" + _htmlEscape(s) + "\"" + (s === col.sort_strategy ? " selected" : "") + ">" + _htmlEscape(s) + "</option>";
3501
+ }).join("");
3502
+
3503
+ var editForm =
3504
+ "<div class=\"panel\" style=\"max-width:40rem;\">" +
3505
+ "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Details</h3>" +
3506
+ "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/edit\">" +
3507
+ _setupField("Title", "title", col.title, "text", "", " maxlength=\"500\" required") +
3508
+ _setupField("Description", "description", col.description || "", "text", "", " maxlength=\"2000\"") +
3509
+ "<label class=\"form-field\"><span>Sort strategy</span><select name=\"sort_strategy\">" + sortOpts + "</select></label>";
3510
+
3511
+ if (col.type === "smart") {
3512
+ var fields = opts.rule_fields || collectionsModule.RULE_FIELDS;
3513
+ var ops = opts.rule_ops || collectionsModule.RULE_OPS;
3514
+ var existing = (col.rules && Array.isArray(col.rules.all)) ? col.rules.all : [];
3515
+ // Render the existing rules plus one spare empty row so the operator
3516
+ // can append without a separate "add row" round-trip.
3517
+ var ruleRows = existing.concat([{ field: "", op: "", value: "" }]).map(function (rule) {
3518
+ var rv = rule.value;
3519
+ if (Array.isArray(rv)) rv = rv.join(",");
3520
+ else if (rv == null) rv = "";
3521
+ var fieldOpts = "<option value=\"\">field…</option>" + fields.map(function (f) {
3522
+ return "<option value=\"" + _htmlEscape(f) + "\"" + (f === rule.field ? " selected" : "") + ">" + _htmlEscape(f) + "</option>";
3523
+ }).join("");
3524
+ var opOpts = "<option value=\"\">op…</option>" + ops.map(function (o) {
3525
+ return "<option value=\"" + _htmlEscape(o) + "\"" + (o === rule.op ? " selected" : "") + ">" + _htmlEscape(o) + "</option>";
3526
+ }).join("");
3527
+ return "<div class=\"actions-row\" style=\"margin:.4rem 0;\">" +
3528
+ "<select name=\"rule_field\">" + fieldOpts + "</select>" +
3529
+ "<select name=\"rule_op\">" + opOpts + "</select>" +
3530
+ "<input type=\"text\" name=\"rule_value\" value=\"" + _htmlEscape(String(rv)) + "\" placeholder=\"value\" maxlength=\"500\">" +
3531
+ "</div>";
3532
+ }).join("");
3533
+ editForm +=
3534
+ "<fieldset style=\"border:1px solid var(--hair); border-radius:.5rem; padding:.75rem 1rem; margin:1rem 0;\">" +
3535
+ "<legend style=\"padding:0 .4rem; font-size:.85rem;\">Rules (all must match)</legend>" +
3536
+ ruleRows +
3537
+ "<small style=\"color:var(--mute);\">Leave a row blank to drop it. Lists (in / not_in) + between use comma-separated values.</small>" +
3538
+ "</fieldset>";
3539
+ }
3540
+ editForm += "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save</button></div></form></div>";
3541
+
3542
+ var detailBody;
3543
+ if (col.type === "manual") {
3544
+ var members = opts.members || [];
3545
+ var memberRows = members.map(function (m) {
3546
+ return "<tr>" +
3547
+ "<td class=\"num\">" + _htmlEscape(String(m.position)) + "</td>" +
3548
+ "<td><code class=\"order-id\">" + _htmlEscape(m.product_id) + "</code></td>" +
3549
+ "<td><form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/remove\" style=\"display:inline;\">" +
3550
+ "<input type=\"hidden\" name=\"product_id\" value=\"" + _htmlEscape(m.product_id) + "\">" +
3551
+ "<button class=\"btn btn--danger\" type=\"submit\">Remove</button></form></td>" +
3552
+ "</tr>";
3553
+ }).join("");
3554
+ var memberTable = members.length
3555
+ ? "<div class=\"panel\"><table><thead><tr><th class=\"num\">#</th><th>Product id</th><th></th></tr></thead><tbody>" + memberRows + "</tbody></table></div>"
3556
+ : "<p class=\"empty\">No members yet — add a product below.</p>";
3557
+
3558
+ // Reorder: a single field of the current ids, comma-joined, that the
3559
+ // operator can rewrite into the new order. v1-defensible without
3560
+ // client JS — the primitive normalises positions to 0..N-1.
3561
+ var currentIds = members.map(function (m) { return m.product_id; }).join(",");
3562
+ var reorderForm = members.length > 1
3563
+ ? "<div class=\"panel\" style=\"margin-top:1rem; max-width:40rem;\">" +
3564
+ "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Reorder members</h3>" +
3565
+ "<p class=\"meta\">Rewrite the comma-separated id list into the order you want. Must list every current member.</p>" +
3566
+ "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/reorder\">" +
3567
+ "<label class=\"form-field\"><span>Ordered product ids</span>" +
3568
+ "<input type=\"text\" name=\"ordered_product_ids\" value=\"" + _htmlEscape(currentIds) + "\"></label>" +
3569
+ "<div class=\"actions-row\"><button class=\"btn btn--ghost\" type=\"submit\">Apply order</button></div>" +
3570
+ "</form>" +
3571
+ "</div>"
3572
+ : "";
3573
+
3574
+ var addForm =
3575
+ "<div class=\"panel\" style=\"margin-top:1rem; max-width:40rem;\">" +
3576
+ "<h3 style=\"font-size:.95rem; margin-bottom:.75rem;\">Add a member</h3>" +
3577
+ "<form method=\"post\" action=\"/admin/collections/" + _htmlEscape(enc) + "/members/add\">" +
3578
+ _setupField("Product id", "product_id", "", "text", "The catalog product's UUID.", " maxlength=\"64\" required") +
3579
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add product</button></div>" +
3580
+ "</form>" +
3581
+ "</div>";
3582
+
3583
+ detailBody = "<section style=\"margin-top:1.5rem;\"><h3 style=\"font-size:1.05rem;\">Members</h3>" +
3584
+ memberTable + reorderForm + addForm + "</section>";
3585
+ } else {
3586
+ var preview = opts.preview || [];
3587
+ var previewCards = preview.map(function (p) {
3588
+ return "<tr>" +
3589
+ "<td><strong>" + _htmlEscape(p.title || "(untitled)") + "</strong></td>" +
3590
+ "<td><code class=\"order-id\">" + _htmlEscape(p.slug || p.id || "") + "</code></td>" +
3591
+ "<td>" + _htmlEscape(p.status || "") + "</td>" +
3592
+ "</tr>";
3593
+ }).join("");
3594
+ var previewTable = preview.length
3595
+ ? "<div class=\"panel\"><table><thead><tr><th>Title</th><th>Slug</th><th>Status</th></tr></thead><tbody>" + previewCards + "</tbody></table></div>"
3596
+ : "<p class=\"empty\">No products match these rules yet.</p>";
3597
+ detailBody = "<section style=\"margin-top:1.5rem;\"><h3 style=\"font-size:1.05rem;\">Matched products (live preview)</h3>" +
3598
+ "<p class=\"meta\">The first " + _htmlEscape(String(preview.length)) + " products the rules match right now.</p>" +
3599
+ previewTable + "</section>";
3600
+ }
3601
+
3602
+ var head =
3603
+ "<p class=\"meta\"><a href=\"/admin/collections\">&larr; Collections</a> · " +
3604
+ "<code class=\"order-id\">" + _htmlEscape(col.slug) + "</code> · " +
3605
+ "<span class=\"status-pill " + (col.type === "smart" ? "pending" : "paid") + "\">" + _htmlEscape(col.type) + "</span>" +
3606
+ (col.archived_at != null ? " · <span class=\"status-pill cancelled\">archived</span>" : "") +
3607
+ " · <a href=\"/collections/" + _htmlEscape(enc) + "\" target=\"_blank\" rel=\"noreferrer\">View on storefront &rarr;</a></p>";
3608
+
3609
+ var body = "<section><h2>" + _htmlEscape(col.title) + "</h2>" + updated + notice + head + editForm + "</section>" + detailBody;
3610
+ return _renderAdminShell(opts.shop_name, "Collection " + col.slug, body, "collections", opts.nav_available);
3611
+ }
3612
+
2675
3613
  module.exports = {
2676
3614
  mount: mount,
2677
3615
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
@@ -2684,7 +3622,12 @@ module.exports = {
2684
3622
  renderAdminInventory: renderAdminInventory,
2685
3623
  renderAdminOrders: renderAdminOrders,
2686
3624
  renderAdminOrder: renderAdminOrder,
3625
+ renderAdminCustomers: renderAdminCustomers,
2687
3626
  renderAdminReturns: renderAdminReturns,
2688
3627
  renderAdminReturn: renderAdminReturn,
2689
3628
  renderAdminReviews: renderAdminReviews,
3629
+ renderAdminCollections: renderAdminCollections,
3630
+ renderAdminCollection: renderAdminCollection,
3631
+ renderAdminGiftCards: renderAdminGiftCards,
3632
+ renderAdminGiftCard: renderAdminGiftCard,
2690
3633
  };