@blamejs/blamejs-shop 0.3.52 → 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/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,6 +515,8 @@ 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
518
521
  // Read-only activity log at /admin/audit. Defaults ON — the framework
519
522
  // audit chain is always booted by createApp, so the screen always has a
@@ -528,7 +531,7 @@ function mount(router, deps) {
528
531
  // `reports` is always present in the nav (read-only sales summary needs no
529
532
  // extra dep); its route mounts unconditionally and renders an unconfigured
530
533
  // notice when the salesReports primitive isn't wired.
531
- 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, auditLog: auditLog };
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 };
532
535
 
533
536
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
534
537
 
@@ -5408,6 +5411,298 @@ function mount(router, deps) {
5408
5411
  ));
5409
5412
  }
5410
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
+
5411
5706
  // ---- pre-orders -----------------------------------------------------
5412
5707
  // Campaigns that take reservations against a SKU that isn't released yet.
5413
5708
  // The lifecycle is draft-free: defineCampaign opens an `active` campaign,
@@ -9982,6 +10277,8 @@ var ADMIN_NAV_ITEMS = [
9982
10277
  { key: "dsr", href: "/admin/dsr", label: "Privacy requests", requires: "complianceExport" },
9983
10278
  { key: "exchanges", href: "/admin/exchanges", label: "Exchanges", requires: "orderExchanges" },
9984
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" },
9985
10282
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
9986
10283
  { key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
9987
10284
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
@@ -14086,6 +14383,98 @@ function _announcementPatchFromForm(body) {
14086
14383
  return patch;
14087
14384
  }
14088
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
+
14089
14478
  // Coerce the create form into the shape preorder.defineCampaign expects.
14090
14479
  // Prices are entered + stored in MINOR units (cents) — the form labels say
14091
14480
  // so, and the JSON API uses minor units too. The launch date is a
@@ -14257,6 +14646,237 @@ function renderAdminAnnouncements(opts) {
14257
14646
  return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
14258
14647
  }
14259
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 &times; 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>{&quot;in_stock&quot;:1}</textarea><small>A flat JSON object of signal &rarr; number, e.g. {&quot;in_stock&quot;:1,&quot;price_minor&quot;:-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 &ldquo;" + _htmlEscape(pinsQuery) + "&rdquo; 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&rarr;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=\"&lt;svg ...&gt;...&lt;/svg&gt;\"></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
+
14260
14880
  // Pre-order campaign console — the list table (status + reserved/available
14261
14881
  // counts + release date), the lifecycle actions (launch once the release date
14262
14882
  // has arrived, close any time), and the create form. The launch button only