@blamejs/blamejs-shop 0.3.36 → 0.3.38

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.38 (2026-05-31) — **Pre-order campaigns — reserve a product before it is released.** Products that are not yet released could not be sold or reserved. Operators can now run a pre-order campaign for a product from the admin console — setting its release date, price, and an optional reservation cap. While the campaign is open, the product page shows a Pre-order control with the release date and how many reservations remain, and a signed-in customer can reserve their quantity without paying upfront. Customers see and can cancel their reservations from their account, and when the operator launches the campaign on its release date, each reservation is turned into a pending order the customer pays for through normal checkout. **Added:** *Pre-order campaigns* — A Pre-orders screen in the admin console runs a campaign for a product's SKU — release date, price, an optional deposit, and a reservation cap — with launch and close controls (launch is available once the release date arrives). While a campaign is open, the product page shows a Pre-order control with the release date and remaining availability instead of the usual add-to-cart, and a signed-in customer can reserve their quantity (no charge at reservation time; reserving past the cap or on a closed campaign is refused). Customers manage their reservations under Pre-orders in their account and can cancel one, which frees that capacity. Launching the campaign converts each open reservation into a pending order the customer completes through normal checkout. A customer can only see and cancel their own reservations.
12
+
13
+ - v0.3.37 (2026-05-31) — **Gift registries — create one, share it, and let people mark gifts as bought.** A customer can now create a gift registry for an occasion — a wedding, baby, or birthday — add the products they want with the quantity desired, and share a public link. Anyone with the link can open the registry without an account, see what is still needed, add an item to their own cart to buy it through normal checkout, or mark an item as already purchased so it stops showing as needed. Registries can be public, unlisted, or private, and a private registry is not viewable from its link. The shared page never reveals who owns the registry or who bought what, and is not indexed by search engines. **Added:** *Gift registries* — A new Registry section in the account area lets a customer create a registry (title, occasion, event date, and a public/unlisted/private visibility), add and remove the items they want with a desired quantity, edit its details, and close it. Each registry has a shareable public page at its own link where anyone — no account needed — can see the items and how many of each are still needed, add an item to their cart to buy it through the normal checkout, or mark an item as already purchased so the registry's progress updates. A private registry is not viewable from its link, the public page shows only the items (never the owner's identity, address, or who purchased what), and registry pages are excluded from search indexing.
14
+
11
15
  - v0.3.36 (2026-05-31) — **Customers can request an exchange instead of only a refund.** Returning an order could only result in a refund, so a customer who received the wrong size had to refund and buy again. A customer can now request an exchange from the order page — choosing the item to swap, the replacement, and a reason — and operators work those requests from a new Exchanges queue in the admin console, moving each one through approval, shipping the replacement, receiving the returned item, and closing it out. A customer can only request and view exchanges on their own orders. This release also updates the vendored blamejs runtime to v0.14.10. **Added:** *Order exchanges* — An order can now be exchanged, not just refunded. From the order page a customer requests an exchange — picking the line to return, the replacement variant, and a reason — and tracks its status under Exchanges in their account. Operators get an Exchanges queue in the admin console with a status filter and a per-exchange screen that offers only the actions valid at the current step: approve or reject the request, mark the replacement shipped and delivered, mark the returned item received, and close it once both sides are complete. Every customer-facing exchange action is scoped to the customer's own orders, and an order must be in a fulfilled state to be eligible. **Changed:** *Vendored blamejs runtime updated to v0.14.10* — The bundled blamejs runtime is updated to v0.14.10, which routes the framework's full-text-search index token hashing through a keyed MAC (HMAC-SHAKE256) instead of a static-salted digest, so an index token hash cannot be forged or correlated across deployments without the per-deployment key. The change is internal to the runtime and requires no operator action.
12
16
 
13
17
  - v0.3.35 (2026-05-31) — **Share a wishlist with a private link.** A customer's wishlist could only be viewed by the customer themselves. From the wishlist page a shopper can now create a shareable link and send it to someone — a friend buying a gift, for example — who opens it without needing an account and sees the wishlist's products. The link can be revoked at any time, after which it stops working. The shared page shows only the products: it does not reveal who owns the wishlist or any private notes on it, and it is not indexed by search engines. **Added:** *Shareable wishlist links* — The wishlist page now has a Share control that creates a private link to the wishlist. The full link is shown once, to copy and send; anyone with it can open the shared wishlist without an account and see its products, each linking to the product page. The owner sees their active links and can revoke any of them, which immediately stops the link from working. The shared view shows only the products — never the owner's identity or private notes — and is marked not to be indexed by search engines, so a personal wishlist stays private to whoever holds the link.
package/lib/admin.js CHANGED
@@ -510,6 +510,7 @@ function mount(router, deps) {
510
510
  var salesTaxFilings = deps.salesTaxFilings || null; // sales-tax-filing remittance console disabled when absent
511
511
  var supportTickets = deps.supportTickets || null; // support-ticket queue + thread console disabled when absent
512
512
  var orderExchanges = deps.orderExchanges || null; // exchange queue + FSM-action console disabled when absent
513
+ var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
513
514
 
514
515
  // Which optional console sections are wired — gates their nav links so a
515
516
  // signed-in admin is never sent to a route that wasn't mounted. Passed
@@ -517,7 +518,7 @@ function mount(router, deps) {
517
518
  // `reports` is always present in the nav (read-only sales summary needs no
518
519
  // extra dep); its route mounts unconditionally and renders an unconfigured
519
520
  // notice when the salesReports primitive isn't wired.
520
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, 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, orderExchanges: !!orderExchanges };
521
+ 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, orderExchanges: !!orderExchanges };
521
522
 
522
523
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
523
524
 
@@ -4932,6 +4933,117 @@ function mount(router, deps) {
4932
4933
  ));
4933
4934
  }
4934
4935
 
4936
+ // ---- pre-orders -----------------------------------------------------
4937
+ // Campaigns that take reservations against a SKU that isn't released yet.
4938
+ // The lifecycle is draft-free: defineCampaign opens an `active` campaign,
4939
+ // launchCampaign converts every active reservation into a regular order
4940
+ // (via the shared order handle wired into the primitive) and flips the
4941
+ // campaign to `launched`, closeCampaign terminates it without launching.
4942
+ // A reservation is intent, not a charge — the convert step at launch is
4943
+ // what produces the order the customer then pays through normal checkout.
4944
+ // Content-negotiated like the other console screens: bearer → the JSON
4945
+ // contract; signed-in browser → the HTML table + create form + lifecycle
4946
+ // actions. The launch action is calendar-gated by the primitive (it
4947
+ // refuses `now < launch_at`), so the console surfaces the launch button
4948
+ // only once the release date has arrived.
4949
+ if (preorder) {
4950
+ // Decorate each campaign row with its reserved/remaining counts (the
4951
+ // availability read) so the table reads honestly without a second query
4952
+ // per row in the renderer. One availability call per campaign; a read
4953
+ // failure on a row degrades that row to null counts rather than 500-ing
4954
+ // the screen.
4955
+ async function _preorderRows() {
4956
+ var campaigns = await preorder.listCampaigns();
4957
+ for (var i = 0; i < campaigns.length; i += 1) {
4958
+ try { campaigns[i].availability = await preorder.availability({ slug: campaigns[i].slug }); }
4959
+ catch (_e) { campaigns[i].availability = null; }
4960
+ }
4961
+ return campaigns;
4962
+ }
4963
+
4964
+ router.get("/admin/preorders", _pageOrApi(true,
4965
+ R(async function (req, res) {
4966
+ _json(res, 200, { rows: await preorder.listCampaigns() });
4967
+ }),
4968
+ async function (req, res) {
4969
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
4970
+ var ok = url && url.searchParams.get("ok");
4971
+ var noticeKey = url && url.searchParams.get("err");
4972
+ _sendHtml(res, 200, renderAdminPreorders({
4973
+ shop_name: deps.shop_name, nav_available: navAvailable,
4974
+ campaigns: await _preorderRows(),
4975
+ ok: ok,
4976
+ notice: noticeKey ? "That action couldn't be completed for the campaign." : null,
4977
+ }));
4978
+ },
4979
+ ));
4980
+
4981
+ router.post("/admin/preorders", _pageOrApi(false,
4982
+ W("preorder.define", async function (req, res) {
4983
+ var row = await preorder.defineCampaign(_preorderFromForm(req.body || {}));
4984
+ _json(res, 201, row);
4985
+ return { id: row.slug };
4986
+ }),
4987
+ async function (req, res) {
4988
+ try {
4989
+ await preorder.defineCampaign(_preorderFromForm(req.body || {}));
4990
+ } catch (e) {
4991
+ var n = _safeNotice(e, "preorder.define");
4992
+ return _sendHtml(res, n.status, renderAdminPreorders({
4993
+ shop_name: deps.shop_name, nav_available: navAvailable,
4994
+ campaigns: await _preorderRows(),
4995
+ notice: n.message.replace(/^preorder[.:]\s*/, ""),
4996
+ }));
4997
+ }
4998
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".preorder.define", outcome: "success" });
4999
+ _redirect(res, "/admin/preorders?ok=created");
5000
+ },
5001
+ ));
5002
+
5003
+ // Launch — convert every active reservation into a regular order and flip
5004
+ // the campaign to `launched`. The primitive composes the shared order
5005
+ // handle (wired in server.js) so each converted reservation lands a real
5006
+ // order the customer pays through normal checkout; the response reports
5007
+ // how many converted. Calendar-gated: a launch before launch_at is a clean
5008
+ // 400 (browser → ?err=1). A bearer client gets the conversion summary.
5009
+ router.post("/admin/preorders/:slug/launch", _pageOrApi(false,
5010
+ W("preorder.launch", async function (req, res) {
5011
+ var result;
5012
+ try { result = await preorder.launchCampaign({ slug: req.params.slug, now: Date.now() }); }
5013
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5014
+ _json(res, 200, result);
5015
+ return { id: req.params.slug };
5016
+ }),
5017
+ async function (req, res) {
5018
+ var slug = req.params.slug;
5019
+ try { await preorder.launchCampaign({ slug: slug, now: Date.now() }); }
5020
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/preorders?err=1"); }
5021
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".preorder.launch", outcome: "success", metadata: { slug: slug } });
5022
+ _redirect(res, "/admin/preorders?ok=launched");
5023
+ },
5024
+ ));
5025
+
5026
+ // Close — terminate the campaign without launching (the fab batch fell
5027
+ // through). Reservations stay `active` so the operator can refund deposits
5028
+ // through the order/refund surface, then cancel them explicitly.
5029
+ router.post("/admin/preorders/:slug/close", _pageOrApi(false,
5030
+ W("preorder.close", async function (req, res) {
5031
+ var row;
5032
+ try { row = await preorder.closeCampaign({ slug: req.params.slug, reason: (req.body || {}).reason || "" }); }
5033
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5034
+ _json(res, 200, row);
5035
+ return { id: req.params.slug };
5036
+ }),
5037
+ async function (req, res) {
5038
+ var slug = req.params.slug;
5039
+ try { await preorder.closeCampaign({ slug: slug, reason: (req.body || {}).reason || "" }); }
5040
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/preorders?err=1"); }
5041
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".preorder.close", outcome: "success", metadata: { slug: slug } });
5042
+ _redirect(res, "/admin/preorders?ok=closed");
5043
+ },
5044
+ ));
5045
+ }
5046
+
4935
5047
  // ---- blog -----------------------------------------------------------
4936
5048
  // Author the operator's editorial blog. The edge Worker serves the
4937
5049
  // customer-facing /blog index + /blog/:slug posts + the RSS feed,
@@ -8978,6 +9090,7 @@ var ADMIN_NAV_ITEMS = [
8978
9090
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
8979
9091
  { key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
8980
9092
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
9093
+ { key: "preorders", href: "/admin/preorders", label: "Pre-orders", requires: "preorder" },
8981
9094
  { key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
8982
9095
  { key: "discounts", href: "/admin/discounts", label: "Discounts", requires: "autoDiscount" },
8983
9096
  { key: "discount-allocation", href: "/admin/discount-allocation", label: "Discount splits", requires: "discountAllocation" },
@@ -12463,6 +12576,41 @@ function _announcementPatchFromForm(body) {
12463
12576
  return patch;
12464
12577
  }
12465
12578
 
12579
+ // Coerce the create form into the shape preorder.defineCampaign expects.
12580
+ // Prices are entered + stored in MINOR units (cents) — the form labels say
12581
+ // so, and the JSON API uses minor units too. The launch date is a
12582
+ // datetime-local string parsed to epoch ms. A blank cap maps to null
12583
+ // (unlimited). The primitive is authoritative on every range
12584
+ // (deposit <= full price, non-negative integers, ISO 4217 currency, slug /
12585
+ // sku shape) and throws a TypeError on a bad shape, which both surfaces
12586
+ // degrade to a clean 400 / err notice. A blank numeric field is left as the
12587
+ // empty string so the primitive's validator (not this coercion) is the one
12588
+ // that reports it.
12589
+ function _preorderIntOrNull(v) {
12590
+ if (v == null || v === "") return null;
12591
+ var n = Number(v);
12592
+ return Number.isInteger(n) ? n : v; // a non-integer passes through for the primitive to reject
12593
+ }
12594
+ function _preorderFromForm(body) {
12595
+ body = body || {};
12596
+ var out = {
12597
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
12598
+ sku: typeof body.sku === "string" ? body.sku.trim() : body.sku,
12599
+ launch_at: _epochFromForm(body.launch_at),
12600
+ full_price_minor: _preorderIntOrNull(body.full_price_minor),
12601
+ currency: typeof body.currency === "string" ? body.currency.trim().toUpperCase() : body.currency,
12602
+ };
12603
+ // Optional fields — only forward when supplied so the primitive applies its
12604
+ // defaults (deposit 0, charge_at = launch_at, unlimited cap, empty copy).
12605
+ var cap = _preorderIntOrNull(body.max_units_available);
12606
+ if (body.max_units_available != null && body.max_units_available !== "") out.max_units_available = cap;
12607
+ if (body.deposit_minor != null && body.deposit_minor !== "") out.deposit_minor = _preorderIntOrNull(body.deposit_minor);
12608
+ var chargeAt = _epochFromForm(body.charge_at);
12609
+ if (chargeAt != null) out.charge_at = chargeAt;
12610
+ if (typeof body.marketing_copy === "string" && body.marketing_copy.trim()) out.marketing_copy = body.marketing_copy.trim();
12611
+ return out;
12612
+ }
12613
+
12466
12614
  // epoch ms → the <datetime-local> value an <input> renders back. Returns
12467
12615
  // "" for a null/absent bound so the field stays empty (open-ended).
12468
12616
  function _datetimeLocalValue(epochMs) {
@@ -12599,6 +12747,95 @@ function renderAdminAnnouncements(opts) {
12599
12747
  return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
12600
12748
  }
12601
12749
 
12750
+ // Pre-order campaign console — the list table (status + reserved/available
12751
+ // counts + release date), the lifecycle actions (launch once the release date
12752
+ // has arrived, close any time), and the create form. The launch button only
12753
+ // renders for an `active` campaign whose launch_at has passed (the primitive
12754
+ // refuses an early launch, so showing the button before then would just
12755
+ // bounce back with an error); close renders for any `active` campaign.
12756
+ function renderAdminPreorders(opts) {
12757
+ opts = opts || {};
12758
+ var rows = opts.campaigns || [];
12759
+ var okKind = opts.ok;
12760
+ var okCopy = okKind === "created" ? "Pre-order campaign created."
12761
+ : okKind === "launched" ? "Campaign launched — active reservations converted to orders."
12762
+ : okKind === "closed" ? "Campaign closed."
12763
+ : "";
12764
+ var okBanner = okCopy ? "<div class=\"banner banner--ok\">" + _htmlEscape(okCopy) + "</div>" : "";
12765
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
12766
+
12767
+ var now = Date.now();
12768
+ function _money(minor, currency) {
12769
+ try { return pricing.format(Number(minor), String(currency || "USD")); }
12770
+ catch (_e) { return String(minor); }
12771
+ }
12772
+ function _date(epochMs) {
12773
+ if (epochMs == null) return "—";
12774
+ var d = new Date(Number(epochMs));
12775
+ return isNaN(d.getTime()) ? "—" : d.toISOString().slice(0, 10);
12776
+ }
12777
+
12778
+ var bodyRows = rows.map(function (c) {
12779
+ var av = c.availability || null;
12780
+ var reserved = av != null ? av.units_reserved : c.units_reserved;
12781
+ var cap = c.max_units_available == null ? "∞" : String(c.max_units_available);
12782
+ var remaining = c.max_units_available == null ? "∞"
12783
+ : String(av != null ? av.remaining_units : (Number(c.max_units_available) - Number(c.units_reserved)));
12784
+ var isActive = c.status === "active";
12785
+ var canLaunch = isActive && Number(c.launch_at) <= now;
12786
+ var enc = _htmlEscape(encodeURIComponent(c.slug));
12787
+ var actions = "";
12788
+ if (isActive) {
12789
+ if (canLaunch) {
12790
+ actions +=
12791
+ "<form method=\"post\" action=\"/admin/preorders/" + enc + "/launch\" class=\"form-inline\">" +
12792
+ "<button class=\"btn\" type=\"submit\">Launch &amp; convert</button></form> ";
12793
+ }
12794
+ actions +=
12795
+ "<form method=\"post\" action=\"/admin/preorders/" + enc + "/close\" class=\"form-inline\">" +
12796
+ "<button class=\"btn btn--danger\" type=\"submit\">Close</button></form>";
12797
+ }
12798
+ return "<tr>" +
12799
+ "<td><code class=\"order-id\">" + _htmlEscape(c.slug) + "</code></td>" +
12800
+ "<td><code>" + _htmlEscape(c.sku) + "</code></td>" +
12801
+ "<td>" + _htmlEscape(_date(c.launch_at)) + "</td>" +
12802
+ "<td class=\"num\">" + _htmlEscape(_money(c.full_price_minor, c.currency)) + "</td>" +
12803
+ "<td class=\"num\">" + _htmlEscape(String(reserved)) + " / " + _htmlEscape(cap) + "</td>" +
12804
+ "<td class=\"num\">" + _htmlEscape(remaining) + "</td>" +
12805
+ "<td><span class=\"status-pill " + (c.status === "active" ? "paid" : c.status === "launched" ? "fulfilled" : "cancelled") + "\">" + _htmlEscape(c.status) + "</span></td>" +
12806
+ "<td><div class=\"actions-row\">" + actions + "</div></td>" +
12807
+ "</tr>";
12808
+ }).join("");
12809
+
12810
+ var table = rows.length
12811
+ ? "<div class=\"panel\"><table><thead><tr>" +
12812
+ "<th scope=\"col\">Slug</th><th scope=\"col\">SKU</th><th scope=\"col\">Ships</th>" +
12813
+ "<th scope=\"col\">Price</th><th scope=\"col\">Reserved / cap</th><th scope=\"col\">Remaining</th>" +
12814
+ "<th scope=\"col\">Status</th><th scope=\"col\">Actions</th>" +
12815
+ "</tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
12816
+ : "<p class=\"empty\">No pre-order campaigns yet.</p>";
12817
+
12818
+ var createForm =
12819
+ "<div class=\"panel mt mw-40\">" +
12820
+ "<h3 class=\"subhead\">Open a pre-order campaign</h3>" +
12821
+ "<p class=\"meta\">A reservation is intent, not a charge — at launch every active reservation converts to a regular order the customer pays through checkout. Prices are in minor units (cents). Leave the cap blank for unlimited.</p>" +
12822
+ "<form method=\"post\" action=\"/admin/preorders\">" +
12823
+ _setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — a stable id + the URL slug.", " maxlength=\"128\" required") +
12824
+ _setupField("SKU", "sku", "", "text", "The variant SKU this campaign reserves against.", " maxlength=\"128\" required") +
12825
+ _setupField("Release date", "launch_at", "", "datetime-local", "When the campaign converts reservations to orders.", " required") +
12826
+ _setupField("Full price (minor units)", "full_price_minor", "", "number", "e.g. 4999 = $49.99. The locked-in unit price on the order line.", " min=\"0\" step=\"1\" required") +
12827
+ _setupField("Deposit (minor units, optional)", "deposit_minor", "", "number", "Partial payment collected at reservation. Blank = no deposit.", " min=\"0\" step=\"1\"") +
12828
+ _setupField("Reservation cap (optional)", "max_units_available", "", "number", "Caps reservations against a known batch. Blank = unlimited.", " min=\"0\" step=\"1\"") +
12829
+ _setupField("Currency", "currency", "USD", "text", "3-letter ISO 4217 code.", " maxlength=\"3\" class=\"input-code\" required") +
12830
+ "<label class=\"form-field\"><span>Marketing copy (optional)</span><textarea name=\"marketing_copy\" maxlength=\"2000\"></textarea></label>" +
12831
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Open campaign</button></div>" +
12832
+ "</form>" +
12833
+ "</div>";
12834
+
12835
+ var bodyHtml = "<section><h2>Pre-orders</h2>" + okBanner + notice + table + createForm + "</section>";
12836
+ return _renderAdminShell(opts.shop_name, "Pre-orders", bodyHtml, "preorders", opts.nav_available);
12837
+ }
12838
+
12602
12839
  // ---- blog form coercion + render --------------------------------------
12603
12840
 
12604
12841
  // A comma- or newline-separated tag field → the normalized string array
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.36",
2
+ "version": "0.3.38",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/preorder.js CHANGED
@@ -316,6 +316,35 @@ function create(opts) {
316
316
  return await _getCampaign(slug);
317
317
  },
318
318
 
319
+ // The OPEN campaign for a SKU, or null. Drives the storefront PDP's
320
+ // pre-order CTA: a not-yet-released SKU isn't in the catalog as a buyable
321
+ // variant, so the page keys the reservation affordance off this read.
322
+ // Returns at most one row — the most recently updated active campaign when
323
+ // more than one names the SKU. Mirrors the edge read
324
+ // (worker/data/catalog.js#getOpenPreorderForSku) so the dual-rendered CTA
325
+ // resolves the same campaign in both substrates.
326
+ openCampaignForSku: async function (sku) {
327
+ _sku(sku);
328
+ var r = await query(
329
+ "SELECT * FROM preorder_campaigns WHERE sku = ?1 AND status = 'active' " +
330
+ "ORDER BY updated_at DESC, slug DESC LIMIT 1",
331
+ [sku],
332
+ );
333
+ return r.rows[0] || null;
334
+ },
335
+
336
+ // Operator-facing campaign list for the admin console. Newest-defined
337
+ // first (created_at DESC, slug DESC tiebreak). A pure read — no cursor;
338
+ // the console shows the full set (an operator runs tens of campaigns, not
339
+ // thousands).
340
+ listCampaigns: async function () {
341
+ var r = await query(
342
+ "SELECT * FROM preorder_campaigns ORDER BY created_at DESC, slug DESC",
343
+ [],
344
+ );
345
+ return r.rows;
346
+ },
347
+
319
348
  // Customer-driven reserve. Refuses when:
320
349
  // - the campaign doesn't exist / isn't active
321
350
  // - the requested quantity would push units_reserved past the