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