@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.
- package/CHANGELOG.md +4 -0
- package/README.md +6 -2
- package/SECURITY.md +12 -0
- package/lib/admin.js +944 -1
- package/lib/checkout.js +192 -22
- package/lib/collections.js +4 -3
- package/lib/customers.js +102 -0
- package/lib/giftcards.js +40 -0
- package/lib/order.js +22 -0
- package/lib/storefront.js +400 -53
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +10 -0
- package/lib/vendor/blamejs/README.md +4 -0
- package/lib/vendor/blamejs/api-snapshot.json +129 -2
- package/lib/vendor/blamejs/index.js +12 -0
- package/lib/vendor/blamejs/lib/json-merge-patch.js +93 -0
- package/lib/vendor/blamejs/lib/json-patch.js +206 -0
- package/lib/vendor/blamejs/lib/json-path.js +638 -0
- package/lib/vendor/blamejs/lib/json-pointer.js +109 -0
- package/lib/vendor/blamejs/lib/jtd.js +234 -0
- package/lib/vendor/blamejs/lib/link-header.js +169 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.57.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.58.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.12.60.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.61.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.62.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/json-merge-patch.test.js +81 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/json-patch.test.js +184 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/json-path.test.js +113 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/jtd.test.js +52 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/link-header.test.js +96 -0
- 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/<slug>).</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\">← 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 →</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
|
};
|