@blamejs/blamejs-shop 0.3.53 → 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/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.54 (2026-06-02) — **In-store pickup, saved payment methods, and gift wrapping at checkout.** Three more built-but-unreachable capabilities are now wired end to end. Operators can offer in-store pickup with managed pickup locations and a fulfilment queue; signed-in customers can save a card and reuse it at checkout; and operators can offer gift wrapping with a per-order gift message. The gift-wrap fee is added as a real cart line so it is included in the charged total. **Added:** *In-store pickup (click and collect)* — Operators can define pickup locations and work a pickup queue in the admin console (filtered by location), and shoppers can choose 'Pick up in store' at checkout. The order page and a new account pickups view show the pickup status. A not-found pickup or location returns a clear not-found rather than a generic error. · *Saved payment methods* — When Stripe is configured, a signed-in customer can save a card via a secure setup flow (loaded through a route-scoped policy and a same-origin script, with no inline script), manage their saved cards under their account, set a default, and reuse a saved card at checkout. A customer can only ever see or change their own saved cards. · *Gift wrapping at checkout* — Operators can define gift-wrap options (with a fee) in the admin console, and shoppers can add a wrap plus a gift message and recipient at checkout. The wrap fee is added as a real cart line, so it is included in the order total and the amount charged — never applied after the charge.
12
+
11
13
  - v0.3.53 (2026-06-02) — **Back-in-stock notifications, operator-tunable search ranking, and trust badges.** Three capabilities that were built into the framework but reachable from no route are now wired end to end. A shopper looking at an out-of-stock variant can ask to be emailed when it returns; operators can tune how search results are ordered and pin products for specific queries; and operators can author trust badges that render at checkout and on the order-confirmation page. **Added:** *Back-in-stock notifications* — When a variant is out of stock, the product page now shows a 'Notify me when it's back' form. The shopper confirms by email (double opt-in), and a scheduled sweep emails everyone waiting when stock returns, with a one-click unsubscribe. The notify form is served on the cached product page and is protected by same-site and fetch-metadata checks. · *Operator-tunable search ranking* — A new Search ranking screen in the admin console lets an operator define weighted ranking signals, activate a weight set, and pin specific products to the top for a given query. Ranking is applied to the search results; if a ranking configuration is missing or invalid, search falls back to its default order rather than erroring. · *Trust badges* — A new Trust badges screen lets an operator author badges (with scheduling windows) that render at checkout and on the order-confirmation page, with impression tracking. Badge content — including any SVG — is sanitized when saved.
12
14
 
13
15
  - v0.3.52 (2026-06-02) — **Read-only admin audit log, database backup tooling, an email-deliverability guide, and a cryptographic integrity gate over the vendored framework.** Audit events were recorded but had no console view, there was no documented database backup path, no operator guidance for email deliverability, and the vendored framework tree was only checked for presence — not integrity. This release adds a read-only audit log screen in the admin console, a database export script plus point-in-time recovery documentation, an SPF/DKIM/DMARC deliverability guide, and a build gate that cryptographically verifies every file in the vendored framework so a tampered or accidentally edited dependency fails the build. **Added:** *Admin audit log screen* — A read-only Audit screen in the admin console lists recorded audit events — action, outcome, actor, resource, and details — with an outcome filter and pagination. Every field is HTML-escaped. The screen is a pure observability view: if the audit store is briefly unavailable it shows an empty table with a notice rather than erroring. · *Database backup and recovery tooling* — A new export script wraps the D1 export command (suitable for a scheduled export to object storage), and the operator docs gain a recovery section pointing at D1 Time Travel for point-in-time restore. · *Email deliverability operator guide* — SECURITY.md and the README gain an email-deliverability section covering the SPF, DKIM, and DMARC DNS setup that determines whether transactional and cart-recovery mail reaches the inbox, alongside the already-handled one-click unsubscribe and suppression behavior. · *Cryptographic integrity gate over the vendored framework* — The build now re-hashes every file in the vendored framework tree against a pinned manifest and fails if any digest changes, giving the no-hand-edits-to-vendored-code rule real enforcement. The check runs across the test matrix and inside the container image build.
package/lib/admin.js CHANGED
@@ -515,6 +515,8 @@ function mount(router, deps) {
515
515
  var complianceExport = deps.complianceExport || null; // DSR queue (export/erasure) disabled when absent
516
516
  var orderExchanges = deps.orderExchanges || null; // exchange queue + FSM-action console disabled when absent
517
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
518
520
  var searchRanking = deps.searchRanking || null; // search-ranking weight-set + pin console disabled when absent
519
521
  var trustBadges = deps.trustBadges || null; // trust-badge authoring console disabled when absent
520
522
  var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
@@ -531,7 +533,7 @@ function mount(router, deps) {
531
533
  // `reports` is always present in the nav (read-only sales summary needs no
532
534
  // extra dep); its route mounts unconditionally and renders an unconfigured
533
535
  // notice when the salesReports primitive isn't wired.
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 };
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 };
535
537
 
536
538
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
537
539
 
@@ -4596,6 +4598,318 @@ function mount(router, deps) {
4596
4598
  ));
4597
4599
  }
4598
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
+
4599
4913
  // ---- config ---------------------------------------------------------
4600
4914
 
4601
4915
  var config = deps.config || null;
@@ -10277,6 +10591,9 @@ var ADMIN_NAV_ITEMS = [
10277
10591
  { key: "dsr", href: "/admin/dsr", label: "Privacy requests", requires: "complianceExport" },
10278
10592
  { key: "exchanges", href: "/admin/exchanges", label: "Exchanges", requires: "orderExchanges" },
10279
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" },
10280
10597
  { key: "search-ranking", href: "/admin/search-ranking", label: "Search ranking", requires: "searchRanking" },
10281
10598
  { key: "trust-badges", href: "/admin/trust-badges", label: "Trust badges", requires: "trustBadges" },
10282
10599
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
@@ -12458,6 +12775,9 @@ function renderAdminDsrDetail(opts) {
12458
12775
  // lifecycle order (the terminal closed / rejected don't appear in the open
12459
12776
  // queue, so they're not filter chips).
12460
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"];
12461
12781
 
12462
12782
  function _exchangePillClass(status) {
12463
12783
  if (status === "closed") return "paid"; // completed — green
@@ -12612,6 +12932,197 @@ function renderAdminExchange(opts) {
12612
12932
  return _renderAdminShell(opts.shop_name, "Exchange " + String(x.id).slice(0, 8), body, "exchanges", opts.nav_available);
12613
12933
  }
12614
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
+
12615
13126
  // One rating card for the moderation queue / recent list. Shows the three
12616
13127
  // scores, the (escaped) comment, the flag state + reason, and the operator
12617
13128
  // reply — plus the flag / reply action forms.
@@ -16747,4 +17258,7 @@ module.exports = {
16747
17258
  renderAdminLoyaltyReward: renderAdminLoyaltyReward,
16748
17259
  renderAdminConfirm: renderAdminConfirm,
16749
17260
  renderAdminAudit: renderAdminAudit,
17261
+ renderAdminPickupLocations: renderAdminPickupLocations,
17262
+ renderAdminPickups: renderAdminPickups,
17263
+ renderAdminGiftWraps: renderAdminGiftWraps,
16750
17264
  };
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.53",
2
+ "version": "0.3.54",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-/NizdENQkTc96romD0kmnl8220NAvW32QI0aSFuGiPbWE5lYbRxLXU5shsjlx5Ek",
@@ -44,6 +44,10 @@
44
44
  "js/paypal-checkout.js": {
45
45
  "integrity": "sha384-LI6y/1z0Y9F8Kx8RhW4EwY2WqJPXLwJozCXqnhDT+dTckLHyvhly0SsRpH0bsdui",
46
46
  "fingerprinted": "js/paypal-checkout.b05ab5572cc3728f.js"
47
+ },
48
+ "js/saved-card.js": {
49
+ "integrity": "sha384-Kaj6n+Any4rwCH2lyREHoq30MrAZtEd/fTa+tDnIrMJ4zO01YWRhW5TTujcYyuVn",
50
+ "fingerprinted": "js/saved-card.d7fd750d746dbe4d.js"
47
51
  }
48
52
  }
49
53
  }