@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 +2 -0
- package/lib/admin.js +515 -1
- package/lib/asset-manifest.json +5 -1
- package/lib/click-and-collect.js +35 -5
- package/lib/payment.js +77 -0
- package/lib/storefront.js +723 -0
- package/package.json +1 -1
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 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
|
};
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.3.
|
|
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
|
}
|