@blamejs/blamejs-shop 0.3.51 → 0.3.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +10 -1
- package/SECURITY.md +58 -0
- package/lib/admin.js +779 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/email.js +94 -0
- package/lib/security-middleware.js +9 -0
- package/lib/storefront.js +450 -5
- package/lib/vendor/MANIFEST.json +2665 -2
- package/package.json +2 -1
package/lib/admin.js
CHANGED
|
@@ -35,6 +35,7 @@ var collectionsModule = require("./collections");
|
|
|
35
35
|
var quantityDiscountsModule = require("./quantity-discounts");
|
|
36
36
|
var loyaltyEarnRulesModule = require("./loyalty-earn-rules");
|
|
37
37
|
var loyaltyRedemptionModule = require("./loyalty-redemption");
|
|
38
|
+
var trustBadgesModule = require("./trust-badges");
|
|
38
39
|
var textGuard = require("./text-guard");
|
|
39
40
|
var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
|
|
40
41
|
|
|
@@ -514,7 +515,15 @@ function mount(router, deps) {
|
|
|
514
515
|
var complianceExport = deps.complianceExport || null; // DSR queue (export/erasure) disabled when absent
|
|
515
516
|
var orderExchanges = deps.orderExchanges || null; // exchange queue + FSM-action console disabled when absent
|
|
516
517
|
var orderRatings = deps.orderRatings || null; // per-order rating moderation queue (flag/clear + public reply) disabled when absent
|
|
518
|
+
var searchRanking = deps.searchRanking || null; // search-ranking weight-set + pin console disabled when absent
|
|
519
|
+
var trustBadges = deps.trustBadges || null; // trust-badge authoring console disabled when absent
|
|
517
520
|
var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
|
|
521
|
+
// Read-only activity log at /admin/audit. Defaults ON — the framework
|
|
522
|
+
// audit chain is always booted by createApp, so the screen always has a
|
|
523
|
+
// data source (unlike the optional primitives above, which default off).
|
|
524
|
+
// An operator who wants the console surface minimized can pass
|
|
525
|
+
// `auditLog: false` to hide both the route and the nav link.
|
|
526
|
+
var auditLog = deps.auditLog !== false;
|
|
518
527
|
|
|
519
528
|
// Which optional console sections are wired — gates their nav links so a
|
|
520
529
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
@@ -522,7 +531,7 @@ function mount(router, deps) {
|
|
|
522
531
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
523
532
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
524
533
|
// notice when the salesReports primitive isn't wired.
|
|
525
|
-
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, orderExport: !!orderExport };
|
|
534
|
+
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog };
|
|
526
535
|
|
|
527
536
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
528
537
|
|
|
@@ -1739,6 +1748,72 @@ function mount(router, deps) {
|
|
|
1739
1748
|
},
|
|
1740
1749
|
));
|
|
1741
1750
|
|
|
1751
|
+
// ---- audit / activity log (read-only) -------------------------------
|
|
1752
|
+
//
|
|
1753
|
+
// A read-only window onto the framework's tamper-evident audit chain —
|
|
1754
|
+
// every mutating admin action (`shop_admin.*`) and catalog-API error is
|
|
1755
|
+
// recorded there. Container-only: it reads `b.audit.query`, which needs
|
|
1756
|
+
// the booted framework DB, so there is no edge twin (and none is needed —
|
|
1757
|
+
// the whole admin console is container-only). Defaults ON; hidden when an
|
|
1758
|
+
// operator passes `auditLog: false`. No POST forms (read-only), so the
|
|
1759
|
+
// shell's CSRF field injection is a no-op here. `b.audit.query` itself
|
|
1760
|
+
// logs an `audit.read` row before each query, so opening this screen is
|
|
1761
|
+
// forensically recorded.
|
|
1762
|
+
//
|
|
1763
|
+
// Request-shape reader → RETURN-DEFAULT discipline: bad query params are
|
|
1764
|
+
// clamped / ignored, never thrown (a dashboard 500 on a fat-fingered URL
|
|
1765
|
+
// is worse than showing all). An unrecognized ?outcome= falls back to
|
|
1766
|
+
// no-filter with a notice, mirroring the orders bad-filter fallback.
|
|
1767
|
+
var AUDIT_OUTCOMES = ["success", "failure", "denied"];
|
|
1768
|
+
function _parseAuditQuery(req) {
|
|
1769
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
1770
|
+
var q = {};
|
|
1771
|
+
var notice = null;
|
|
1772
|
+
var outcome = url && url.searchParams.get("outcome");
|
|
1773
|
+
if (outcome) {
|
|
1774
|
+
if (AUDIT_OUTCOMES.indexOf(outcome) !== -1) q.outcome = outcome;
|
|
1775
|
+
else notice = "Unknown outcome filter — showing all.";
|
|
1776
|
+
}
|
|
1777
|
+
var action = url && url.searchParams.get("action");
|
|
1778
|
+
if (typeof action === "string" && action.length) q.action = action.slice(0, 200);
|
|
1779
|
+
var actorUserId = url && url.searchParams.get("actorUserId");
|
|
1780
|
+
if (typeof actorUserId === "string" && actorUserId.length) q.actorUserId = actorUserId.slice(0, 200);
|
|
1781
|
+
// limit: clamp to 1..200, default 50. offset: >= 0, default 0.
|
|
1782
|
+
var limitN = parseInt((url && url.searchParams.get("limit")) || "", 10);
|
|
1783
|
+
q.limit = (Number.isFinite(limitN) && limitN >= 1 && limitN <= 200) ? limitN : 50;
|
|
1784
|
+
var offsetN = parseInt((url && url.searchParams.get("offset")) || "", 10);
|
|
1785
|
+
q.offset = (Number.isFinite(offsetN) && offsetN >= 0) ? offsetN : 0;
|
|
1786
|
+
q._page = Math.floor(q.offset / q.limit) + 1;
|
|
1787
|
+
q._notice = notice;
|
|
1788
|
+
return q;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
if (auditLog) {
|
|
1792
|
+
router.get("/admin/audit", _pageOrApi(true,
|
|
1793
|
+
R(async function (req, res) { // bearer → JSON
|
|
1794
|
+
var q = _parseAuditQuery(req);
|
|
1795
|
+
var rows = [];
|
|
1796
|
+
try { rows = await b.audit.query(q); }
|
|
1797
|
+
catch (_e) { rows = []; }
|
|
1798
|
+
_json(res, 200, { rows: rows, page: q._page, limit: q.limit });
|
|
1799
|
+
}),
|
|
1800
|
+
async function (req, res) { // browser cookie → HTML
|
|
1801
|
+
var q = _parseAuditQuery(req);
|
|
1802
|
+
var rows = [];
|
|
1803
|
+
var notice = q._notice;
|
|
1804
|
+
// Read-only observability view: ANY query failure (a malformed
|
|
1805
|
+
// criterion, or an unavailable audit store) degrades to an empty
|
|
1806
|
+
// table + notice, never a 500. Drop-silent by design.
|
|
1807
|
+
try { rows = await b.audit.query(q); }
|
|
1808
|
+
catch (_e) { rows = []; notice = notice || "The audit log is temporarily unavailable."; }
|
|
1809
|
+
_sendHtml(res, 200, renderAdminAudit({
|
|
1810
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
1811
|
+
rows: rows, filters: q, notice: notice,
|
|
1812
|
+
}));
|
|
1813
|
+
},
|
|
1814
|
+
));
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1742
1817
|
// ---- shipment tracking (operator-managed) ---------------------------
|
|
1743
1818
|
//
|
|
1744
1819
|
// Attach a shipment to an order (carrier + optional tracking number) and
|
|
@@ -5336,6 +5411,298 @@ function mount(router, deps) {
|
|
|
5336
5411
|
));
|
|
5337
5412
|
}
|
|
5338
5413
|
|
|
5414
|
+
// ---- search ranking -------------------------------------------------
|
|
5415
|
+
// Operator-tunable storefront search ranking: named weight sets (one
|
|
5416
|
+
// active at a time), per-query manual pins, and a per-set metrics rollup.
|
|
5417
|
+
// Content-negotiated like the other consoles (bearer → JSON, browser →
|
|
5418
|
+
// HTML). The weight map is authored as a small JSON object validated
|
|
5419
|
+
// server-side; coded errors (NOT_FOUND / ARCHIVED) map to 404 / 400 before
|
|
5420
|
+
// the TypeError fallthrough, so a bad slug never 500s.
|
|
5421
|
+
if (deps.searchRanking) {
|
|
5422
|
+
var searchRankingApi = deps.searchRanking;
|
|
5423
|
+
|
|
5424
|
+
// Parse the `weights` JSON textarea into the flat signal->number map the
|
|
5425
|
+
// primitive expects. A non-object / malformed JSON surfaces as a TypeError
|
|
5426
|
+
// (caught by the route → _safeNotice 400) so the operator gets a clean
|
|
5427
|
+
// re-render rather than a 500.
|
|
5428
|
+
function _parseWeightsJson(raw) {
|
|
5429
|
+
var s = typeof raw === "string" ? raw.trim() : "";
|
|
5430
|
+
if (!s.length) throw new TypeError("weights must be a JSON object of signal -> number");
|
|
5431
|
+
var parsed;
|
|
5432
|
+
try { parsed = JSON.parse(s); }
|
|
5433
|
+
catch (_e) { throw new TypeError("weights must be valid JSON (a flat object of signal -> number)"); }
|
|
5434
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
5435
|
+
throw new TypeError("weights must be a JSON object, e.g. {\"relevance\":1,\"in_stock\":0.5}");
|
|
5436
|
+
}
|
|
5437
|
+
return parsed;
|
|
5438
|
+
}
|
|
5439
|
+
|
|
5440
|
+
async function _searchRankingListData() {
|
|
5441
|
+
var weights = await searchRankingApi.listWeights({ include_archived: true });
|
|
5442
|
+
var active = await searchRankingApi.activeWeights();
|
|
5443
|
+
return { weights: weights, active: active };
|
|
5444
|
+
}
|
|
5445
|
+
|
|
5446
|
+
router.get("/admin/search-ranking", _pageOrApi(true,
|
|
5447
|
+
R(async function (req, res) {
|
|
5448
|
+
var data = await _searchRankingListData();
|
|
5449
|
+
_json(res, 200, { weights: data.weights, active: data.active ? data.active.slug : null });
|
|
5450
|
+
}),
|
|
5451
|
+
async function (req, res) {
|
|
5452
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
5453
|
+
var data = await _searchRankingListData();
|
|
5454
|
+
// Optional pin manager for a query (?pins_query=...).
|
|
5455
|
+
var pinsQuery = url && url.searchParams.get("pins_query");
|
|
5456
|
+
var pins = [];
|
|
5457
|
+
if (pinsQuery) {
|
|
5458
|
+
try { pins = await searchRankingApi.pinsForQuery(pinsQuery); }
|
|
5459
|
+
catch (_e) { pins = []; }
|
|
5460
|
+
}
|
|
5461
|
+
// Optional metrics rollup for a weight set (?metrics_slug=&from=&to=).
|
|
5462
|
+
var metrics = null;
|
|
5463
|
+
var metricsSlug = url && url.searchParams.get("metrics_slug");
|
|
5464
|
+
if (metricsSlug) {
|
|
5465
|
+
var fromMs = _searchRankingMsParam(url && url.searchParams.get("from"), Date.now() - b.constants.TIME.days(30));
|
|
5466
|
+
var toMs = _searchRankingMsParam(url && url.searchParams.get("to"), Date.now());
|
|
5467
|
+
try { metrics = await searchRankingApi.metricsForWeights({ weights_slug: metricsSlug, from: fromMs, to: toMs }); }
|
|
5468
|
+
catch (_e) { metrics = null; }
|
|
5469
|
+
}
|
|
5470
|
+
_sendHtml(res, 200, renderAdminSearchRanking({
|
|
5471
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
5472
|
+
weights: data.weights, active: data.active,
|
|
5473
|
+
pins_query: pinsQuery, pins: pins, metrics: metrics,
|
|
5474
|
+
created: url && url.searchParams.get("created"),
|
|
5475
|
+
activated: url && url.searchParams.get("activated"),
|
|
5476
|
+
archived: url && url.searchParams.get("archived"),
|
|
5477
|
+
pinned: url && url.searchParams.get("pinned"),
|
|
5478
|
+
unpinned: url && url.searchParams.get("unpinned"),
|
|
5479
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
|
|
5480
|
+
}));
|
|
5481
|
+
},
|
|
5482
|
+
));
|
|
5483
|
+
|
|
5484
|
+
router.post("/admin/search-ranking/weights", _pageOrApi(false,
|
|
5485
|
+
W("search_ranking.define_weights", async function (req, res) {
|
|
5486
|
+
var b2 = req.body || {};
|
|
5487
|
+
var row = await searchRankingApi.defineWeights({ slug: b2.slug, name: b2.name, weights: _parseWeightsJson(b2.weights) });
|
|
5488
|
+
_json(res, 201, row);
|
|
5489
|
+
return { id: row.slug };
|
|
5490
|
+
}),
|
|
5491
|
+
async function (req, res) {
|
|
5492
|
+
var b2 = req.body || {};
|
|
5493
|
+
try {
|
|
5494
|
+
await searchRankingApi.defineWeights({ slug: b2.slug, name: b2.name, weights: _parseWeightsJson(b2.weights) });
|
|
5495
|
+
} catch (e) {
|
|
5496
|
+
var n = _safeNotice(e, "search_ranking.define_weights");
|
|
5497
|
+
var data = await _searchRankingListData();
|
|
5498
|
+
return _sendHtml(res, n.status, renderAdminSearchRanking({
|
|
5499
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
5500
|
+
weights: data.weights, active: data.active,
|
|
5501
|
+
notice: n.message.replace(/^searchRanking[.:]\s*/, ""),
|
|
5502
|
+
}));
|
|
5503
|
+
}
|
|
5504
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_ranking.define_weights", outcome: "success", metadata: { slug: b2.slug } });
|
|
5505
|
+
_redirect(res, "/admin/search-ranking?created=1");
|
|
5506
|
+
},
|
|
5507
|
+
));
|
|
5508
|
+
|
|
5509
|
+
router.post("/admin/search-ranking/weights/:slug/activate", _pageOrApi(false,
|
|
5510
|
+
W("search_ranking.activate_weights", async function (req, res) {
|
|
5511
|
+
var row;
|
|
5512
|
+
try { row = await searchRankingApi.setActiveWeights(req.params.slug); }
|
|
5513
|
+
catch (e) {
|
|
5514
|
+
if (e && e.code === "SEARCH_WEIGHTS_NOT_FOUND") return _problem(res, 404, "weights-not-found");
|
|
5515
|
+
if (e && e.code === "SEARCH_WEIGHTS_ARCHIVED") return _problem(res, 400, "weights-archived");
|
|
5516
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
5517
|
+
throw e;
|
|
5518
|
+
}
|
|
5519
|
+
_json(res, 200, row);
|
|
5520
|
+
return { id: row.slug };
|
|
5521
|
+
}),
|
|
5522
|
+
async function (req, res) {
|
|
5523
|
+
var slug = req.params.slug;
|
|
5524
|
+
try { await searchRankingApi.setActiveWeights(slug); }
|
|
5525
|
+
catch (e) {
|
|
5526
|
+
// Coded errors (NOT_FOUND / ARCHIVED) are Error with .code, NOT
|
|
5527
|
+
// TypeError — check e.code first, then the TypeError fallthrough.
|
|
5528
|
+
if (!(e && (e.code === "SEARCH_WEIGHTS_NOT_FOUND" || e.code === "SEARCH_WEIGHTS_ARCHIVED") || e instanceof TypeError)) throw e;
|
|
5529
|
+
return _redirect(res, "/admin/search-ranking?err=1");
|
|
5530
|
+
}
|
|
5531
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_ranking.activate_weights", outcome: "success", metadata: { slug: slug } });
|
|
5532
|
+
_redirect(res, "/admin/search-ranking?activated=1");
|
|
5533
|
+
},
|
|
5534
|
+
));
|
|
5535
|
+
|
|
5536
|
+
router.post("/admin/search-ranking/weights/:slug/archive", _pageOrApi(false,
|
|
5537
|
+
W("search_ranking.archive_weights", async function (req, res) {
|
|
5538
|
+
var row;
|
|
5539
|
+
try { row = await searchRankingApi.archiveWeights(req.params.slug); }
|
|
5540
|
+
catch (e) {
|
|
5541
|
+
if (e && e.code === "SEARCH_WEIGHTS_NOT_FOUND") return _problem(res, 404, "weights-not-found");
|
|
5542
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
5543
|
+
throw e;
|
|
5544
|
+
}
|
|
5545
|
+
_json(res, 200, row);
|
|
5546
|
+
return { id: row.slug };
|
|
5547
|
+
}),
|
|
5548
|
+
async function (req, res) {
|
|
5549
|
+
var slug = req.params.slug;
|
|
5550
|
+
try { await searchRankingApi.archiveWeights(slug); }
|
|
5551
|
+
catch (e) { if (!(e && e.code === "SEARCH_WEIGHTS_NOT_FOUND" || e instanceof TypeError)) throw e; return _redirect(res, "/admin/search-ranking?err=1"); }
|
|
5552
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_ranking.archive_weights", outcome: "success", metadata: { slug: slug } });
|
|
5553
|
+
_redirect(res, "/admin/search-ranking?archived=1");
|
|
5554
|
+
},
|
|
5555
|
+
));
|
|
5556
|
+
|
|
5557
|
+
router.post("/admin/search-ranking/pins", _pageOrApi(false,
|
|
5558
|
+
W("search_ranking.pin", async function (req, res) {
|
|
5559
|
+
var b2 = req.body || {};
|
|
5560
|
+
var row = await searchRankingApi.pinProductForQuery({
|
|
5561
|
+
query: b2.query, product_id: b2.product_id, position: _searchRankingIntField(b2.position),
|
|
5562
|
+
});
|
|
5563
|
+
_json(res, 201, row);
|
|
5564
|
+
return { id: (row && row.product_id) || null };
|
|
5565
|
+
}),
|
|
5566
|
+
async function (req, res) {
|
|
5567
|
+
var b2 = req.body || {};
|
|
5568
|
+
try {
|
|
5569
|
+
await searchRankingApi.pinProductForQuery({
|
|
5570
|
+
query: b2.query, product_id: b2.product_id, position: _searchRankingIntField(b2.position),
|
|
5571
|
+
});
|
|
5572
|
+
} catch (e) {
|
|
5573
|
+
if (!(e instanceof TypeError)) throw e;
|
|
5574
|
+
return _redirect(res, "/admin/search-ranking?err=1" + (b2.query ? "&pins_query=" + encodeURIComponent(b2.query) : ""));
|
|
5575
|
+
}
|
|
5576
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_ranking.pin", outcome: "success" });
|
|
5577
|
+
_redirect(res, "/admin/search-ranking?pinned=1" + (b2.query ? "&pins_query=" + encodeURIComponent(b2.query) : ""));
|
|
5578
|
+
},
|
|
5579
|
+
));
|
|
5580
|
+
|
|
5581
|
+
router.post("/admin/search-ranking/pins/delete", _pageOrApi(false,
|
|
5582
|
+
W("search_ranking.unpin", async function (req, res) {
|
|
5583
|
+
var b2 = req.body || {};
|
|
5584
|
+
var result = await searchRankingApi.unpinProduct({ query: b2.query, product_id: b2.product_id });
|
|
5585
|
+
_json(res, 200, result);
|
|
5586
|
+
return { id: b2.product_id || null };
|
|
5587
|
+
}),
|
|
5588
|
+
async function (req, res) {
|
|
5589
|
+
var b2 = req.body || {};
|
|
5590
|
+
try { await searchRankingApi.unpinProduct({ query: b2.query, product_id: b2.product_id }); }
|
|
5591
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/search-ranking?err=1" + (b2.query ? "&pins_query=" + encodeURIComponent(b2.query) : "")); }
|
|
5592
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_ranking.unpin", outcome: "success" });
|
|
5593
|
+
_redirect(res, "/admin/search-ranking?unpinned=1" + (b2.query ? "&pins_query=" + encodeURIComponent(b2.query) : ""));
|
|
5594
|
+
},
|
|
5595
|
+
));
|
|
5596
|
+
}
|
|
5597
|
+
|
|
5598
|
+
// ---- trust badges ---------------------------------------------------
|
|
5599
|
+
// Operator-authored trust/certification badges. Create with EITHER an
|
|
5600
|
+
// inline SVG payload (sanitized at define time through b.guardSvg — a
|
|
5601
|
+
// hostile <script>/onload SVG refuses as a TypeError) OR a hosted https://
|
|
5602
|
+
// image URL, plus placements, schedule, priority, alt text. The raw
|
|
5603
|
+
// svg_payload is NEVER echoed into an admin HTML attribute — it's rendered
|
|
5604
|
+
// inside a <textarea> (which escapes < and &) on the edit screen.
|
|
5605
|
+
if (deps.trustBadges) {
|
|
5606
|
+
var trustBadgesApi = deps.trustBadges;
|
|
5607
|
+
|
|
5608
|
+
async function _trustBadgesList() {
|
|
5609
|
+
return await trustBadgesApi.listBadges({});
|
|
5610
|
+
}
|
|
5611
|
+
|
|
5612
|
+
router.get("/admin/trust-badges", _pageOrApi(true,
|
|
5613
|
+
R(async function (req, res) {
|
|
5614
|
+
_json(res, 200, { rows: await _trustBadgesList() });
|
|
5615
|
+
}),
|
|
5616
|
+
async function (req, res) {
|
|
5617
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
5618
|
+
_sendHtml(res, 200, renderAdminTrustBadges({
|
|
5619
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
5620
|
+
badges: await _trustBadgesList(),
|
|
5621
|
+
created: url && url.searchParams.get("created"),
|
|
5622
|
+
archived: url && url.searchParams.get("archived"),
|
|
5623
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the badge." : null,
|
|
5624
|
+
}));
|
|
5625
|
+
},
|
|
5626
|
+
));
|
|
5627
|
+
|
|
5628
|
+
router.post("/admin/trust-badges", _pageOrApi(false,
|
|
5629
|
+
W("trust_badge.define", async function (req, res) {
|
|
5630
|
+
var row = await trustBadgesApi.defineBadge(_trustBadgeFromForm(req.body || {}));
|
|
5631
|
+
_json(res, 201, row);
|
|
5632
|
+
return { id: row.slug };
|
|
5633
|
+
}),
|
|
5634
|
+
async function (req, res) {
|
|
5635
|
+
try {
|
|
5636
|
+
await trustBadgesApi.defineBadge(_trustBadgeFromForm(req.body || {}));
|
|
5637
|
+
} catch (e) {
|
|
5638
|
+
var n = _safeNotice(e, "trust_badge.define");
|
|
5639
|
+
return _sendHtml(res, n.status, renderAdminTrustBadges({
|
|
5640
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
5641
|
+
badges: await _trustBadgesList(),
|
|
5642
|
+
notice: n.message.replace(/^trustBadges[.:]\s*/, ""),
|
|
5643
|
+
}));
|
|
5644
|
+
}
|
|
5645
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".trust_badge.define", outcome: "success" });
|
|
5646
|
+
_redirect(res, "/admin/trust-badges?created=1");
|
|
5647
|
+
},
|
|
5648
|
+
));
|
|
5649
|
+
|
|
5650
|
+
router.get("/admin/trust-badges/:slug", _pageOrApi(true,
|
|
5651
|
+
R(async function (req, res) {
|
|
5652
|
+
var row = await trustBadgesApi.getBadge(req.params.slug);
|
|
5653
|
+
if (!row) return _problem(res, 404, "trust-badge-not-found");
|
|
5654
|
+
_json(res, 200, row);
|
|
5655
|
+
}),
|
|
5656
|
+
async function (req, res) {
|
|
5657
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
5658
|
+
var row = await trustBadgesApi.getBadge(req.params.slug);
|
|
5659
|
+
if (!row) return _sendHtml(res, 404, renderAdminTrustBadges({
|
|
5660
|
+
shop_name: deps.shop_name, nav_available: navAvailable, badges: [], notice: "Badge not found.",
|
|
5661
|
+
}));
|
|
5662
|
+
_sendHtml(res, 200, renderAdminTrustBadge({
|
|
5663
|
+
shop_name: deps.shop_name, nav_available: navAvailable, badge: row,
|
|
5664
|
+
updated: url && url.searchParams.get("updated"),
|
|
5665
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the badge." : null,
|
|
5666
|
+
}));
|
|
5667
|
+
},
|
|
5668
|
+
));
|
|
5669
|
+
|
|
5670
|
+
router.post("/admin/trust-badges/:slug/edit", _pageOrApi(false,
|
|
5671
|
+
W("trust_badge.update", async function (req, res) {
|
|
5672
|
+
var row;
|
|
5673
|
+
try { row = await trustBadgesApi.updateBadge(req.params.slug, _trustBadgePatchFromForm(req.body || {})); }
|
|
5674
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
5675
|
+
_json(res, 200, row);
|
|
5676
|
+
return { id: row.slug };
|
|
5677
|
+
}),
|
|
5678
|
+
async function (req, res) {
|
|
5679
|
+
var slug = req.params.slug;
|
|
5680
|
+
var enc = encodeURIComponent(slug);
|
|
5681
|
+
try { await trustBadgesApi.updateBadge(slug, _trustBadgePatchFromForm(req.body || {})); }
|
|
5682
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/trust-badges/" + enc + "?err=1"); }
|
|
5683
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".trust_badge.update", outcome: "success", metadata: { slug: slug } });
|
|
5684
|
+
_redirect(res, "/admin/trust-badges/" + enc + "?updated=1");
|
|
5685
|
+
},
|
|
5686
|
+
));
|
|
5687
|
+
|
|
5688
|
+
router.post("/admin/trust-badges/:slug/archive", _pageOrApi(false,
|
|
5689
|
+
W("trust_badge.archive", async function (req, res) {
|
|
5690
|
+
var row;
|
|
5691
|
+
try { row = await trustBadgesApi.archiveBadge(req.params.slug); }
|
|
5692
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
5693
|
+
_json(res, 200, row);
|
|
5694
|
+
return { id: row.slug };
|
|
5695
|
+
}),
|
|
5696
|
+
async function (req, res) {
|
|
5697
|
+
var slug = req.params.slug;
|
|
5698
|
+
try { await trustBadgesApi.archiveBadge(slug); }
|
|
5699
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/trust-badges?err=1"); }
|
|
5700
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".trust_badge.archive", outcome: "success", metadata: { slug: slug } });
|
|
5701
|
+
_redirect(res, "/admin/trust-badges?archived=1");
|
|
5702
|
+
},
|
|
5703
|
+
));
|
|
5704
|
+
}
|
|
5705
|
+
|
|
5339
5706
|
// ---- pre-orders -----------------------------------------------------
|
|
5340
5707
|
// Campaigns that take reservations against a SKU that isn't released yet.
|
|
5341
5708
|
// The lifecycle is draft-free: defineCampaign opens an `active` campaign,
|
|
@@ -9901,6 +10268,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
9901
10268
|
{ key: "inventory", href: "/admin/inventory", label: "Inventory" },
|
|
9902
10269
|
{ key: "orders", href: "/admin/orders", label: "Orders" },
|
|
9903
10270
|
{ key: "reports", href: "/admin/reports", label: "Reports" },
|
|
10271
|
+
{ key: "audit", href: "/admin/audit", label: "Audit", requires: "auditLog" },
|
|
9904
10272
|
{ key: "exports", href: "/admin/exports", label: "Exports", requires: "orderExport" },
|
|
9905
10273
|
{ key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
|
|
9906
10274
|
{ key: "segments", href: "/admin/segments", label: "Segments", requires: "customerSegments" },
|
|
@@ -9909,6 +10277,8 @@ var ADMIN_NAV_ITEMS = [
|
|
|
9909
10277
|
{ key: "dsr", href: "/admin/dsr", label: "Privacy requests", requires: "complianceExport" },
|
|
9910
10278
|
{ key: "exchanges", href: "/admin/exchanges", label: "Exchanges", requires: "orderExchanges" },
|
|
9911
10279
|
{ key: "ratings", href: "/admin/ratings", label: "Ratings", requires: "orderRatings" },
|
|
10280
|
+
{ key: "search-ranking", href: "/admin/search-ranking", label: "Search ranking", requires: "searchRanking" },
|
|
10281
|
+
{ key: "trust-badges", href: "/admin/trust-badges", label: "Trust badges", requires: "trustBadges" },
|
|
9912
10282
|
{ key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
|
|
9913
10283
|
{ key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
|
|
9914
10284
|
{ key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
|
|
@@ -10212,6 +10582,90 @@ function renderAdminOrders(opts) {
|
|
|
10212
10582
|
return _renderAdminShell(opts.shop_name, "Orders", body, "orders", opts.nav_available);
|
|
10213
10583
|
}
|
|
10214
10584
|
|
|
10585
|
+
// The outcomes an operator can filter the audit log by — drives the chips.
|
|
10586
|
+
var AUDIT_OUTCOME_FILTERS = ["success", "failure", "denied"];
|
|
10587
|
+
|
|
10588
|
+
// Read-only activity log. Renders the framework audit rows (action, outcome,
|
|
10589
|
+
// actor, resource, truncated metadata) for the selected outcome filter, with
|
|
10590
|
+
// offset/limit paging.
|
|
10591
|
+
//
|
|
10592
|
+
// XSS-CRITICAL: audit rows carry operator-controlled action names and
|
|
10593
|
+
// ATTACKER-INFLUENCED free text — a bot-driven `actorUserAgent`, a caught
|
|
10594
|
+
// error's message folded into `metadata`, an attacker-chosen resource id.
|
|
10595
|
+
// EVERY cell value flows through `_htmlEscape` (escape-by-default). This
|
|
10596
|
+
// class is un-lintable (no detector can tell a render-concat apart from a
|
|
10597
|
+
// string-build), so the XSS-payload regression test is the guarantee — see
|
|
10598
|
+
// test/layer-2-integration/admin-audit-console.test.js.
|
|
10599
|
+
function renderAdminAudit(opts) {
|
|
10600
|
+
opts = opts || {};
|
|
10601
|
+
var rows = opts.rows || [];
|
|
10602
|
+
var filters = opts.filters || {};
|
|
10603
|
+
var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
10604
|
+
var active = filters.outcome || null;
|
|
10605
|
+
var limit = filters.limit || 50;
|
|
10606
|
+
var offset = filters.offset || 0;
|
|
10607
|
+
|
|
10608
|
+
// Preserve an active ?action= filter across the outcome chips + pager.
|
|
10609
|
+
function _withParams(extra) {
|
|
10610
|
+
var parts = [];
|
|
10611
|
+
if (extra.outcome) parts.push("outcome=" + encodeURIComponent(extra.outcome));
|
|
10612
|
+
if (filters.action) parts.push("action=" + encodeURIComponent(filters.action));
|
|
10613
|
+
if (filters.actorUserId) parts.push("actorUserId=" + encodeURIComponent(filters.actorUserId));
|
|
10614
|
+
if (extra.offset != null) parts.push("offset=" + encodeURIComponent(String(extra.offset)));
|
|
10615
|
+
if (limit !== 50) parts.push("limit=" + encodeURIComponent(String(limit)));
|
|
10616
|
+
return "/admin/audit" + (parts.length ? "?" + parts.join("&") : "");
|
|
10617
|
+
}
|
|
10618
|
+
|
|
10619
|
+
var chips = "<div class=\"order-filters\">" +
|
|
10620
|
+
"<a class=\"chip" + (active ? "" : " chip--on") + "\" href=\"" + _htmlEscape(_withParams({})) + "\">All</a>" +
|
|
10621
|
+
AUDIT_OUTCOME_FILTERS.map(function (o) {
|
|
10622
|
+
return "<a class=\"chip" + (active === o ? " chip--on" : "") + "\" href=\"" + _htmlEscape(_withParams({ outcome: o })) + "\">" + _htmlEscape(o) + "</a>";
|
|
10623
|
+
}).join("") +
|
|
10624
|
+
"</div>";
|
|
10625
|
+
|
|
10626
|
+
var bodyRows = rows.map(function (row) {
|
|
10627
|
+
// metadata is a JSON string (audit.record stores it as JSON.stringify);
|
|
10628
|
+
// escape THEN truncate so a long redacted blob can't blow the row, and
|
|
10629
|
+
// so a payload inside it is neutralized before it reaches the DOM.
|
|
10630
|
+
var meta = row.metadata == null ? "" : String(row.metadata);
|
|
10631
|
+
var metaEsc = _htmlEscape(meta.length > 120 ? meta.slice(0, 120) + "…" : meta);
|
|
10632
|
+
var resource = "";
|
|
10633
|
+
if (row.resourceKind || row.resourceId) {
|
|
10634
|
+
resource = _htmlEscape(row.resourceKind || "") +
|
|
10635
|
+
(row.resourceId ? " <code>" + _htmlEscape(row.resourceId) + "</code>" : "");
|
|
10636
|
+
} else {
|
|
10637
|
+
resource = "—";
|
|
10638
|
+
}
|
|
10639
|
+
return "<tr>" +
|
|
10640
|
+
"<td>" + _htmlEscape(_fmtDate(row.recordedAt)) + "</td>" +
|
|
10641
|
+
"<td><code>" + _htmlEscape(row.action) + "</code></td>" +
|
|
10642
|
+
"<td><span class=\"status-pill " + _htmlEscape(row.outcome) + "\">" + _htmlEscape(row.outcome) + "</span></td>" +
|
|
10643
|
+
"<td>" + _htmlEscape(row.actorUserId == null ? "—" : row.actorUserId) + "</td>" +
|
|
10644
|
+
"<td>" + resource + "</td>" +
|
|
10645
|
+
"<td class=\"audit-meta\">" + metaEsc + "</td>" +
|
|
10646
|
+
"</tr>";
|
|
10647
|
+
}).join("");
|
|
10648
|
+
|
|
10649
|
+
var table = rows.length
|
|
10650
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Time</th><th scope=\"col\">Action</th><th scope=\"col\">Outcome</th><th scope=\"col\">Actor</th><th scope=\"col\">Resource</th><th scope=\"col\">Details</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
10651
|
+
: "<p class=\"empty\">No audit events match.</p>";
|
|
10652
|
+
|
|
10653
|
+
// Pager: Newer steps back one page (disabled at offset 0); Older steps
|
|
10654
|
+
// forward (shown only when the page is full — the standard "there may be
|
|
10655
|
+
// more" heuristic). Filters are preserved in both hrefs.
|
|
10656
|
+
var prevOffset = Math.max(0, offset - limit);
|
|
10657
|
+
var newer = offset > 0
|
|
10658
|
+
? "<a class=\"btn btn--ghost\" href=\"" + _htmlEscape(_withParams({ outcome: active, offset: prevOffset })) + "\">← Newer</a>"
|
|
10659
|
+
: "<span class=\"btn btn--ghost\" aria-disabled=\"true\">← Newer</span>";
|
|
10660
|
+
var older = rows.length === limit
|
|
10661
|
+
? "<a class=\"btn btn--ghost\" href=\"" + _htmlEscape(_withParams({ outcome: active, offset: offset + limit })) + "\">Older →</a>"
|
|
10662
|
+
: "";
|
|
10663
|
+
var pager = "<div class=\"order-filters\">" + newer + older + "</div>";
|
|
10664
|
+
|
|
10665
|
+
var body = "<section><h2>Audit log</h2>" + notice + chips + table + (rows.length || offset > 0 ? pager : "") + "</section>";
|
|
10666
|
+
return _renderAdminShell(opts.shop_name, "Audit", body, "audit", opts.nav_available);
|
|
10667
|
+
}
|
|
10668
|
+
|
|
10215
10669
|
// epoch-ms → "YYYY-MM-DD" for a date <input>'s value. Mirrors `_fmtDate`'s
|
|
10216
10670
|
// guard so a bad value never throws inside the template.
|
|
10217
10671
|
function _dateInputValue(v) {
|
|
@@ -13929,6 +14383,98 @@ function _announcementPatchFromForm(body) {
|
|
|
13929
14383
|
return patch;
|
|
13930
14384
|
}
|
|
13931
14385
|
|
|
14386
|
+
// ---- search-ranking form coercion -----------------------------------
|
|
14387
|
+
|
|
14388
|
+
// `?from`/`?to` ms-epoch query params for the metrics rollup → integer, or a
|
|
14389
|
+
// supplied fallback when blank/unparseable.
|
|
14390
|
+
function _searchRankingMsParam(v, fallback) {
|
|
14391
|
+
if (v == null || v === "") return fallback;
|
|
14392
|
+
var n = parseInt(v, 10);
|
|
14393
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
14394
|
+
}
|
|
14395
|
+
|
|
14396
|
+
// A position form field → integer (the primitive validates the 1..MAX range +
|
|
14397
|
+
// throws a TypeError on a bad shape). A blank/non-numeric value is forwarded
|
|
14398
|
+
// as NaN so the primitive's validator (not this coercion) reports it.
|
|
14399
|
+
function _searchRankingIntField(v) {
|
|
14400
|
+
if (v == null || v === "") return NaN;
|
|
14401
|
+
var n = parseInt(v, 10);
|
|
14402
|
+
return Number.isFinite(n) ? n : NaN;
|
|
14403
|
+
}
|
|
14404
|
+
|
|
14405
|
+
// ---- trust-badge form coercion --------------------------------------
|
|
14406
|
+
|
|
14407
|
+
// Read the placements multi-checkbox into a non-empty array. An HTML form
|
|
14408
|
+
// submits repeated `placements` fields as an array (when >1 checked) or a
|
|
14409
|
+
// single string (when exactly 1 checked). The primitive validates membership
|
|
14410
|
+
// + the 1..6 count + dedup, throwing a TypeError on a bad shape.
|
|
14411
|
+
function _trustBadgePlacements(raw) {
|
|
14412
|
+
if (Array.isArray(raw)) return raw;
|
|
14413
|
+
if (typeof raw === "string" && raw.length) return [raw];
|
|
14414
|
+
return []; // empty → the primitive's _placements throws "non-empty array"
|
|
14415
|
+
}
|
|
14416
|
+
|
|
14417
|
+
// Coerce the create form into the shape trustBadges.defineBadge expects.
|
|
14418
|
+
// EITHER svg_payload OR image_url (blank maps to absent so the primitive's
|
|
14419
|
+
// either/or rule applies). The primitive is authoritative on every field
|
|
14420
|
+
// (SVG sanitize via guardSvg, URL via safeUrl, slug/title/alt shape, priority
|
|
14421
|
+
// range, schedule monotonicity) and throws a TypeError on a bad shape, which
|
|
14422
|
+
// both surfaces degrade to a clean 400 / err notice.
|
|
14423
|
+
function _trustBadgeFromForm(body) {
|
|
14424
|
+
body = body || {};
|
|
14425
|
+
var out = {
|
|
14426
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
14427
|
+
title: body.title,
|
|
14428
|
+
alt_text: body.alt_text,
|
|
14429
|
+
placements: _trustBadgePlacements(body.placements),
|
|
14430
|
+
};
|
|
14431
|
+
var svg = typeof body.svg_payload === "string" ? body.svg_payload.trim() : "";
|
|
14432
|
+
var img = typeof body.image_url === "string" ? body.image_url.trim() : "";
|
|
14433
|
+
if (svg) out.svg_payload = svg;
|
|
14434
|
+
if (img) out.image_url = img;
|
|
14435
|
+
var link = typeof body.link_url === "string" ? body.link_url.trim() : "";
|
|
14436
|
+
if (link) out.link_url = link;
|
|
14437
|
+
if (body.priority != null && body.priority !== "") {
|
|
14438
|
+
var p = parseInt(body.priority, 10);
|
|
14439
|
+
out.priority = Number.isFinite(p) ? p : body.priority;
|
|
14440
|
+
}
|
|
14441
|
+
var sa = _epochFromForm(body.starts_at); if (sa != null) out.starts_at = sa;
|
|
14442
|
+
var ea = _epochFromForm(body.expires_at); if (ea != null) out.expires_at = ea;
|
|
14443
|
+
return out;
|
|
14444
|
+
}
|
|
14445
|
+
|
|
14446
|
+
// Coerce the edit form into an updateBadge patch. Each editable column rides
|
|
14447
|
+
// only when its hidden presence marker says so, so a partial edit doesn't
|
|
14448
|
+
// clear an unrelated column. The svg_payload / image_url pair is mutually
|
|
14449
|
+
// exclusive on the resulting row (the primitive enforces it); the edit form
|
|
14450
|
+
// only ever sends the column the badge already uses.
|
|
14451
|
+
function _trustBadgePatchFromForm(body) {
|
|
14452
|
+
body = body || {};
|
|
14453
|
+
var patch = {};
|
|
14454
|
+
if (body.title != null && body.title !== "") patch.title = body.title;
|
|
14455
|
+
if (body.alt_text != null && body.alt_text !== "") patch.alt_text = body.alt_text;
|
|
14456
|
+
if (body.placements_present === "1") patch.placements = _trustBadgePlacements(body.placements);
|
|
14457
|
+
if (body.svg_present === "1") {
|
|
14458
|
+
var svg = typeof body.svg_payload === "string" ? body.svg_payload.trim() : "";
|
|
14459
|
+
patch.svg_payload = svg ? svg : null;
|
|
14460
|
+
}
|
|
14461
|
+
if (body.image_present === "1") {
|
|
14462
|
+
var img = typeof body.image_url === "string" ? body.image_url.trim() : "";
|
|
14463
|
+
patch.image_url = img ? img : null;
|
|
14464
|
+
}
|
|
14465
|
+
if (body.link_present === "1") {
|
|
14466
|
+
var link = typeof body.link_url === "string" ? body.link_url.trim() : "";
|
|
14467
|
+
patch.link_url = link ? link : null;
|
|
14468
|
+
}
|
|
14469
|
+
if (body.priority != null && body.priority !== "") {
|
|
14470
|
+
var p = parseInt(body.priority, 10);
|
|
14471
|
+
patch.priority = Number.isFinite(p) ? p : body.priority;
|
|
14472
|
+
}
|
|
14473
|
+
if (body.starts_present === "1") patch.starts_at = _epochFromForm(body.starts_at);
|
|
14474
|
+
if (body.expires_present === "1") patch.expires_at = _epochFromForm(body.expires_at);
|
|
14475
|
+
return patch;
|
|
14476
|
+
}
|
|
14477
|
+
|
|
13932
14478
|
// Coerce the create form into the shape preorder.defineCampaign expects.
|
|
13933
14479
|
// Prices are entered + stored in MINOR units (cents) — the form labels say
|
|
13934
14480
|
// so, and the JSON API uses minor units too. The launch date is a
|
|
@@ -14100,6 +14646,237 @@ function renderAdminAnnouncements(opts) {
|
|
|
14100
14646
|
return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
|
|
14101
14647
|
}
|
|
14102
14648
|
|
|
14649
|
+
// Search-ranking console — the weight-set list (active flag + activate /
|
|
14650
|
+
// archive actions), the create form (slug + name + JSON weight map), the
|
|
14651
|
+
// per-query pin manager, and an optional per-set metrics rollup. Every
|
|
14652
|
+
// free-text value (name, query, product_id) is _htmlEscape'd before it lands
|
|
14653
|
+
// in raw HTML — the weight-set name + the pin query are the XSS-sensitive
|
|
14654
|
+
// operator strings (see the flow test's payload assertion).
|
|
14655
|
+
function renderAdminSearchRanking(opts) {
|
|
14656
|
+
opts = opts || {};
|
|
14657
|
+
var weights = opts.weights || [];
|
|
14658
|
+
var active = opts.active || null;
|
|
14659
|
+
var banners = "";
|
|
14660
|
+
if (opts.created) banners += "<div class=\"banner banner--ok\">Weight set saved.</div>";
|
|
14661
|
+
if (opts.activated) banners += "<div class=\"banner banner--ok\">Active weight set updated.</div>";
|
|
14662
|
+
if (opts.archived) banners += "<div class=\"banner banner--ok\">Weight set archived.</div>";
|
|
14663
|
+
if (opts.pinned) banners += "<div class=\"banner banner--ok\">Product pinned.</div>";
|
|
14664
|
+
if (opts.unpinned) banners += "<div class=\"banner banner--ok\">Pin removed.</div>";
|
|
14665
|
+
if (opts.notice) banners += "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>";
|
|
14666
|
+
|
|
14667
|
+
var rows = weights.map(function (w) {
|
|
14668
|
+
var isArchived = w.archived_at != null;
|
|
14669
|
+
var isActive = w.active === true;
|
|
14670
|
+
var weightStr;
|
|
14671
|
+
try { weightStr = JSON.stringify(w.weights || {}); } catch (_e) { weightStr = "{}"; }
|
|
14672
|
+
var actions = "";
|
|
14673
|
+
if (!isArchived) {
|
|
14674
|
+
if (!isActive) {
|
|
14675
|
+
actions += "<form method=\"post\" action=\"/admin/search-ranking/weights/" + _htmlEscape(encodeURIComponent(w.slug)) + "/activate\" class=\"form-inline\">" +
|
|
14676
|
+
"<button class=\"btn\" type=\"submit\">Make active</button></form> ";
|
|
14677
|
+
}
|
|
14678
|
+
actions += "<form method=\"post\" action=\"/admin/search-ranking/weights/" + _htmlEscape(encodeURIComponent(w.slug)) + "/archive\" class=\"form-inline\">" +
|
|
14679
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>";
|
|
14680
|
+
}
|
|
14681
|
+
return "<tr>" +
|
|
14682
|
+
"<td><code class=\"order-id\">" + _htmlEscape(w.slug) + "</code></td>" +
|
|
14683
|
+
"<td>" + _htmlEscape(w.name) + "</td>" +
|
|
14684
|
+
"<td><code>" + _htmlEscape(weightStr) + "</code></td>" +
|
|
14685
|
+
"<td><span class=\"status-pill " + (isArchived ? "cancelled" : (isActive ? "paid" : "")) + "\">" +
|
|
14686
|
+
(isArchived ? "archived" : (isActive ? "active" : "inactive")) + "</span></td>" +
|
|
14687
|
+
"<td><div class=\"actions-row\">" + actions + "</div></td>" +
|
|
14688
|
+
"</tr>";
|
|
14689
|
+
}).join("");
|
|
14690
|
+
var table = weights.length
|
|
14691
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Name</th><th scope=\"col\">Weights</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
|
|
14692
|
+
: "<p class=\"empty\">No weight sets yet. Create one below and make it active to rerank storefront search.</p>";
|
|
14693
|
+
|
|
14694
|
+
var createForm =
|
|
14695
|
+
"<div class=\"panel mt mw-40\">" +
|
|
14696
|
+
"<h3 class=\"subhead\">Create a weight set</h3>" +
|
|
14697
|
+
"<p class=\"meta\">A weight set maps result signals to multipliers. Available signals on storefront results: <code>in_stock</code> (1 when any variant is in stock), <code>price_minor</code>. Score = sum(weight × signal); results sort by score descending.</p>" +
|
|
14698
|
+
"<form method=\"post\" action=\"/admin/search-ranking/weights\">" +
|
|
14699
|
+
_setupField("Slug", "slug", "", "text", "Lowercase, e.g. in-stock-first — a stable id.", " maxlength=\"64\" required") +
|
|
14700
|
+
_setupField("Name", "name", "", "text", "A human label for the console.", " maxlength=\"200\" required") +
|
|
14701
|
+
"<label class=\"form-field\"><span>Weights (JSON)</span><textarea name=\"weights\" rows=\"4\" required>{"in_stock":1}</textarea><small>A flat JSON object of signal → number, e.g. {"in_stock":1,"price_minor":-0.0001}.</small></label>" +
|
|
14702
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create weight set</button></div>" +
|
|
14703
|
+
"</form>" +
|
|
14704
|
+
"</div>";
|
|
14705
|
+
|
|
14706
|
+
// Per-query pin manager.
|
|
14707
|
+
var pinsQuery = opts.pins_query;
|
|
14708
|
+
var pins = opts.pins || [];
|
|
14709
|
+
var pinRows = pins.map(function (p) {
|
|
14710
|
+
return "<tr>" +
|
|
14711
|
+
"<td><code>" + _htmlEscape(p.product_id) + "</code></td>" +
|
|
14712
|
+
"<td>" + _htmlEscape(String(p.position)) + "</td>" +
|
|
14713
|
+
"<td><form method=\"post\" action=\"/admin/search-ranking/pins/delete\" class=\"form-inline\">" +
|
|
14714
|
+
"<input type=\"hidden\" name=\"query\" value=\"" + _htmlEscape(pinsQuery || "") + "\">" +
|
|
14715
|
+
"<input type=\"hidden\" name=\"product_id\" value=\"" + _htmlEscape(p.product_id) + "\">" +
|
|
14716
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Unpin</button></form></td>" +
|
|
14717
|
+
"</tr>";
|
|
14718
|
+
}).join("");
|
|
14719
|
+
var pinLookupForm =
|
|
14720
|
+
"<form method=\"get\" action=\"/admin/search-ranking\" class=\"form-inline\">" +
|
|
14721
|
+
"<input type=\"text\" name=\"pins_query\" value=\"" + _htmlEscape(pinsQuery || "") + "\" placeholder=\"search query\" maxlength=\"500\">" +
|
|
14722
|
+
"<button class=\"btn\" type=\"submit\">View pins</button>" +
|
|
14723
|
+
"</form>";
|
|
14724
|
+
var pinTable = pinsQuery
|
|
14725
|
+
? (pins.length
|
|
14726
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Product id</th><th scope=\"col\">Position</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + pinRows + "</tbody></table></div>"
|
|
14727
|
+
: "<p class=\"empty\">No pins for “" + _htmlEscape(pinsQuery) + "” yet.</p>")
|
|
14728
|
+
: "";
|
|
14729
|
+
var pinCreateForm = pinsQuery
|
|
14730
|
+
? "<div class=\"panel mt mw-40\"><h3 class=\"subhead\">Pin a product</h3>" +
|
|
14731
|
+
"<form method=\"post\" action=\"/admin/search-ranking/pins\">" +
|
|
14732
|
+
"<input type=\"hidden\" name=\"query\" value=\"" + _htmlEscape(pinsQuery) + "\">" +
|
|
14733
|
+
_setupField("Product id", "product_id", "", "text", "The product id (or upstream SKU) to pin.", " maxlength=\"128\" required") +
|
|
14734
|
+
_setupField("Position", "position", "1", "number", "1 = top of the results for this query.", " min=\"1\" max=\"1000\" required") +
|
|
14735
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Pin product</button></div>" +
|
|
14736
|
+
"</form></div>"
|
|
14737
|
+
: "";
|
|
14738
|
+
var pinsSection =
|
|
14739
|
+
"<div class=\"panel mt mw-40\"><h3 class=\"subhead\">Per-query pins</h3>" +
|
|
14740
|
+
"<p class=\"meta\">Pin specific products to fixed positions for a search query. Pinned products lead the results regardless of their weighted score.</p>" +
|
|
14741
|
+
pinLookupForm + "</div>" + pinTable + pinCreateForm;
|
|
14742
|
+
|
|
14743
|
+
// Optional metrics rollup.
|
|
14744
|
+
var metrics = opts.metrics;
|
|
14745
|
+
var metricsSection = "";
|
|
14746
|
+
if (metrics) {
|
|
14747
|
+
function _ratio(r) { return r == null ? "—" : (Math.round(r * 10000) / 100) + "%"; }
|
|
14748
|
+
metricsSection =
|
|
14749
|
+
"<div class=\"panel mt mw-40\"><h3 class=\"subhead\">Metrics — " + _htmlEscape(metrics.weights_slug) + "</h3>" +
|
|
14750
|
+
"<dl class=\"totals-list\">" +
|
|
14751
|
+
"<dt>Impressions</dt><dd>" + _htmlEscape(String(metrics.impressions)) + "</dd>" +
|
|
14752
|
+
"<dt>Clicks</dt><dd>" + _htmlEscape(String(metrics.clicks)) + "</dd>" +
|
|
14753
|
+
"<dt>Purchases</dt><dd>" + _htmlEscape(String(metrics.purchases)) + "</dd>" +
|
|
14754
|
+
"<dt>CTR</dt><dd>" + _ratio(metrics.ctr) + "</dd>" +
|
|
14755
|
+
"<dt>Conversion</dt><dd>" + _ratio(metrics.conversion_rate) + "</dd>" +
|
|
14756
|
+
"<dt>Click→purchase</dt><dd>" + _ratio(metrics.click_to_purchase) + "</dd>" +
|
|
14757
|
+
"</dl></div>";
|
|
14758
|
+
}
|
|
14759
|
+
|
|
14760
|
+
var activeLine = active
|
|
14761
|
+
? "<p class=\"meta\">Active weight set: <strong>" + _htmlEscape(active.name) + "</strong> (<code>" + _htmlEscape(active.slug) + "</code>).</p>"
|
|
14762
|
+
: "<p class=\"meta\">No active weight set — storefront search uses its default order.</p>";
|
|
14763
|
+
|
|
14764
|
+
var bodyHtml = "<section><h2>Search ranking</h2>" + banners + activeLine + table + createForm + pinsSection + metricsSection + "</section>";
|
|
14765
|
+
return _renderAdminShell(opts.shop_name, "Search ranking", bodyHtml, "search-ranking", opts.nav_available);
|
|
14766
|
+
}
|
|
14767
|
+
|
|
14768
|
+
// Trust-badge console — the badge list (placements + status + edit / archive
|
|
14769
|
+
// actions) and the create form (svg payload OR image url, link, placements,
|
|
14770
|
+
// schedule, priority, alt text). The raw svg_payload is NEVER echoed into an
|
|
14771
|
+
// admin attribute — it rides only inside a <textarea> on the edit screen
|
|
14772
|
+
// (which escapes < and &). title + alt_text are _htmlEscape'd in the list.
|
|
14773
|
+
function renderAdminTrustBadges(opts) {
|
|
14774
|
+
opts = opts || {};
|
|
14775
|
+
var badges = opts.badges || [];
|
|
14776
|
+
var banners = "";
|
|
14777
|
+
if (opts.created) banners += "<div class=\"banner banner--ok\">Badge saved.</div>";
|
|
14778
|
+
if (opts.archived) banners += "<div class=\"banner banner--ok\">Badge archived.</div>";
|
|
14779
|
+
if (opts.notice) banners += "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>";
|
|
14780
|
+
|
|
14781
|
+
var rows = badges.map(function (bd) {
|
|
14782
|
+
var isArchived = bd.archived_at != null;
|
|
14783
|
+
var kind = bd.svg_payload ? "SVG" : "image";
|
|
14784
|
+
var placements = Array.isArray(bd.placements) ? bd.placements.join(", ") : "";
|
|
14785
|
+
return "<tr>" +
|
|
14786
|
+
"<td><code class=\"order-id\">" + _htmlEscape(bd.slug) + "</code></td>" +
|
|
14787
|
+
"<td>" + _htmlEscape(bd.title) + "</td>" +
|
|
14788
|
+
"<td>" + _htmlEscape(kind) + "</td>" +
|
|
14789
|
+
"<td>" + _htmlEscape(placements) + "</td>" +
|
|
14790
|
+
"<td>" + _htmlEscape(String(bd.priority)) + "</td>" +
|
|
14791
|
+
"<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
|
|
14792
|
+
"<td><div class=\"actions-row\">" +
|
|
14793
|
+
(isArchived ? "" :
|
|
14794
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/trust-badges/" + _htmlEscape(encodeURIComponent(bd.slug)) + "\">Edit</a> " +
|
|
14795
|
+
"<form method=\"post\" action=\"/admin/trust-badges/" + _htmlEscape(encodeURIComponent(bd.slug)) + "/archive\" class=\"form-inline\">" +
|
|
14796
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
|
|
14797
|
+
"</div></td>" +
|
|
14798
|
+
"</tr>";
|
|
14799
|
+
}).join("");
|
|
14800
|
+
var table = badges.length
|
|
14801
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Title</th><th scope=\"col\">Kind</th><th scope=\"col\">Placements</th><th scope=\"col\">Priority</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
|
|
14802
|
+
: "<p class=\"empty\">No trust badges yet. Create one below.</p>";
|
|
14803
|
+
|
|
14804
|
+
var placementChecks = trustBadgesModule.ALLOWED_PLACEMENTS.map(function (pl) {
|
|
14805
|
+
var live = pl === "checkout" || pl === "order_confirmation";
|
|
14806
|
+
return "<label class=\"kv\"><input type=\"checkbox\" name=\"placements\" value=\"" + pl + "\"" + (live ? " checked" : "") + "> " +
|
|
14807
|
+
_htmlEscape(pl) + (live ? "" : " (not yet rendered)") + "</label>";
|
|
14808
|
+
}).join("");
|
|
14809
|
+
|
|
14810
|
+
var createForm =
|
|
14811
|
+
"<div class=\"panel mt mw-40\">" +
|
|
14812
|
+
"<h3 class=\"subhead\">Create a trust badge</h3>" +
|
|
14813
|
+
"<p class=\"meta\">Supply EITHER an inline SVG payload OR a hosted https:// image URL (not both). Badges render today at the <strong>checkout</strong> and <strong>order confirmation</strong> placements; the other placements are accepted now and will render in a later update. SVG is sanitized on save (no script / event handlers).</p>" +
|
|
14814
|
+
"<form method=\"post\" action=\"/admin/trust-badges\">" +
|
|
14815
|
+
_setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — a stable id.", " maxlength=\"80\" required") +
|
|
14816
|
+
_setupField("Title", "title", "", "text", "Shown as the badge's hover title.", " maxlength=\"200\" required") +
|
|
14817
|
+
_setupField("Alt text", "alt_text", "", "text", "Describes the badge for assistive tech.", " maxlength=\"300\" required") +
|
|
14818
|
+
"<label class=\"form-field\"><span>SVG payload (optional)</span><textarea name=\"svg_payload\" rows=\"4\" placeholder=\"<svg ...>...</svg>\"></textarea><small>Inline SVG — sanitized on save. Leave blank to use an image URL instead.</small></label>" +
|
|
14819
|
+
_setupField("Image URL (optional)", "image_url", "", "text", "https:// or a /-rooted path. Leave blank to use SVG instead.", " maxlength=\"2048\"") +
|
|
14820
|
+
_setupField("Link URL (optional)", "link_url", "", "text", "https:// or a /-rooted path the badge links to.", " maxlength=\"2048\"") +
|
|
14821
|
+
"<fieldset class=\"form-field\"><legend>Placements</legend>" + placementChecks + "</fieldset>" +
|
|
14822
|
+
_setupField("Priority", "priority", "0", "number", "Higher shows first within a placement.", " min=\"0\" max=\"1000000\"") +
|
|
14823
|
+
"<label class=\"form-field\"><span>Starts at (optional)</span><input type=\"datetime-local\" name=\"starts_at\"></label>" +
|
|
14824
|
+
"<label class=\"form-field\"><span>Expires at (optional)</span><input type=\"datetime-local\" name=\"expires_at\"></label>" +
|
|
14825
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create badge</button></div>" +
|
|
14826
|
+
"</form>" +
|
|
14827
|
+
"</div>";
|
|
14828
|
+
|
|
14829
|
+
var bodyHtml = "<section><h2>Trust badges</h2>" + banners + table + createForm + "</section>";
|
|
14830
|
+
return _renderAdminShell(opts.shop_name, "Trust badges", bodyHtml, "trust-badges", opts.nav_available);
|
|
14831
|
+
}
|
|
14832
|
+
|
|
14833
|
+
// Trust-badge detail/edit screen. The svg_payload rides ONLY inside a
|
|
14834
|
+
// <textarea> (escaped) — never an attribute — so a hostile-but-sanitized
|
|
14835
|
+
// payload can't break out. Hidden presence markers let a partial edit avoid
|
|
14836
|
+
// clearing unrelated columns.
|
|
14837
|
+
function renderAdminTrustBadge(opts) {
|
|
14838
|
+
opts = opts || {};
|
|
14839
|
+
var bd = opts.badge || {};
|
|
14840
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Badge updated.</div>" : "";
|
|
14841
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
14842
|
+
var enc = _htmlEscape(encodeURIComponent(bd.slug || ""));
|
|
14843
|
+
|
|
14844
|
+
var placementSet = {};
|
|
14845
|
+
(Array.isArray(bd.placements) ? bd.placements : []).forEach(function (p) { placementSet[p] = true; });
|
|
14846
|
+
var placementChecks = trustBadgesModule.ALLOWED_PLACEMENTS.map(function (pl) {
|
|
14847
|
+
return "<label class=\"kv\"><input type=\"checkbox\" name=\"placements\" value=\"" + pl + "\"" + (placementSet[pl] ? " checked" : "") + "> " + _htmlEscape(pl) + "</label>";
|
|
14848
|
+
}).join("");
|
|
14849
|
+
|
|
14850
|
+
var startsVal = _datetimeLocalValue(bd.starts_at);
|
|
14851
|
+
var expiresVal = _datetimeLocalValue(bd.expires_at);
|
|
14852
|
+
|
|
14853
|
+
var body =
|
|
14854
|
+
"<section class=\"mw-40\">" +
|
|
14855
|
+
"<h2>Edit badge — " + _htmlEscape(bd.slug || "") + "</h2>" +
|
|
14856
|
+
updated + notice +
|
|
14857
|
+
"<form method=\"post\" action=\"/admin/trust-badges/" + enc + "/edit\">" +
|
|
14858
|
+
_setupField("Title", "title", bd.title, "text", "", " maxlength=\"200\" required") +
|
|
14859
|
+
_setupField("Alt text", "alt_text", bd.alt_text, "text", "", " maxlength=\"300\" required") +
|
|
14860
|
+
"<input type=\"hidden\" name=\"svg_present\" value=\"1\">" +
|
|
14861
|
+
"<label class=\"form-field\"><span>SVG payload</span><textarea name=\"svg_payload\" rows=\"4\">" + _htmlEscape(bd.svg_payload || "") + "</textarea><small>Sanitized on save. Blank this to switch to an image URL.</small></label>" +
|
|
14862
|
+
"<input type=\"hidden\" name=\"image_present\" value=\"1\">" +
|
|
14863
|
+
_setupField("Image URL", "image_url", bd.image_url || "", "text", "Blank this to switch to an SVG payload.", " maxlength=\"2048\"") +
|
|
14864
|
+
"<input type=\"hidden\" name=\"link_present\" value=\"1\">" +
|
|
14865
|
+
_setupField("Link URL", "link_url", bd.link_url || "", "text", "https:// or a /-rooted path.", " maxlength=\"2048\"") +
|
|
14866
|
+
"<input type=\"hidden\" name=\"placements_present\" value=\"1\">" +
|
|
14867
|
+
"<fieldset class=\"form-field\"><legend>Placements</legend>" + placementChecks + "</fieldset>" +
|
|
14868
|
+
_setupField("Priority", "priority", String(bd.priority == null ? 0 : bd.priority), "number", "", " min=\"0\" max=\"1000000\"") +
|
|
14869
|
+
"<input type=\"hidden\" name=\"starts_present\" value=\"1\">" +
|
|
14870
|
+
"<label class=\"form-field\"><span>Starts at</span><input type=\"datetime-local\" name=\"starts_at\" value=\"" + _htmlEscape(startsVal) + "\"></label>" +
|
|
14871
|
+
"<input type=\"hidden\" name=\"expires_present\" value=\"1\">" +
|
|
14872
|
+
"<label class=\"form-field\"><span>Expires at</span><input type=\"datetime-local\" name=\"expires_at\" value=\"" + _htmlEscape(expiresVal) + "\"></label>" +
|
|
14873
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save badge</button>" +
|
|
14874
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/trust-badges\">Back to badges</a></div>" +
|
|
14875
|
+
"</form>" +
|
|
14876
|
+
"</section>";
|
|
14877
|
+
return _renderAdminShell(opts.shop_name, "Edit badge", body, "trust-badges", opts.nav_available);
|
|
14878
|
+
}
|
|
14879
|
+
|
|
14103
14880
|
// Pre-order campaign console — the list table (status + reserved/available
|
|
14104
14881
|
// counts + release date), the lifecycle actions (launch once the release date
|
|
14105
14882
|
// has arrived, close any time), and the create form. The launch button only
|
|
@@ -15969,4 +16746,5 @@ module.exports = {
|
|
|
15969
16746
|
renderAdminLoyaltyRewards: renderAdminLoyaltyRewards,
|
|
15970
16747
|
renderAdminLoyaltyReward: renderAdminLoyaltyReward,
|
|
15971
16748
|
renderAdminConfirm: renderAdminConfirm,
|
|
16749
|
+
renderAdminAudit: renderAdminAudit,
|
|
15972
16750
|
};
|