@blamejs/blamejs-shop 0.3.52 → 0.3.54

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,10 @@ 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 clickAndCollect = deps.clickAndCollect || null; // pickup-locations CRUD + pickup queue console disabled when absent
519
+ var giftOptions = deps.giftOptions || null; // gift-wrap catalog console disabled when absent
520
+ var searchRanking = deps.searchRanking || null; // search-ranking weight-set + pin console disabled when absent
521
+ var trustBadges = deps.trustBadges || null; // trust-badge authoring console disabled when absent
517
522
  var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
518
523
  // Read-only activity log at /admin/audit. Defaults ON — the framework
519
524
  // audit chain is always booted by createApp, so the screen always has a
@@ -528,7 +533,7 @@ function mount(router, deps) {
528
533
  // `reports` is always present in the nav (read-only sales summary needs no
529
534
  // extra dep); its route mounts unconditionally and renders an unconfigured
530
535
  // 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 };
536
+ 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, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog };
532
537
 
533
538
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
534
539
 
@@ -4593,6 +4598,318 @@ function mount(router, deps) {
4593
4598
  ));
4594
4599
  }
4595
4600
 
4601
+ // ---- click-and-collect (pickup locations + the pickup queue) --------
4602
+ //
4603
+ // Two operator surfaces: the pickup-location CRUD (define / archive) and
4604
+ // the front-counter pickup queue per location (the FSM: ready / picked-up
4605
+ // / no-show). The primitive throws PLAIN TypeError (no `.code`) on every
4606
+ // refusal EXCEPT markPickedUp's internal `fsm/illegal-transition` handling,
4607
+ // so the action wrapper maps TypeError → 400/?err=1 — but RESOLVES EXISTENCE
4608
+ // FIRST (getScheduleByOrder / getLocation null → 404) so a not-found isn't
4609
+ // mislabeled a bad-shape 400.
4610
+ if (clickAndCollect) {
4611
+ // Parse the operator's hours textarea (one "Mon 09:00-17:00" line per
4612
+ // weekday) into the hours_json object the primitive validates. An empty
4613
+ // textarea yields {} (the primitive only requires a JSON-serialisable
4614
+ // object). Unparseable lines are skipped so a typo doesn't 500 the save.
4615
+ function _parseHoursJson(raw) {
4616
+ var hours = {};
4617
+ if (typeof raw !== "string" || !raw.trim()) return hours;
4618
+ raw.split(/\r?\n/).forEach(function (line) {
4619
+ var m = line.trim().match(/^([A-Za-z]{3,})\s+(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$/);
4620
+ if (m) hours[m[1].toLowerCase().slice(0, 3)] = { open: m[2], close: m[3] };
4621
+ });
4622
+ return hours;
4623
+ }
4624
+
4625
+ // GET /admin/pickup-locations — the active-location list (the primitive
4626
+ // has no "list all incl archived" verb; archived rows drop out of
4627
+ // availableLocations, which is the operator's working set — re-open an
4628
+ // archived-locations list if operators ask for it).
4629
+ router.get("/admin/pickup-locations", _pageOrApi(true,
4630
+ R(async function (req, res) {
4631
+ var rows = await clickAndCollect.availableLocations({ limit: 200 });
4632
+ _json(res, 200, { rows: rows });
4633
+ }),
4634
+ async function (req, res) {
4635
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4636
+ var rows = await clickAndCollect.availableLocations({ limit: 200 });
4637
+ _sendHtml(res, 200, renderAdminPickupLocations({
4638
+ shop_name: deps.shop_name, nav_available: navAvailable, locations: rows,
4639
+ saved: url && url.searchParams.get("saved"),
4640
+ notice: url && url.searchParams.get("err") ? "That location couldn't be saved — check the fields and try again." : null,
4641
+ }));
4642
+ },
4643
+ ));
4644
+
4645
+ // POST /admin/pickup-locations — definePickupLocation upsert. A bad
4646
+ // shape (missing name / bad capacity / bad address) is a TypeError → 400.
4647
+ router.post("/admin/pickup-locations", _pageOrApi(false,
4648
+ W("pickup.location.define", async function (req, res) {
4649
+ var body = req.body || {};
4650
+ var loc;
4651
+ try { loc = await _definePickupFromBody(body); }
4652
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4653
+ _json(res, 201, loc);
4654
+ return loc;
4655
+ }),
4656
+ async function (req, res) {
4657
+ try { await _definePickupFromBody(req.body || {}); }
4658
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/pickup-locations?err=1"); throw e; }
4659
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".pickup.location.define", outcome: "success", metadata: { code: (req.body || {}).code } });
4660
+ _redirect(res, "/admin/pickup-locations?saved=1");
4661
+ },
4662
+ ));
4663
+
4664
+ function _definePickupFromBody(body) {
4665
+ return clickAndCollect.definePickupLocation({
4666
+ code: body.code,
4667
+ name: body.name,
4668
+ address: { line1: body.line1, city: body.city, country: body.country },
4669
+ hours_json: _parseHoursJson(body.hours),
4670
+ capacity_per_hour: parseInt(body.capacity_per_hour, 10),
4671
+ lead_time_hours: parseInt(body.lead_time_hours, 10),
4672
+ active: !(body.active === "0" || body.active === false),
4673
+ });
4674
+ }
4675
+
4676
+ // POST /admin/pickup-locations/:code/archive — soft-delete. Resolve
4677
+ // existence first (getLocation null → 404) so a typo'd code is a 404, not
4678
+ // a 400/500. The archive verb itself only throws on a malformed code.
4679
+ router.post("/admin/pickup-locations/:code/archive", _pageOrApi(false,
4680
+ W("pickup.location.archive", async function (req, res) {
4681
+ var code = req.params.code;
4682
+ var existing;
4683
+ try { existing = await clickAndCollect.getLocation(code); }
4684
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4685
+ if (!existing) return _problem(res, 404, "pickup-location-not-found");
4686
+ var row = await clickAndCollect.archiveLocation(code);
4687
+ _json(res, 200, row);
4688
+ return row;
4689
+ }),
4690
+ async function (req, res) {
4691
+ var code = req.params.code;
4692
+ var existing;
4693
+ try { existing = await clickAndCollect.getLocation(code); }
4694
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/pickup-locations?err=1"); throw e; }
4695
+ if (!existing) return _redirect(res, "/admin/pickup-locations?err=1");
4696
+ await clickAndCollect.archiveLocation(code);
4697
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".pickup.location.archive", outcome: "success", metadata: { code: code } });
4698
+ _redirect(res, "/admin/pickup-locations?saved=1");
4699
+ },
4700
+ ));
4701
+
4702
+ // GET /admin/pickups — the per-location pickup queue. The primitive has
4703
+ // no all-locations queue verb, so the screen requires a ?location=
4704
+ // selector defaulting to the first active location. A wide window
4705
+ // (now-7d … now+30d) bounds the read.
4706
+ router.get("/admin/pickups", _pageOrApi(true,
4707
+ R(async function (req, res) {
4708
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4709
+ var locs = await clickAndCollect.availableLocations({ limit: 200 });
4710
+ var selected = (url && url.searchParams.get("location")) || (locs[0] && locs[0].code) || null;
4711
+ var status = (url && url.searchParams.get("status")) || null;
4712
+ var rows = selected ? await _pickupQueue(selected, status) : [];
4713
+ _json(res, 200, { rows: rows, location: selected, status: status });
4714
+ }),
4715
+ async function (req, res) {
4716
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4717
+ var locs = await clickAndCollect.availableLocations({ limit: 200 });
4718
+ var selected = (url && url.searchParams.get("location")) || (locs[0] && locs[0].code) || null;
4719
+ var status = (url && url.searchParams.get("status")) || null;
4720
+ var rows = [];
4721
+ if (selected) {
4722
+ try { rows = await _pickupQueue(selected, status); }
4723
+ catch (e) { if (!(e instanceof TypeError)) throw e; rows = []; }
4724
+ }
4725
+ _sendHtml(res, 200, renderAdminPickups({
4726
+ shop_name: deps.shop_name, nav_available: navAvailable,
4727
+ locations: locs, selected: selected, status: status, pickups: rows,
4728
+ moved: url && url.searchParams.get("moved"),
4729
+ notice: url && url.searchParams.get("err") ? "That pickup action couldn't be completed." : null,
4730
+ }));
4731
+ },
4732
+ ));
4733
+
4734
+ function _pickupQueue(locationCode, status) {
4735
+ var now = Date.now();
4736
+ var opts2 = {
4737
+ location_code: locationCode,
4738
+ from: now - b.constants.TIME.days(7),
4739
+ to: now + b.constants.TIME.days(30),
4740
+ };
4741
+ if (status && PICKUP_QUEUE_STATUSES.indexOf(status) !== -1) opts2.status = status;
4742
+ return clickAndCollect.pickupsForLocation(opts2);
4743
+ }
4744
+
4745
+ // The three FSM action routes. Each RESOLVES EXISTENCE FIRST
4746
+ // (getScheduleByOrder null → 404) so a not-found order isn't mislabeled a
4747
+ // 400; a real refusal (wrong status / bad shape) is a TypeError → 400.
4748
+ function _pickupAction(verb, auditEvent, opFn) {
4749
+ router.post("/admin/pickups/:order_id/" + verb, _pageOrApi(false,
4750
+ W("pickup." + auditEvent, async function (req, res) {
4751
+ var orderId = req.params.order_id;
4752
+ var existing;
4753
+ try { existing = await clickAndCollect.getScheduleByOrder(orderId); }
4754
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4755
+ if (!existing) return _problem(res, 404, "pickup-schedule-not-found");
4756
+ var row;
4757
+ try { row = await opFn(orderId, req.body || {}); }
4758
+ catch (e) {
4759
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
4760
+ if (e && e.code === "fsm/illegal-transition") return _problem(res, 409, "conflict", e.message);
4761
+ throw e;
4762
+ }
4763
+ _json(res, 200, row);
4764
+ return row;
4765
+ }),
4766
+ async function (req, res) {
4767
+ var orderId = req.params.order_id;
4768
+ var existing;
4769
+ try { existing = await clickAndCollect.getScheduleByOrder(orderId); }
4770
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/pickups?err=1"); throw e; }
4771
+ if (!existing) return _redirect(res, "/admin/pickups?err=1");
4772
+ var backLoc = "&location=" + encodeURIComponent(existing.location_code);
4773
+ try { await opFn(orderId, req.body || {}); }
4774
+ catch (e) {
4775
+ if (e instanceof TypeError || (e && e.code === "fsm/illegal-transition")) {
4776
+ return _redirect(res, "/admin/pickups?err=1" + backLoc);
4777
+ }
4778
+ throw e;
4779
+ }
4780
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".pickup." + auditEvent, outcome: "success", metadata: { order_id: orderId } });
4781
+ _redirect(res, "/admin/pickups?moved=1" + backLoc);
4782
+ },
4783
+ ));
4784
+ }
4785
+ _pickupAction("ready", "ready", function (orderId) {
4786
+ return clickAndCollect.markReadyForPickup({ order_id: orderId });
4787
+ });
4788
+ _pickupAction("picked-up", "picked_up", function (orderId, body) {
4789
+ return clickAndCollect.markPickedUp({
4790
+ order_id: orderId, picked_up_at: Date.now(),
4791
+ signature: body.signature || undefined,
4792
+ customer_id_proof_kind: body.proof_kind || undefined,
4793
+ });
4794
+ });
4795
+ _pickupAction("no-show", "no_show", function (orderId, body) {
4796
+ return clickAndCollect.markNoShow({ order_id: orderId, reason: body.reason });
4797
+ });
4798
+ }
4799
+
4800
+ // ---- gift wraps -----------------------------------------------------
4801
+ //
4802
+ // The operator-defined gift-wrap catalog: define / update / archive a wrap
4803
+ // option whose wrap_sku is a real catalog variant (so the fee flows through
4804
+ // inventory + pricing). defineWrap / updateWrap / archiveWrap throw PLAIN
4805
+ // TypeError on a bad shape / unknown sku / not-found → 400; update/archive
4806
+ // resolve existence first (getWrap null → 404) for parity.
4807
+ if (giftOptions) {
4808
+ router.get("/admin/gift-wraps", _pageOrApi(true,
4809
+ R(async function (req, res) {
4810
+ var rows = await giftOptions.listWraps({});
4811
+ _json(res, 200, { rows: rows });
4812
+ }),
4813
+ async function (req, res) {
4814
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4815
+ var rows = await giftOptions.listWraps({});
4816
+ _sendHtml(res, 200, renderAdminGiftWraps({
4817
+ shop_name: deps.shop_name, nav_available: navAvailable, wraps: rows,
4818
+ saved: url && url.searchParams.get("saved"),
4819
+ notice: url && url.searchParams.get("err") ? "That wrap couldn't be saved — the SKU must be a real catalog variant." : null,
4820
+ }));
4821
+ },
4822
+ ));
4823
+
4824
+ router.post("/admin/gift-wraps", _pageOrApi(false,
4825
+ W("gift.wrap.define", async function (req, res) {
4826
+ var wrap;
4827
+ try { wrap = await _defineWrapFromBody(req.body || {}); }
4828
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4829
+ _json(res, 201, wrap);
4830
+ return wrap;
4831
+ }),
4832
+ async function (req, res) {
4833
+ try { await _defineWrapFromBody(req.body || {}); }
4834
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/gift-wraps?err=1"); throw e; }
4835
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".gift.wrap.define", outcome: "success", metadata: { wrap_sku: (req.body || {}).wrap_sku } });
4836
+ _redirect(res, "/admin/gift-wraps?saved=1");
4837
+ },
4838
+ ));
4839
+
4840
+ function _defineWrapFromBody(body) {
4841
+ return giftOptions.defineWrap({
4842
+ wrap_sku: body.wrap_sku,
4843
+ title: body.title,
4844
+ fee_minor: parseInt(body.fee_minor, 10),
4845
+ image_url: body.image_url ? body.image_url : undefined,
4846
+ max_per_order: body.max_per_order ? parseInt(body.max_per_order, 10) : undefined,
4847
+ active: !(body.active === "0" || body.active === false),
4848
+ });
4849
+ }
4850
+
4851
+ router.post("/admin/gift-wraps/:wrap_sku/update", _pageOrApi(false,
4852
+ W("gift.wrap.update", async function (req, res) {
4853
+ var sku = req.params.wrap_sku;
4854
+ var existing;
4855
+ try { existing = await giftOptions.getWrap(sku); }
4856
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4857
+ if (!existing) return _problem(res, 404, "gift-wrap-not-found");
4858
+ var wrap;
4859
+ try { wrap = await giftOptions.updateWrap(sku, _wrapPatchFromBody(req.body || {})); }
4860
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4861
+ _json(res, 200, wrap);
4862
+ return wrap;
4863
+ }),
4864
+ async function (req, res) {
4865
+ var sku = req.params.wrap_sku;
4866
+ var existing;
4867
+ try { existing = await giftOptions.getWrap(sku); }
4868
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/gift-wraps?err=1"); throw e; }
4869
+ if (!existing) return _redirect(res, "/admin/gift-wraps?err=1");
4870
+ try { await giftOptions.updateWrap(sku, _wrapPatchFromBody(req.body || {})); }
4871
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/gift-wraps?err=1"); throw e; }
4872
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".gift.wrap.update", outcome: "success", metadata: { wrap_sku: sku } });
4873
+ _redirect(res, "/admin/gift-wraps?saved=1");
4874
+ },
4875
+ ));
4876
+
4877
+ // Build the updateWrap patch from the edit form — only the columns the
4878
+ // operator actually changed (a blank field is omitted, never sent as "").
4879
+ function _wrapPatchFromBody(body) {
4880
+ var patch = {};
4881
+ if (body.title != null && body.title !== "") patch.title = body.title;
4882
+ if (body.fee_minor != null && body.fee_minor !== "") patch.fee_minor = parseInt(body.fee_minor, 10);
4883
+ if (body.image_url != null && body.image_url !== "") patch.image_url = body.image_url;
4884
+ if (body.max_per_order != null && body.max_per_order !== "") patch.max_per_order = parseInt(body.max_per_order, 10);
4885
+ if (body.active != null) patch.active = !(body.active === "0" || body.active === false);
4886
+ return patch;
4887
+ }
4888
+
4889
+ router.post("/admin/gift-wraps/:wrap_sku/archive", _pageOrApi(false,
4890
+ W("gift.wrap.archive", async function (req, res) {
4891
+ var sku = req.params.wrap_sku;
4892
+ var existing;
4893
+ try { existing = await giftOptions.getWrap(sku); }
4894
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
4895
+ if (!existing) return _problem(res, 404, "gift-wrap-not-found");
4896
+ var wrap = await giftOptions.archiveWrap(sku);
4897
+ _json(res, 200, wrap);
4898
+ return wrap;
4899
+ }),
4900
+ async function (req, res) {
4901
+ var sku = req.params.wrap_sku;
4902
+ var existing;
4903
+ try { existing = await giftOptions.getWrap(sku); }
4904
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/gift-wraps?err=1"); throw e; }
4905
+ if (!existing) return _redirect(res, "/admin/gift-wraps?err=1");
4906
+ await giftOptions.archiveWrap(sku);
4907
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".gift.wrap.archive", outcome: "success", metadata: { wrap_sku: sku } });
4908
+ _redirect(res, "/admin/gift-wraps?saved=1");
4909
+ },
4910
+ ));
4911
+ }
4912
+
4596
4913
  // ---- config ---------------------------------------------------------
4597
4914
 
4598
4915
  var config = deps.config || null;
@@ -5408,6 +5725,298 @@ function mount(router, deps) {
5408
5725
  ));
5409
5726
  }
5410
5727
 
5728
+ // ---- search ranking -------------------------------------------------
5729
+ // Operator-tunable storefront search ranking: named weight sets (one
5730
+ // active at a time), per-query manual pins, and a per-set metrics rollup.
5731
+ // Content-negotiated like the other consoles (bearer → JSON, browser →
5732
+ // HTML). The weight map is authored as a small JSON object validated
5733
+ // server-side; coded errors (NOT_FOUND / ARCHIVED) map to 404 / 400 before
5734
+ // the TypeError fallthrough, so a bad slug never 500s.
5735
+ if (deps.searchRanking) {
5736
+ var searchRankingApi = deps.searchRanking;
5737
+
5738
+ // Parse the `weights` JSON textarea into the flat signal->number map the
5739
+ // primitive expects. A non-object / malformed JSON surfaces as a TypeError
5740
+ // (caught by the route → _safeNotice 400) so the operator gets a clean
5741
+ // re-render rather than a 500.
5742
+ function _parseWeightsJson(raw) {
5743
+ var s = typeof raw === "string" ? raw.trim() : "";
5744
+ if (!s.length) throw new TypeError("weights must be a JSON object of signal -> number");
5745
+ var parsed;
5746
+ try { parsed = JSON.parse(s); }
5747
+ catch (_e) { throw new TypeError("weights must be valid JSON (a flat object of signal -> number)"); }
5748
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
5749
+ throw new TypeError("weights must be a JSON object, e.g. {\"relevance\":1,\"in_stock\":0.5}");
5750
+ }
5751
+ return parsed;
5752
+ }
5753
+
5754
+ async function _searchRankingListData() {
5755
+ var weights = await searchRankingApi.listWeights({ include_archived: true });
5756
+ var active = await searchRankingApi.activeWeights();
5757
+ return { weights: weights, active: active };
5758
+ }
5759
+
5760
+ router.get("/admin/search-ranking", _pageOrApi(true,
5761
+ R(async function (req, res) {
5762
+ var data = await _searchRankingListData();
5763
+ _json(res, 200, { weights: data.weights, active: data.active ? data.active.slug : null });
5764
+ }),
5765
+ async function (req, res) {
5766
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5767
+ var data = await _searchRankingListData();
5768
+ // Optional pin manager for a query (?pins_query=...).
5769
+ var pinsQuery = url && url.searchParams.get("pins_query");
5770
+ var pins = [];
5771
+ if (pinsQuery) {
5772
+ try { pins = await searchRankingApi.pinsForQuery(pinsQuery); }
5773
+ catch (_e) { pins = []; }
5774
+ }
5775
+ // Optional metrics rollup for a weight set (?metrics_slug=&from=&to=).
5776
+ var metrics = null;
5777
+ var metricsSlug = url && url.searchParams.get("metrics_slug");
5778
+ if (metricsSlug) {
5779
+ var fromMs = _searchRankingMsParam(url && url.searchParams.get("from"), Date.now() - b.constants.TIME.days(30));
5780
+ var toMs = _searchRankingMsParam(url && url.searchParams.get("to"), Date.now());
5781
+ try { metrics = await searchRankingApi.metricsForWeights({ weights_slug: metricsSlug, from: fromMs, to: toMs }); }
5782
+ catch (_e) { metrics = null; }
5783
+ }
5784
+ _sendHtml(res, 200, renderAdminSearchRanking({
5785
+ shop_name: deps.shop_name, nav_available: navAvailable,
5786
+ weights: data.weights, active: data.active,
5787
+ pins_query: pinsQuery, pins: pins, metrics: metrics,
5788
+ created: url && url.searchParams.get("created"),
5789
+ activated: url && url.searchParams.get("activated"),
5790
+ archived: url && url.searchParams.get("archived"),
5791
+ pinned: url && url.searchParams.get("pinned"),
5792
+ unpinned: url && url.searchParams.get("unpinned"),
5793
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
5794
+ }));
5795
+ },
5796
+ ));
5797
+
5798
+ router.post("/admin/search-ranking/weights", _pageOrApi(false,
5799
+ W("search_ranking.define_weights", async function (req, res) {
5800
+ var b2 = req.body || {};
5801
+ var row = await searchRankingApi.defineWeights({ slug: b2.slug, name: b2.name, weights: _parseWeightsJson(b2.weights) });
5802
+ _json(res, 201, row);
5803
+ return { id: row.slug };
5804
+ }),
5805
+ async function (req, res) {
5806
+ var b2 = req.body || {};
5807
+ try {
5808
+ await searchRankingApi.defineWeights({ slug: b2.slug, name: b2.name, weights: _parseWeightsJson(b2.weights) });
5809
+ } catch (e) {
5810
+ var n = _safeNotice(e, "search_ranking.define_weights");
5811
+ var data = await _searchRankingListData();
5812
+ return _sendHtml(res, n.status, renderAdminSearchRanking({
5813
+ shop_name: deps.shop_name, nav_available: navAvailable,
5814
+ weights: data.weights, active: data.active,
5815
+ notice: n.message.replace(/^searchRanking[.:]\s*/, ""),
5816
+ }));
5817
+ }
5818
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_ranking.define_weights", outcome: "success", metadata: { slug: b2.slug } });
5819
+ _redirect(res, "/admin/search-ranking?created=1");
5820
+ },
5821
+ ));
5822
+
5823
+ router.post("/admin/search-ranking/weights/:slug/activate", _pageOrApi(false,
5824
+ W("search_ranking.activate_weights", async function (req, res) {
5825
+ var row;
5826
+ try { row = await searchRankingApi.setActiveWeights(req.params.slug); }
5827
+ catch (e) {
5828
+ if (e && e.code === "SEARCH_WEIGHTS_NOT_FOUND") return _problem(res, 404, "weights-not-found");
5829
+ if (e && e.code === "SEARCH_WEIGHTS_ARCHIVED") return _problem(res, 400, "weights-archived");
5830
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
5831
+ throw e;
5832
+ }
5833
+ _json(res, 200, row);
5834
+ return { id: row.slug };
5835
+ }),
5836
+ async function (req, res) {
5837
+ var slug = req.params.slug;
5838
+ try { await searchRankingApi.setActiveWeights(slug); }
5839
+ catch (e) {
5840
+ // Coded errors (NOT_FOUND / ARCHIVED) are Error with .code, NOT
5841
+ // TypeError — check e.code first, then the TypeError fallthrough.
5842
+ if (!(e && (e.code === "SEARCH_WEIGHTS_NOT_FOUND" || e.code === "SEARCH_WEIGHTS_ARCHIVED") || e instanceof TypeError)) throw e;
5843
+ return _redirect(res, "/admin/search-ranking?err=1");
5844
+ }
5845
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_ranking.activate_weights", outcome: "success", metadata: { slug: slug } });
5846
+ _redirect(res, "/admin/search-ranking?activated=1");
5847
+ },
5848
+ ));
5849
+
5850
+ router.post("/admin/search-ranking/weights/:slug/archive", _pageOrApi(false,
5851
+ W("search_ranking.archive_weights", async function (req, res) {
5852
+ var row;
5853
+ try { row = await searchRankingApi.archiveWeights(req.params.slug); }
5854
+ catch (e) {
5855
+ if (e && e.code === "SEARCH_WEIGHTS_NOT_FOUND") return _problem(res, 404, "weights-not-found");
5856
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
5857
+ throw e;
5858
+ }
5859
+ _json(res, 200, row);
5860
+ return { id: row.slug };
5861
+ }),
5862
+ async function (req, res) {
5863
+ var slug = req.params.slug;
5864
+ try { await searchRankingApi.archiveWeights(slug); }
5865
+ catch (e) { if (!(e && e.code === "SEARCH_WEIGHTS_NOT_FOUND" || e instanceof TypeError)) throw e; return _redirect(res, "/admin/search-ranking?err=1"); }
5866
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_ranking.archive_weights", outcome: "success", metadata: { slug: slug } });
5867
+ _redirect(res, "/admin/search-ranking?archived=1");
5868
+ },
5869
+ ));
5870
+
5871
+ router.post("/admin/search-ranking/pins", _pageOrApi(false,
5872
+ W("search_ranking.pin", async function (req, res) {
5873
+ var b2 = req.body || {};
5874
+ var row = await searchRankingApi.pinProductForQuery({
5875
+ query: b2.query, product_id: b2.product_id, position: _searchRankingIntField(b2.position),
5876
+ });
5877
+ _json(res, 201, row);
5878
+ return { id: (row && row.product_id) || null };
5879
+ }),
5880
+ async function (req, res) {
5881
+ var b2 = req.body || {};
5882
+ try {
5883
+ await searchRankingApi.pinProductForQuery({
5884
+ query: b2.query, product_id: b2.product_id, position: _searchRankingIntField(b2.position),
5885
+ });
5886
+ } catch (e) {
5887
+ if (!(e instanceof TypeError)) throw e;
5888
+ return _redirect(res, "/admin/search-ranking?err=1" + (b2.query ? "&pins_query=" + encodeURIComponent(b2.query) : ""));
5889
+ }
5890
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_ranking.pin", outcome: "success" });
5891
+ _redirect(res, "/admin/search-ranking?pinned=1" + (b2.query ? "&pins_query=" + encodeURIComponent(b2.query) : ""));
5892
+ },
5893
+ ));
5894
+
5895
+ router.post("/admin/search-ranking/pins/delete", _pageOrApi(false,
5896
+ W("search_ranking.unpin", async function (req, res) {
5897
+ var b2 = req.body || {};
5898
+ var result = await searchRankingApi.unpinProduct({ query: b2.query, product_id: b2.product_id });
5899
+ _json(res, 200, result);
5900
+ return { id: b2.product_id || null };
5901
+ }),
5902
+ async function (req, res) {
5903
+ var b2 = req.body || {};
5904
+ try { await searchRankingApi.unpinProduct({ query: b2.query, product_id: b2.product_id }); }
5905
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/search-ranking?err=1" + (b2.query ? "&pins_query=" + encodeURIComponent(b2.query) : "")); }
5906
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".search_ranking.unpin", outcome: "success" });
5907
+ _redirect(res, "/admin/search-ranking?unpinned=1" + (b2.query ? "&pins_query=" + encodeURIComponent(b2.query) : ""));
5908
+ },
5909
+ ));
5910
+ }
5911
+
5912
+ // ---- trust badges ---------------------------------------------------
5913
+ // Operator-authored trust/certification badges. Create with EITHER an
5914
+ // inline SVG payload (sanitized at define time through b.guardSvg — a
5915
+ // hostile <script>/onload SVG refuses as a TypeError) OR a hosted https://
5916
+ // image URL, plus placements, schedule, priority, alt text. The raw
5917
+ // svg_payload is NEVER echoed into an admin HTML attribute — it's rendered
5918
+ // inside a <textarea> (which escapes < and &) on the edit screen.
5919
+ if (deps.trustBadges) {
5920
+ var trustBadgesApi = deps.trustBadges;
5921
+
5922
+ async function _trustBadgesList() {
5923
+ return await trustBadgesApi.listBadges({});
5924
+ }
5925
+
5926
+ router.get("/admin/trust-badges", _pageOrApi(true,
5927
+ R(async function (req, res) {
5928
+ _json(res, 200, { rows: await _trustBadgesList() });
5929
+ }),
5930
+ async function (req, res) {
5931
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5932
+ _sendHtml(res, 200, renderAdminTrustBadges({
5933
+ shop_name: deps.shop_name, nav_available: navAvailable,
5934
+ badges: await _trustBadgesList(),
5935
+ created: url && url.searchParams.get("created"),
5936
+ archived: url && url.searchParams.get("archived"),
5937
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the badge." : null,
5938
+ }));
5939
+ },
5940
+ ));
5941
+
5942
+ router.post("/admin/trust-badges", _pageOrApi(false,
5943
+ W("trust_badge.define", async function (req, res) {
5944
+ var row = await trustBadgesApi.defineBadge(_trustBadgeFromForm(req.body || {}));
5945
+ _json(res, 201, row);
5946
+ return { id: row.slug };
5947
+ }),
5948
+ async function (req, res) {
5949
+ try {
5950
+ await trustBadgesApi.defineBadge(_trustBadgeFromForm(req.body || {}));
5951
+ } catch (e) {
5952
+ var n = _safeNotice(e, "trust_badge.define");
5953
+ return _sendHtml(res, n.status, renderAdminTrustBadges({
5954
+ shop_name: deps.shop_name, nav_available: navAvailable,
5955
+ badges: await _trustBadgesList(),
5956
+ notice: n.message.replace(/^trustBadges[.:]\s*/, ""),
5957
+ }));
5958
+ }
5959
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".trust_badge.define", outcome: "success" });
5960
+ _redirect(res, "/admin/trust-badges?created=1");
5961
+ },
5962
+ ));
5963
+
5964
+ router.get("/admin/trust-badges/:slug", _pageOrApi(true,
5965
+ R(async function (req, res) {
5966
+ var row = await trustBadgesApi.getBadge(req.params.slug);
5967
+ if (!row) return _problem(res, 404, "trust-badge-not-found");
5968
+ _json(res, 200, row);
5969
+ }),
5970
+ async function (req, res) {
5971
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5972
+ var row = await trustBadgesApi.getBadge(req.params.slug);
5973
+ if (!row) return _sendHtml(res, 404, renderAdminTrustBadges({
5974
+ shop_name: deps.shop_name, nav_available: navAvailable, badges: [], notice: "Badge not found.",
5975
+ }));
5976
+ _sendHtml(res, 200, renderAdminTrustBadge({
5977
+ shop_name: deps.shop_name, nav_available: navAvailable, badge: row,
5978
+ updated: url && url.searchParams.get("updated"),
5979
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the badge." : null,
5980
+ }));
5981
+ },
5982
+ ));
5983
+
5984
+ router.post("/admin/trust-badges/:slug/edit", _pageOrApi(false,
5985
+ W("trust_badge.update", async function (req, res) {
5986
+ var row;
5987
+ try { row = await trustBadgesApi.updateBadge(req.params.slug, _trustBadgePatchFromForm(req.body || {})); }
5988
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5989
+ _json(res, 200, row);
5990
+ return { id: row.slug };
5991
+ }),
5992
+ async function (req, res) {
5993
+ var slug = req.params.slug;
5994
+ var enc = encodeURIComponent(slug);
5995
+ try { await trustBadgesApi.updateBadge(slug, _trustBadgePatchFromForm(req.body || {})); }
5996
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/trust-badges/" + enc + "?err=1"); }
5997
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".trust_badge.update", outcome: "success", metadata: { slug: slug } });
5998
+ _redirect(res, "/admin/trust-badges/" + enc + "?updated=1");
5999
+ },
6000
+ ));
6001
+
6002
+ router.post("/admin/trust-badges/:slug/archive", _pageOrApi(false,
6003
+ W("trust_badge.archive", async function (req, res) {
6004
+ var row;
6005
+ try { row = await trustBadgesApi.archiveBadge(req.params.slug); }
6006
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6007
+ _json(res, 200, row);
6008
+ return { id: row.slug };
6009
+ }),
6010
+ async function (req, res) {
6011
+ var slug = req.params.slug;
6012
+ try { await trustBadgesApi.archiveBadge(slug); }
6013
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/trust-badges?err=1"); }
6014
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".trust_badge.archive", outcome: "success", metadata: { slug: slug } });
6015
+ _redirect(res, "/admin/trust-badges?archived=1");
6016
+ },
6017
+ ));
6018
+ }
6019
+
5411
6020
  // ---- pre-orders -----------------------------------------------------
5412
6021
  // Campaigns that take reservations against a SKU that isn't released yet.
5413
6022
  // The lifecycle is draft-free: defineCampaign opens an `active` campaign,
@@ -9982,6 +10591,11 @@ var ADMIN_NAV_ITEMS = [
9982
10591
  { key: "dsr", href: "/admin/dsr", label: "Privacy requests", requires: "complianceExport" },
9983
10592
  { key: "exchanges", href: "/admin/exchanges", label: "Exchanges", requires: "orderExchanges" },
9984
10593
  { key: "ratings", href: "/admin/ratings", label: "Ratings", requires: "orderRatings" },
10594
+ { key: "pickup-locations", href: "/admin/pickup-locations", label: "Pickup locations", requires: "clickAndCollect" },
10595
+ { key: "pickups", href: "/admin/pickups", label: "Pickups", requires: "clickAndCollect" },
10596
+ { key: "gift-wraps", href: "/admin/gift-wraps", label: "Gift wraps", requires: "giftOptions" },
10597
+ { key: "search-ranking", href: "/admin/search-ranking", label: "Search ranking", requires: "searchRanking" },
10598
+ { key: "trust-badges", href: "/admin/trust-badges", label: "Trust badges", requires: "trustBadges" },
9985
10599
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
9986
10600
  { key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
9987
10601
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
@@ -12161,6 +12775,9 @@ function renderAdminDsrDetail(opts) {
12161
12775
  // lifecycle order (the terminal closed / rejected don't appear in the open
12162
12776
  // queue, so they're not filter chips).
12163
12777
  var EXCHANGE_STATUS_FILTERS = ["pending", "approved", "shipped", "delivered", "received"];
12778
+ // The pickup-queue status chips the operator can filter by (mirrors the
12779
+ // click-and-collect FSM statuses the pickupsForLocation read accepts).
12780
+ var PICKUP_QUEUE_STATUSES = ["scheduled", "ready", "picked_up", "no_show", "cancelled"];
12164
12781
 
12165
12782
  function _exchangePillClass(status) {
12166
12783
  if (status === "closed") return "paid"; // completed — green
@@ -12315,6 +12932,197 @@ function renderAdminExchange(opts) {
12315
12932
  return _renderAdminShell(opts.shop_name, "Exchange " + String(x.id).slice(0, 8), body, "exchanges", opts.nav_available);
12316
12933
  }
12317
12934
 
12935
+ // ---- click-and-collect render fns -----------------------------------
12936
+
12937
+ // Pickup-locations CRUD screen: the active-location list (each with an
12938
+ // archive form) + a define/upsert form. Every operator free-text field —
12939
+ // name, address parts, hours — is HTML-escaped at the sink (the primitive
12940
+ // validates length/shape, NOT HTML, so escaping here is the XSS guard).
12941
+ function renderAdminPickupLocations(opts) {
12942
+ opts = opts || {};
12943
+ var list = opts.locations || [];
12944
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Pickup location saved.</div>" : "";
12945
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
12946
+
12947
+ var rows = list.map(function (l) {
12948
+ var addr = l.address || {};
12949
+ var addrLine = [addr.line1, addr.city, addr.country].filter(Boolean).map(String).join(", ");
12950
+ return "<tr>" +
12951
+ "<td><code class=\"order-id\">" + _htmlEscape(l.code) + "</code></td>" +
12952
+ "<td>" + _htmlEscape(l.name) + "</td>" +
12953
+ "<td>" + _htmlEscape(addrLine) + "</td>" +
12954
+ "<td>" + _htmlEscape(String(l.capacity_per_hour)) + "/hr</td>" +
12955
+ "<td>" + _htmlEscape(String(l.lead_time_hours)) + "h</td>" +
12956
+ "<td>" +
12957
+ "<form method=\"post\" action=\"/admin/pickup-locations/" + encodeURIComponent(l.code) + "/archive\" class=\"form-inline\">" +
12958
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Archive</button>" +
12959
+ "</form>" +
12960
+ "</td>" +
12961
+ "</tr>";
12962
+ }).join("");
12963
+
12964
+ var table = list.length
12965
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Code</th><th scope=\"col\">Name</th><th scope=\"col\">Address</th><th scope=\"col\">Capacity</th><th scope=\"col\">Lead time</th><th scope=\"col\">Action</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
12966
+ : "<p class=\"empty\">No active pickup locations yet. Add one below.</p>";
12967
+
12968
+ var form =
12969
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Add / update a pickup location</h3>" +
12970
+ "<form method=\"post\" action=\"/admin/pickup-locations\">" +
12971
+ _setupField("Code", "code", "", "text", "Stable identifier (alnum + . _ -). Re-using a code updates that location.", " maxlength=\"64\" required") +
12972
+ _setupField("Name", "name", "", "text", "Shown to customers at checkout.", " maxlength=\"200\" required") +
12973
+ _setupField("Address line 1", "line1", "", "text", "", " maxlength=\"200\" required") +
12974
+ _setupField("City", "city", "", "text", "", " maxlength=\"100\" required") +
12975
+ _setupField("Country", "country", "", "text", "2-letter ISO code (e.g. US).", " maxlength=\"2\" class=\"input-code\" required") +
12976
+ "<label class=\"form-field\"><span>Opening hours</span>" +
12977
+ "<textarea name=\"hours\" rows=\"4\" placeholder=\"mon 09:00-17:00&#10;tue 09:00-17:00\"></textarea>" +
12978
+ "<small>One line per day: <code>mon 09:00-17:00</code>. Optional.</small>" +
12979
+ "</label>" +
12980
+ _setupField("Capacity per hour", "capacity_per_hour", "", "number", "Max concurrent pickups in a one-hour window.", " min=\"1\" required") +
12981
+ _setupField("Lead time (hours)", "lead_time_hours", "", "number", "Minimum gap before the earliest bookable slot. 0 allows same-hour.", " min=\"0\" required") +
12982
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Save location</button></div>" +
12983
+ "</form>" +
12984
+ "</div>";
12985
+
12986
+ var body = "<section><h2>Pickup locations</h2>" + saved + notice + table + form + "</section>";
12987
+ return _renderAdminShell(opts.shop_name, "Pickup locations", body, "pickup-locations", opts.nav_available);
12988
+ }
12989
+
12990
+ // Pickup queue for one location: a location selector + status chips, the
12991
+ // scheduled/ready rows, and the FSM action forms (ready / picked-up /
12992
+ // no-show) legal from each row's status. The no_show reason is operator-
12993
+ // authored free text — escaped at the sink.
12994
+ function renderAdminPickups(opts) {
12995
+ opts = opts || {};
12996
+ var locs = opts.locations || [];
12997
+ var list = opts.pickups || [];
12998
+ var selected = opts.selected || null;
12999
+ var status = opts.status || null;
13000
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Pickup updated.</div>" : "";
13001
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
13002
+
13003
+ var selector = "";
13004
+ if (locs.length) {
13005
+ var opts2 = locs.map(function (l) {
13006
+ return "<option value=\"" + _htmlEscape(l.code) + "\"" + (l.code === selected ? " selected" : "") + ">" +
13007
+ _htmlEscape(l.name) + " (" + _htmlEscape(l.code) + ")</option>";
13008
+ }).join("");
13009
+ selector =
13010
+ "<form method=\"get\" action=\"/admin/pickups\" class=\"form-inline\">" +
13011
+ "<label class=\"form-field\"><span>Location</span><select name=\"location\" onchange=\"this.form.submit()\">" + opts2 + "</select></label>" +
13012
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">View</button>" +
13013
+ "</form>";
13014
+ }
13015
+
13016
+ var statusChip = function (s, label) {
13017
+ var href = "/admin/pickups?location=" + encodeURIComponent(selected || "") + (s ? "&status=" + encodeURIComponent(s) : "");
13018
+ var on = (s == null && status == null) || s === status;
13019
+ return "<a class=\"chip" + (on ? " chip--on" : "") + "\" href=\"" + href + "\">" + _htmlEscape(label) + "</a>";
13020
+ };
13021
+ var chips = selected
13022
+ ? "<div class=\"order-filters\">" + statusChip(null, "all") +
13023
+ PICKUP_QUEUE_STATUSES.map(function (s) { return statusChip(s, s); }).join("") + "</div>"
13024
+ : "";
13025
+
13026
+ function _pickupActions(p) {
13027
+ var blocks = [];
13028
+ if (p.status === "scheduled") {
13029
+ blocks.push(
13030
+ "<form method=\"post\" action=\"/admin/pickups/" + encodeURIComponent(p.order_id) + "/ready\" class=\"form-inline\">" +
13031
+ "<button class=\"btn btn--sm\" type=\"submit\">Mark ready</button></form>");
13032
+ }
13033
+ if (p.status === "ready") {
13034
+ blocks.push(
13035
+ "<form method=\"post\" action=\"/admin/pickups/" + encodeURIComponent(p.order_id) + "/picked-up\" class=\"return-action\">" +
13036
+ "<input type=\"hidden\" name=\"proof_kind\" value=\"store_credential\">" +
13037
+ "<button class=\"btn btn--sm\" type=\"submit\">Mark picked up</button></form>");
13038
+ }
13039
+ if (p.status === "scheduled" || p.status === "ready") {
13040
+ blocks.push(
13041
+ "<form method=\"post\" action=\"/admin/pickups/" + encodeURIComponent(p.order_id) + "/no-show\" class=\"return-action\">" +
13042
+ _setupField("No-show reason", "reason", "", "text", "Shown in the escalation queue.", " maxlength=\"280\" required") +
13043
+ "<button class=\"btn btn--danger btn--sm\" type=\"submit\">No-show</button></form>");
13044
+ }
13045
+ return blocks.length ? blocks.join("") : "<span class=\"meta\">—</span>";
13046
+ }
13047
+
13048
+ var rows = list.map(function (p) {
13049
+ return "<tr>" +
13050
+ "<td><a class=\"order-id\" href=\"/admin/orders/" + _htmlEscape(p.order_id) + "\">" + _htmlEscape(String(p.order_id).slice(0, 8)) + "</a></td>" +
13051
+ "<td><span class=\"status-pill\">" + _htmlEscape(p.status) + "</span></td>" +
13052
+ "<td>" + _htmlEscape(_fmtDate(p.scheduled_window_start)) + "</td>" +
13053
+ "<td>" + (p.picked_up_at ? _htmlEscape(_fmtDate(p.picked_up_at)) : "<span class=\"meta\">—</span>") + "</td>" +
13054
+ "<td>" + _pickupActions(p) + "</td>" +
13055
+ "</tr>";
13056
+ }).join("");
13057
+
13058
+ var table = !selected
13059
+ ? "<p class=\"empty\">Add a pickup location first.</p>"
13060
+ : (list.length
13061
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Order</th><th scope=\"col\">Status</th><th scope=\"col\">Window</th><th scope=\"col\">Picked up</th><th scope=\"col\">Action</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
13062
+ : "<p class=\"empty\">No pickups in this window for this location.</p>");
13063
+
13064
+ var body = "<section><h2>Pickups</h2>" + moved + notice + selector + chips + table + "</section>";
13065
+ return _renderAdminShell(opts.shop_name, "Pickups", body, "pickups", opts.nav_available);
13066
+ }
13067
+
13068
+ // ---- gift-wraps render fn -------------------------------------------
13069
+
13070
+ // Gift-wrap catalog screen: the list (incl. archived) with an inline update
13071
+ // form + archive action per row, and a define form. The wrap title is
13072
+ // operator free text — escaped at the sink (the primitive bounds length +
13073
+ // refuses control/zero-width bytes, but not HTML). image_url is escaped as
13074
+ // an attribute value.
13075
+ function renderAdminGiftWraps(opts) {
13076
+ opts = opts || {};
13077
+ var list = opts.wraps || [];
13078
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Gift wrap saved.</div>" : "";
13079
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
13080
+
13081
+ var rows = list.map(function (w) {
13082
+ var stateLabel = w.archived_at ? "archived" : (w.active ? "active" : "inactive");
13083
+ var img = w.image_url
13084
+ ? "<img src=\"" + _htmlEscape(String(w.image_url)) + "\" alt=\"\" width=\"32\" height=\"32\" style=\"object-fit:cover\">"
13085
+ : "<span class=\"meta\">—</span>";
13086
+ var updateForm =
13087
+ "<form method=\"post\" action=\"/admin/gift-wraps/" + encodeURIComponent(w.wrap_sku) + "/update\" class=\"form-inline\">" +
13088
+ "<input type=\"text\" name=\"title\" value=\"" + _htmlEscape(w.title) + "\" maxlength=\"200\" aria-label=\"Title\">" +
13089
+ "<input type=\"number\" name=\"fee_minor\" value=\"" + _htmlEscape(String(w.fee_minor)) + "\" min=\"0\" aria-label=\"Fee (minor units)\">" +
13090
+ "<button class=\"btn btn--sm\" type=\"submit\">Update</button>" +
13091
+ "</form>";
13092
+ var archiveForm = w.archived_at ? "" :
13093
+ "<form method=\"post\" action=\"/admin/gift-wraps/" + encodeURIComponent(w.wrap_sku) + "/archive\" class=\"form-inline\">" +
13094
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Archive</button>" +
13095
+ "</form>";
13096
+ return "<tr>" +
13097
+ "<td><code class=\"order-id\">" + _htmlEscape(w.wrap_sku) + "</code></td>" +
13098
+ "<td>" + img + "</td>" +
13099
+ "<td>" + _htmlEscape(w.title) + "</td>" +
13100
+ "<td>" + _htmlEscape(String(w.fee_minor)) + "</td>" +
13101
+ "<td><span class=\"status-pill\">" + _htmlEscape(stateLabel) + "</span></td>" +
13102
+ "<td>" + updateForm + archiveForm + "</td>" +
13103
+ "</tr>";
13104
+ }).join("");
13105
+
13106
+ var table = list.length
13107
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\">Image</th><th scope=\"col\">Title</th><th scope=\"col\">Fee</th><th scope=\"col\">State</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + rows + "</tbody></table></div>"
13108
+ : "<p class=\"empty\">No gift wraps yet. Define one below — the SKU must be a real catalog variant.</p>";
13109
+
13110
+ var form =
13111
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Define a gift wrap</h3>" +
13112
+ "<form method=\"post\" action=\"/admin/gift-wraps\">" +
13113
+ _setupField("Wrap SKU", "wrap_sku", "", "text", "Must be a real catalog variant SKU — the fee flows through inventory + pricing.", " maxlength=\"128\" required") +
13114
+ _setupField("Title", "title", "", "text", "Shown to customers at checkout.", " maxlength=\"200\" required") +
13115
+ _setupField("Fee (minor units)", "fee_minor", "", "number", "Set this equal to the variant's catalog price; the fee is charged as a real cart line.", " min=\"0\" required") +
13116
+ _setupField("Image URL", "image_url", "", "text", "Optional. https:// or /absolute path.", " maxlength=\"2048\"") +
13117
+ _setupField("Max per order", "max_per_order", "", "number", "Optional cap on wraps per order.", " min=\"1\"") +
13118
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Save wrap</button></div>" +
13119
+ "</form>" +
13120
+ "</div>";
13121
+
13122
+ var body = "<section><h2>Gift wraps</h2>" + saved + notice + table + form + "</section>";
13123
+ return _renderAdminShell(opts.shop_name, "Gift wraps", body, "gift-wraps", opts.nav_available);
13124
+ }
13125
+
12318
13126
  // One rating card for the moderation queue / recent list. Shows the three
12319
13127
  // scores, the (escaped) comment, the flag state + reason, and the operator
12320
13128
  // reply — plus the flag / reply action forms.
@@ -14086,6 +14894,98 @@ function _announcementPatchFromForm(body) {
14086
14894
  return patch;
14087
14895
  }
14088
14896
 
14897
+ // ---- search-ranking form coercion -----------------------------------
14898
+
14899
+ // `?from`/`?to` ms-epoch query params for the metrics rollup → integer, or a
14900
+ // supplied fallback when blank/unparseable.
14901
+ function _searchRankingMsParam(v, fallback) {
14902
+ if (v == null || v === "") return fallback;
14903
+ var n = parseInt(v, 10);
14904
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
14905
+ }
14906
+
14907
+ // A position form field → integer (the primitive validates the 1..MAX range +
14908
+ // throws a TypeError on a bad shape). A blank/non-numeric value is forwarded
14909
+ // as NaN so the primitive's validator (not this coercion) reports it.
14910
+ function _searchRankingIntField(v) {
14911
+ if (v == null || v === "") return NaN;
14912
+ var n = parseInt(v, 10);
14913
+ return Number.isFinite(n) ? n : NaN;
14914
+ }
14915
+
14916
+ // ---- trust-badge form coercion --------------------------------------
14917
+
14918
+ // Read the placements multi-checkbox into a non-empty array. An HTML form
14919
+ // submits repeated `placements` fields as an array (when >1 checked) or a
14920
+ // single string (when exactly 1 checked). The primitive validates membership
14921
+ // + the 1..6 count + dedup, throwing a TypeError on a bad shape.
14922
+ function _trustBadgePlacements(raw) {
14923
+ if (Array.isArray(raw)) return raw;
14924
+ if (typeof raw === "string" && raw.length) return [raw];
14925
+ return []; // empty → the primitive's _placements throws "non-empty array"
14926
+ }
14927
+
14928
+ // Coerce the create form into the shape trustBadges.defineBadge expects.
14929
+ // EITHER svg_payload OR image_url (blank maps to absent so the primitive's
14930
+ // either/or rule applies). The primitive is authoritative on every field
14931
+ // (SVG sanitize via guardSvg, URL via safeUrl, slug/title/alt shape, priority
14932
+ // range, schedule monotonicity) and throws a TypeError on a bad shape, which
14933
+ // both surfaces degrade to a clean 400 / err notice.
14934
+ function _trustBadgeFromForm(body) {
14935
+ body = body || {};
14936
+ var out = {
14937
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
14938
+ title: body.title,
14939
+ alt_text: body.alt_text,
14940
+ placements: _trustBadgePlacements(body.placements),
14941
+ };
14942
+ var svg = typeof body.svg_payload === "string" ? body.svg_payload.trim() : "";
14943
+ var img = typeof body.image_url === "string" ? body.image_url.trim() : "";
14944
+ if (svg) out.svg_payload = svg;
14945
+ if (img) out.image_url = img;
14946
+ var link = typeof body.link_url === "string" ? body.link_url.trim() : "";
14947
+ if (link) out.link_url = link;
14948
+ if (body.priority != null && body.priority !== "") {
14949
+ var p = parseInt(body.priority, 10);
14950
+ out.priority = Number.isFinite(p) ? p : body.priority;
14951
+ }
14952
+ var sa = _epochFromForm(body.starts_at); if (sa != null) out.starts_at = sa;
14953
+ var ea = _epochFromForm(body.expires_at); if (ea != null) out.expires_at = ea;
14954
+ return out;
14955
+ }
14956
+
14957
+ // Coerce the edit form into an updateBadge patch. Each editable column rides
14958
+ // only when its hidden presence marker says so, so a partial edit doesn't
14959
+ // clear an unrelated column. The svg_payload / image_url pair is mutually
14960
+ // exclusive on the resulting row (the primitive enforces it); the edit form
14961
+ // only ever sends the column the badge already uses.
14962
+ function _trustBadgePatchFromForm(body) {
14963
+ body = body || {};
14964
+ var patch = {};
14965
+ if (body.title != null && body.title !== "") patch.title = body.title;
14966
+ if (body.alt_text != null && body.alt_text !== "") patch.alt_text = body.alt_text;
14967
+ if (body.placements_present === "1") patch.placements = _trustBadgePlacements(body.placements);
14968
+ if (body.svg_present === "1") {
14969
+ var svg = typeof body.svg_payload === "string" ? body.svg_payload.trim() : "";
14970
+ patch.svg_payload = svg ? svg : null;
14971
+ }
14972
+ if (body.image_present === "1") {
14973
+ var img = typeof body.image_url === "string" ? body.image_url.trim() : "";
14974
+ patch.image_url = img ? img : null;
14975
+ }
14976
+ if (body.link_present === "1") {
14977
+ var link = typeof body.link_url === "string" ? body.link_url.trim() : "";
14978
+ patch.link_url = link ? link : null;
14979
+ }
14980
+ if (body.priority != null && body.priority !== "") {
14981
+ var p = parseInt(body.priority, 10);
14982
+ patch.priority = Number.isFinite(p) ? p : body.priority;
14983
+ }
14984
+ if (body.starts_present === "1") patch.starts_at = _epochFromForm(body.starts_at);
14985
+ if (body.expires_present === "1") patch.expires_at = _epochFromForm(body.expires_at);
14986
+ return patch;
14987
+ }
14988
+
14089
14989
  // Coerce the create form into the shape preorder.defineCampaign expects.
14090
14990
  // Prices are entered + stored in MINOR units (cents) — the form labels say
14091
14991
  // so, and the JSON API uses minor units too. The launch date is a
@@ -14257,6 +15157,237 @@ function renderAdminAnnouncements(opts) {
14257
15157
  return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
14258
15158
  }
14259
15159
 
15160
+ // Search-ranking console — the weight-set list (active flag + activate /
15161
+ // archive actions), the create form (slug + name + JSON weight map), the
15162
+ // per-query pin manager, and an optional per-set metrics rollup. Every
15163
+ // free-text value (name, query, product_id) is _htmlEscape'd before it lands
15164
+ // in raw HTML — the weight-set name + the pin query are the XSS-sensitive
15165
+ // operator strings (see the flow test's payload assertion).
15166
+ function renderAdminSearchRanking(opts) {
15167
+ opts = opts || {};
15168
+ var weights = opts.weights || [];
15169
+ var active = opts.active || null;
15170
+ var banners = "";
15171
+ if (opts.created) banners += "<div class=\"banner banner--ok\">Weight set saved.</div>";
15172
+ if (opts.activated) banners += "<div class=\"banner banner--ok\">Active weight set updated.</div>";
15173
+ if (opts.archived) banners += "<div class=\"banner banner--ok\">Weight set archived.</div>";
15174
+ if (opts.pinned) banners += "<div class=\"banner banner--ok\">Product pinned.</div>";
15175
+ if (opts.unpinned) banners += "<div class=\"banner banner--ok\">Pin removed.</div>";
15176
+ if (opts.notice) banners += "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>";
15177
+
15178
+ var rows = weights.map(function (w) {
15179
+ var isArchived = w.archived_at != null;
15180
+ var isActive = w.active === true;
15181
+ var weightStr;
15182
+ try { weightStr = JSON.stringify(w.weights || {}); } catch (_e) { weightStr = "{}"; }
15183
+ var actions = "";
15184
+ if (!isArchived) {
15185
+ if (!isActive) {
15186
+ actions += "<form method=\"post\" action=\"/admin/search-ranking/weights/" + _htmlEscape(encodeURIComponent(w.slug)) + "/activate\" class=\"form-inline\">" +
15187
+ "<button class=\"btn\" type=\"submit\">Make active</button></form> ";
15188
+ }
15189
+ actions += "<form method=\"post\" action=\"/admin/search-ranking/weights/" + _htmlEscape(encodeURIComponent(w.slug)) + "/archive\" class=\"form-inline\">" +
15190
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>";
15191
+ }
15192
+ return "<tr>" +
15193
+ "<td><code class=\"order-id\">" + _htmlEscape(w.slug) + "</code></td>" +
15194
+ "<td>" + _htmlEscape(w.name) + "</td>" +
15195
+ "<td><code>" + _htmlEscape(weightStr) + "</code></td>" +
15196
+ "<td><span class=\"status-pill " + (isArchived ? "cancelled" : (isActive ? "paid" : "")) + "\">" +
15197
+ (isArchived ? "archived" : (isActive ? "active" : "inactive")) + "</span></td>" +
15198
+ "<td><div class=\"actions-row\">" + actions + "</div></td>" +
15199
+ "</tr>";
15200
+ }).join("");
15201
+ var table = weights.length
15202
+ ? "<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>"
15203
+ : "<p class=\"empty\">No weight sets yet. Create one below and make it active to rerank storefront search.</p>";
15204
+
15205
+ var createForm =
15206
+ "<div class=\"panel mt mw-40\">" +
15207
+ "<h3 class=\"subhead\">Create a weight set</h3>" +
15208
+ "<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>" +
15209
+ "<form method=\"post\" action=\"/admin/search-ranking/weights\">" +
15210
+ _setupField("Slug", "slug", "", "text", "Lowercase, e.g. in-stock-first — a stable id.", " maxlength=\"64\" required") +
15211
+ _setupField("Name", "name", "", "text", "A human label for the console.", " maxlength=\"200\" required") +
15212
+ "<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>" +
15213
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create weight set</button></div>" +
15214
+ "</form>" +
15215
+ "</div>";
15216
+
15217
+ // Per-query pin manager.
15218
+ var pinsQuery = opts.pins_query;
15219
+ var pins = opts.pins || [];
15220
+ var pinRows = pins.map(function (p) {
15221
+ return "<tr>" +
15222
+ "<td><code>" + _htmlEscape(p.product_id) + "</code></td>" +
15223
+ "<td>" + _htmlEscape(String(p.position)) + "</td>" +
15224
+ "<td><form method=\"post\" action=\"/admin/search-ranking/pins/delete\" class=\"form-inline\">" +
15225
+ "<input type=\"hidden\" name=\"query\" value=\"" + _htmlEscape(pinsQuery || "") + "\">" +
15226
+ "<input type=\"hidden\" name=\"product_id\" value=\"" + _htmlEscape(p.product_id) + "\">" +
15227
+ "<button class=\"btn btn--danger\" type=\"submit\">Unpin</button></form></td>" +
15228
+ "</tr>";
15229
+ }).join("");
15230
+ var pinLookupForm =
15231
+ "<form method=\"get\" action=\"/admin/search-ranking\" class=\"form-inline\">" +
15232
+ "<input type=\"text\" name=\"pins_query\" value=\"" + _htmlEscape(pinsQuery || "") + "\" placeholder=\"search query\" maxlength=\"500\">" +
15233
+ "<button class=\"btn\" type=\"submit\">View pins</button>" +
15234
+ "</form>";
15235
+ var pinTable = pinsQuery
15236
+ ? (pins.length
15237
+ ? "<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>"
15238
+ : "<p class=\"empty\">No pins for &ldquo;" + _htmlEscape(pinsQuery) + "&rdquo; yet.</p>")
15239
+ : "";
15240
+ var pinCreateForm = pinsQuery
15241
+ ? "<div class=\"panel mt mw-40\"><h3 class=\"subhead\">Pin a product</h3>" +
15242
+ "<form method=\"post\" action=\"/admin/search-ranking/pins\">" +
15243
+ "<input type=\"hidden\" name=\"query\" value=\"" + _htmlEscape(pinsQuery) + "\">" +
15244
+ _setupField("Product id", "product_id", "", "text", "The product id (or upstream SKU) to pin.", " maxlength=\"128\" required") +
15245
+ _setupField("Position", "position", "1", "number", "1 = top of the results for this query.", " min=\"1\" max=\"1000\" required") +
15246
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Pin product</button></div>" +
15247
+ "</form></div>"
15248
+ : "";
15249
+ var pinsSection =
15250
+ "<div class=\"panel mt mw-40\"><h3 class=\"subhead\">Per-query pins</h3>" +
15251
+ "<p class=\"meta\">Pin specific products to fixed positions for a search query. Pinned products lead the results regardless of their weighted score.</p>" +
15252
+ pinLookupForm + "</div>" + pinTable + pinCreateForm;
15253
+
15254
+ // Optional metrics rollup.
15255
+ var metrics = opts.metrics;
15256
+ var metricsSection = "";
15257
+ if (metrics) {
15258
+ function _ratio(r) { return r == null ? "—" : (Math.round(r * 10000) / 100) + "%"; }
15259
+ metricsSection =
15260
+ "<div class=\"panel mt mw-40\"><h3 class=\"subhead\">Metrics — " + _htmlEscape(metrics.weights_slug) + "</h3>" +
15261
+ "<dl class=\"totals-list\">" +
15262
+ "<dt>Impressions</dt><dd>" + _htmlEscape(String(metrics.impressions)) + "</dd>" +
15263
+ "<dt>Clicks</dt><dd>" + _htmlEscape(String(metrics.clicks)) + "</dd>" +
15264
+ "<dt>Purchases</dt><dd>" + _htmlEscape(String(metrics.purchases)) + "</dd>" +
15265
+ "<dt>CTR</dt><dd>" + _ratio(metrics.ctr) + "</dd>" +
15266
+ "<dt>Conversion</dt><dd>" + _ratio(metrics.conversion_rate) + "</dd>" +
15267
+ "<dt>Click&rarr;purchase</dt><dd>" + _ratio(metrics.click_to_purchase) + "</dd>" +
15268
+ "</dl></div>";
15269
+ }
15270
+
15271
+ var activeLine = active
15272
+ ? "<p class=\"meta\">Active weight set: <strong>" + _htmlEscape(active.name) + "</strong> (<code>" + _htmlEscape(active.slug) + "</code>).</p>"
15273
+ : "<p class=\"meta\">No active weight set — storefront search uses its default order.</p>";
15274
+
15275
+ var bodyHtml = "<section><h2>Search ranking</h2>" + banners + activeLine + table + createForm + pinsSection + metricsSection + "</section>";
15276
+ return _renderAdminShell(opts.shop_name, "Search ranking", bodyHtml, "search-ranking", opts.nav_available);
15277
+ }
15278
+
15279
+ // Trust-badge console — the badge list (placements + status + edit / archive
15280
+ // actions) and the create form (svg payload OR image url, link, placements,
15281
+ // schedule, priority, alt text). The raw svg_payload is NEVER echoed into an
15282
+ // admin attribute — it rides only inside a <textarea> on the edit screen
15283
+ // (which escapes < and &). title + alt_text are _htmlEscape'd in the list.
15284
+ function renderAdminTrustBadges(opts) {
15285
+ opts = opts || {};
15286
+ var badges = opts.badges || [];
15287
+ var banners = "";
15288
+ if (opts.created) banners += "<div class=\"banner banner--ok\">Badge saved.</div>";
15289
+ if (opts.archived) banners += "<div class=\"banner banner--ok\">Badge archived.</div>";
15290
+ if (opts.notice) banners += "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>";
15291
+
15292
+ var rows = badges.map(function (bd) {
15293
+ var isArchived = bd.archived_at != null;
15294
+ var kind = bd.svg_payload ? "SVG" : "image";
15295
+ var placements = Array.isArray(bd.placements) ? bd.placements.join(", ") : "";
15296
+ return "<tr>" +
15297
+ "<td><code class=\"order-id\">" + _htmlEscape(bd.slug) + "</code></td>" +
15298
+ "<td>" + _htmlEscape(bd.title) + "</td>" +
15299
+ "<td>" + _htmlEscape(kind) + "</td>" +
15300
+ "<td>" + _htmlEscape(placements) + "</td>" +
15301
+ "<td>" + _htmlEscape(String(bd.priority)) + "</td>" +
15302
+ "<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
15303
+ "<td><div class=\"actions-row\">" +
15304
+ (isArchived ? "" :
15305
+ "<a class=\"btn btn--ghost\" href=\"/admin/trust-badges/" + _htmlEscape(encodeURIComponent(bd.slug)) + "\">Edit</a> " +
15306
+ "<form method=\"post\" action=\"/admin/trust-badges/" + _htmlEscape(encodeURIComponent(bd.slug)) + "/archive\" class=\"form-inline\">" +
15307
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
15308
+ "</div></td>" +
15309
+ "</tr>";
15310
+ }).join("");
15311
+ var table = badges.length
15312
+ ? "<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>"
15313
+ : "<p class=\"empty\">No trust badges yet. Create one below.</p>";
15314
+
15315
+ var placementChecks = trustBadgesModule.ALLOWED_PLACEMENTS.map(function (pl) {
15316
+ var live = pl === "checkout" || pl === "order_confirmation";
15317
+ return "<label class=\"kv\"><input type=\"checkbox\" name=\"placements\" value=\"" + pl + "\"" + (live ? " checked" : "") + "> " +
15318
+ _htmlEscape(pl) + (live ? "" : " (not yet rendered)") + "</label>";
15319
+ }).join("");
15320
+
15321
+ var createForm =
15322
+ "<div class=\"panel mt mw-40\">" +
15323
+ "<h3 class=\"subhead\">Create a trust badge</h3>" +
15324
+ "<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>" +
15325
+ "<form method=\"post\" action=\"/admin/trust-badges\">" +
15326
+ _setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — a stable id.", " maxlength=\"80\" required") +
15327
+ _setupField("Title", "title", "", "text", "Shown as the badge's hover title.", " maxlength=\"200\" required") +
15328
+ _setupField("Alt text", "alt_text", "", "text", "Describes the badge for assistive tech.", " maxlength=\"300\" required") +
15329
+ "<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>" +
15330
+ _setupField("Image URL (optional)", "image_url", "", "text", "https:// or a /-rooted path. Leave blank to use SVG instead.", " maxlength=\"2048\"") +
15331
+ _setupField("Link URL (optional)", "link_url", "", "text", "https:// or a /-rooted path the badge links to.", " maxlength=\"2048\"") +
15332
+ "<fieldset class=\"form-field\"><legend>Placements</legend>" + placementChecks + "</fieldset>" +
15333
+ _setupField("Priority", "priority", "0", "number", "Higher shows first within a placement.", " min=\"0\" max=\"1000000\"") +
15334
+ "<label class=\"form-field\"><span>Starts at (optional)</span><input type=\"datetime-local\" name=\"starts_at\"></label>" +
15335
+ "<label class=\"form-field\"><span>Expires at (optional)</span><input type=\"datetime-local\" name=\"expires_at\"></label>" +
15336
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create badge</button></div>" +
15337
+ "</form>" +
15338
+ "</div>";
15339
+
15340
+ var bodyHtml = "<section><h2>Trust badges</h2>" + banners + table + createForm + "</section>";
15341
+ return _renderAdminShell(opts.shop_name, "Trust badges", bodyHtml, "trust-badges", opts.nav_available);
15342
+ }
15343
+
15344
+ // Trust-badge detail/edit screen. The svg_payload rides ONLY inside a
15345
+ // <textarea> (escaped) — never an attribute — so a hostile-but-sanitized
15346
+ // payload can't break out. Hidden presence markers let a partial edit avoid
15347
+ // clearing unrelated columns.
15348
+ function renderAdminTrustBadge(opts) {
15349
+ opts = opts || {};
15350
+ var bd = opts.badge || {};
15351
+ var updated = opts.updated ? "<div class=\"banner banner--ok\">Badge updated.</div>" : "";
15352
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15353
+ var enc = _htmlEscape(encodeURIComponent(bd.slug || ""));
15354
+
15355
+ var placementSet = {};
15356
+ (Array.isArray(bd.placements) ? bd.placements : []).forEach(function (p) { placementSet[p] = true; });
15357
+ var placementChecks = trustBadgesModule.ALLOWED_PLACEMENTS.map(function (pl) {
15358
+ return "<label class=\"kv\"><input type=\"checkbox\" name=\"placements\" value=\"" + pl + "\"" + (placementSet[pl] ? " checked" : "") + "> " + _htmlEscape(pl) + "</label>";
15359
+ }).join("");
15360
+
15361
+ var startsVal = _datetimeLocalValue(bd.starts_at);
15362
+ var expiresVal = _datetimeLocalValue(bd.expires_at);
15363
+
15364
+ var body =
15365
+ "<section class=\"mw-40\">" +
15366
+ "<h2>Edit badge — " + _htmlEscape(bd.slug || "") + "</h2>" +
15367
+ updated + notice +
15368
+ "<form method=\"post\" action=\"/admin/trust-badges/" + enc + "/edit\">" +
15369
+ _setupField("Title", "title", bd.title, "text", "", " maxlength=\"200\" required") +
15370
+ _setupField("Alt text", "alt_text", bd.alt_text, "text", "", " maxlength=\"300\" required") +
15371
+ "<input type=\"hidden\" name=\"svg_present\" value=\"1\">" +
15372
+ "<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>" +
15373
+ "<input type=\"hidden\" name=\"image_present\" value=\"1\">" +
15374
+ _setupField("Image URL", "image_url", bd.image_url || "", "text", "Blank this to switch to an SVG payload.", " maxlength=\"2048\"") +
15375
+ "<input type=\"hidden\" name=\"link_present\" value=\"1\">" +
15376
+ _setupField("Link URL", "link_url", bd.link_url || "", "text", "https:// or a /-rooted path.", " maxlength=\"2048\"") +
15377
+ "<input type=\"hidden\" name=\"placements_present\" value=\"1\">" +
15378
+ "<fieldset class=\"form-field\"><legend>Placements</legend>" + placementChecks + "</fieldset>" +
15379
+ _setupField("Priority", "priority", String(bd.priority == null ? 0 : bd.priority), "number", "", " min=\"0\" max=\"1000000\"") +
15380
+ "<input type=\"hidden\" name=\"starts_present\" value=\"1\">" +
15381
+ "<label class=\"form-field\"><span>Starts at</span><input type=\"datetime-local\" name=\"starts_at\" value=\"" + _htmlEscape(startsVal) + "\"></label>" +
15382
+ "<input type=\"hidden\" name=\"expires_present\" value=\"1\">" +
15383
+ "<label class=\"form-field\"><span>Expires at</span><input type=\"datetime-local\" name=\"expires_at\" value=\"" + _htmlEscape(expiresVal) + "\"></label>" +
15384
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save badge</button>" +
15385
+ "<a class=\"btn btn--ghost\" href=\"/admin/trust-badges\">Back to badges</a></div>" +
15386
+ "</form>" +
15387
+ "</section>";
15388
+ return _renderAdminShell(opts.shop_name, "Edit badge", body, "trust-badges", opts.nav_available);
15389
+ }
15390
+
14260
15391
  // Pre-order campaign console — the list table (status + reserved/available
14261
15392
  // counts + release date), the lifecycle actions (launch once the release date
14262
15393
  // has arrived, close any time), and the create form. The launch button only
@@ -16127,4 +17258,7 @@ module.exports = {
16127
17258
  renderAdminLoyaltyReward: renderAdminLoyaltyReward,
16128
17259
  renderAdminConfirm: renderAdminConfirm,
16129
17260
  renderAdminAudit: renderAdminAudit,
17261
+ renderAdminPickupLocations: renderAdminPickupLocations,
17262
+ renderAdminPickups: renderAdminPickups,
17263
+ renderAdminGiftWraps: renderAdminGiftWraps,
16130
17264
  };