@blamejs/blamejs-shop 0.3.37 → 0.3.39

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.39 (2026-05-31) — **Paginated lists no longer show an empty extra page at exact page boundaries.** Several paginated lists offered a next page even when the current page was the last one, if the list's length happened to be an exact multiple of the page size — following that next link landed on an empty page. This affected a customer's order history, loyalty history, and store-credit history, and the admin customer list. Each now checks whether a further item actually exists before offering a next page, so the next link appears only when there is genuinely more to show. **Fixed:** *No phantom next page on exactly-full lists* — A customer's order history, loyalty history, and store-credit history, and the admin customer list, offered a next page whenever the current page was full — so a list whose total was an exact multiple of the page size showed a next link that led to an empty page. These lists now look one item past the page before offering a next link, so it appears only when a further item exists. This matches the fix already in place for collection pages.
12
+
13
+ - 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.
14
+
11
15
  - 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.
12
16
 
13
17
  - 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.
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.37",
2
+ "version": "0.3.39",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/customers.js CHANGED
@@ -255,20 +255,27 @@ function create(opts) {
255
255
  }
256
256
  }
257
257
  var sql, params;
258
+ // Fetch one row beyond the page so the next cursor is emitted ONLY
259
+ // when a customer past this page actually exists. Keying the cursor
260
+ // off a full page alone (rows.length === limit) advertises a
261
+ // phantom next page when the roster size is an exact multiple of
262
+ // the limit — the console's "Next page" link then lands empty.
258
263
  if (cursorVals) {
259
264
  sql = "SELECT id, email_hash, display_name, created_at, updated_at FROM customers " +
260
265
  "WHERE (created_at < ?1 OR (created_at = ?1 AND id < ?2)) " +
261
266
  "ORDER BY created_at DESC, id DESC LIMIT ?3";
262
- params = [cursorVals[0], cursorVals[1], limit];
267
+ params = [cursorVals[0], cursorVals[1], limit + 1];
263
268
  } else {
264
269
  sql = "SELECT id, email_hash, display_name, created_at, updated_at FROM customers " +
265
270
  "ORDER BY created_at DESC, id DESC LIMIT ?1";
266
- params = [limit];
271
+ params = [limit + 1];
267
272
  }
268
- var rows = (await query(sql, params)).rows;
273
+ var fetched = (await query(sql, params)).rows;
274
+ var hasMore = fetched.length > limit;
275
+ var rows = hasMore ? fetched.slice(0, limit) : fetched;
269
276
  var last = rows[rows.length - 1];
270
277
  var next = null;
271
- if (last && rows.length === limit) {
278
+ if (last && hasMore) {
272
279
  next = b.pagination.encodeCursor({
273
280
  orderKey: CUSTOMERS_ORDER_KEY,
274
281
  vals: [last.created_at, last.id],
package/lib/loyalty.js CHANGED
@@ -450,11 +450,17 @@ function create(opts) {
450
450
  sql += " AND occurred_at < ?2";
451
451
  params.push(cursor);
452
452
  }
453
+ // Fetch one row beyond the page so the next cursor is emitted ONLY
454
+ // when an older transaction actually exists. Keying the cursor off
455
+ // a full page alone (rows.length === limit) advertises a phantom
456
+ // next page when the history length is an exact multiple of the
457
+ // limit — the "Older activity" link then lands on an empty page.
453
458
  sql += " ORDER BY occurred_at DESC LIMIT ?" + (params.length + 1);
454
- params.push(limit);
459
+ params.push(limit + 1);
455
460
  var r = await query(sql, params);
456
- var rows = r.rows;
457
- var nextCursor = rows.length === limit ? rows[rows.length - 1].occurred_at : null;
461
+ var hasMore = r.rows.length > limit;
462
+ var rows = hasMore ? r.rows.slice(0, limit) : r.rows;
463
+ var nextCursor = hasMore ? rows[rows.length - 1].occurred_at : null;
458
464
  return { rows: rows, next_cursor: nextCursor };
459
465
  },
460
466
 
package/lib/order.js CHANGED
@@ -412,19 +412,28 @@ function create(opts) {
412
412
  }
413
413
  }
414
414
  var sql, params;
415
+ // Fetch one row beyond the page so the next cursor is emitted ONLY
416
+ // when an order past this page actually exists. Keying the cursor
417
+ // off a full page alone (rows.length === limit) advertises a
418
+ // phantom next page when the customer's order count is an exact
419
+ // multiple of the limit — the "Load more" link then lands on an
420
+ // empty page.
415
421
  if (cursorVals) {
416
422
  sql = "SELECT * FROM orders WHERE customer_id = ?1 AND " +
417
423
  "(updated_at < ?2 OR (updated_at = ?2 AND id < ?3)) " +
418
424
  "ORDER BY updated_at DESC, id DESC LIMIT ?4";
419
- params = [customerId, cursorVals[0], cursorVals[1], limit];
425
+ params = [customerId, cursorVals[0], cursorVals[1], limit + 1];
420
426
  } else {
421
427
  sql = "SELECT * FROM orders WHERE customer_id = ?1 " +
422
428
  "ORDER BY updated_at DESC, id DESC LIMIT ?2";
423
- params = [customerId, limit];
429
+ params = [customerId, limit + 1];
424
430
  }
425
- var rows = (await query(sql, params)).rows;
431
+ var fetched = (await query(sql, params)).rows;
432
+ var hasMore = fetched.length > limit;
433
+ var rows = hasMore ? fetched.slice(0, limit) : fetched;
426
434
  // Hydrate ship_to_json + lines on each row so the renderer
427
- // doesn't need a separate trip per order.
435
+ // doesn't need a separate trip per order. The peeked row is sliced
436
+ // off first so it never costs a hydration trip.
428
437
  for (var i = 0; i < rows.length; i += 1) {
429
438
  rows[i].ship_to = JSON.parse(rows[i].ship_to_json);
430
439
  rows[i].lines = (await query(
@@ -434,7 +443,7 @@ function create(opts) {
434
443
  }
435
444
  var last = rows[rows.length - 1];
436
445
  var next = null;
437
- if (last && rows.length === limit) {
446
+ if (last && hasMore) {
438
447
  next = b.pagination.encodeCursor({
439
448
  orderKey: ORDER_ORDER_KEY,
440
449
  vals: [last.updated_at, last.id],
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
@@ -331,11 +331,17 @@ function create(opts) {
331
331
  sql += " AND occurred_at < ?2";
332
332
  params.push(cursor);
333
333
  }
334
+ // Fetch one row beyond the page so the next cursor is emitted ONLY
335
+ // when an older ledger entry actually exists. Keying the cursor off
336
+ // a full page alone (rows.length === limit) advertises a phantom
337
+ // next page when the ledger length is an exact multiple of the
338
+ // limit — the "Older activity" link then lands on an empty page.
334
339
  sql += " ORDER BY occurred_at DESC, id DESC LIMIT ?" + (params.length + 1);
335
- params.push(limit);
340
+ params.push(limit + 1);
336
341
  var r = await query(sql, params);
337
- var rows = r.rows;
338
- var nextCursor = rows.length === limit ? rows[rows.length - 1].occurred_at : null;
342
+ var hasMore = r.rows.length > limit;
343
+ var rows = hasMore ? r.rows.slice(0, limit) : r.rows;
344
+ var nextCursor = hasMore ? rows[rows.length - 1].occurred_at : null;
339
345
  return { rows: rows, next_cursor: nextCursor };
340
346
  },
341
347
 
package/lib/storefront.js CHANGED
@@ -1944,6 +1944,115 @@ function _pdpShippingNote(availability) {
1944
1944
  "See our <a href=\"/terms\">shipping &amp; returns policy</a>.</p>";
1945
1945
  }
1946
1946
 
1947
+ // Pre-order CTA — replaces the add-to-cart buy box on a PDP whose lead SKU
1948
+ // has an OPEN pre-order campaign (a SKU that isn't released yet, so it's not
1949
+ // normally purchasable). `preorder` is the resolved shape the route loads:
1950
+ // { product_slug, release_date_iso, remaining_units (null = unlimited),
1951
+ // full_price_str, deposit_str (null when no deposit), sold_out,
1952
+ // reserve_form }
1953
+ // A reservation is INTENT, not a charge — the form POSTs to the container
1954
+ // /products/:slug/preorder route, which pins the reservation to the signed-in
1955
+ // session customer and converts it into a regular (Stripe-gated) order at
1956
+ // launch. The reserve POST is CSRF-protected + auth-gated, so the POST FORM is
1957
+ // rendered ONLY by the container (where the per-request `_csrf` token is
1958
+ // injected): `reserve_form` is true on the container render, false on the edge.
1959
+ // The edge render shows the same pre-order info + a sign-in affordance instead
1960
+ // of a token-less form — a logged-in customer's session-cookie request skips
1961
+ // the edge cache and routes to the container, so they always reach the real
1962
+ // (tokened) form; an anonymous edge visitor can't reserve anyway (the route
1963
+ // 303s guests to login). This mirrors how cart-count / session chrome is
1964
+ // handled on edge-cached pages. The non-form parts stay byte-identical to the
1965
+ // edge builder. A sold-out campaign renders a disabled control + an honest
1966
+ // note, mirroring the out-of-stock add control.
1967
+ function _buildPreorderCta(preorder, escAttr) {
1968
+ var soldOut = !!preorder.sold_out;
1969
+ var remaining = preorder.remaining_units;
1970
+ var availLine = remaining == null
1971
+ ? "<p class=\"pdp__preorder-avail\" role=\"status\">Open for pre-order.</p>"
1972
+ : (remaining > 0
1973
+ ? "<p class=\"pdp__preorder-avail\" role=\"status\"><span class=\"dot dot--live\" aria-hidden=\"true\"></span> " + escAttr(String(remaining)) + " of " + escAttr(String(preorder.max_units_available)) + " reservations remaining.</p>"
1974
+ : "<p class=\"pdp__preorder-avail\" role=\"status\">All reservations are spoken for.</p>");
1975
+ var depositLine = preorder.deposit_str
1976
+ ? "<p class=\"pdp__preorder-deposit\">Reserve with a " + escAttr(preorder.deposit_str) + " deposit · " + escAttr(preorder.full_price_str) + " total at launch.</p>"
1977
+ : "<p class=\"pdp__preorder-deposit\">No payment due now · " + escAttr(preorder.full_price_str) + " charged when it ships.</p>";
1978
+ var head =
1979
+ "<div class=\"pdp__buybox pdp__buybox--preorder\">\n" +
1980
+ " <p class=\"pdp__badge pdp__badge--preorder\">Pre-order · ships " + escAttr(preorder.release_date_iso) + "</p>\n" +
1981
+ " <p class=\"featured-product__price\">" + escAttr(preorder.full_price_str) + "</p>\n" +
1982
+ " " + availLine + "\n" +
1983
+ " " + depositLine + "\n";
1984
+ // Sold-out: the same disabled control in both substrates (no POST either way).
1985
+ if (soldOut) {
1986
+ return head +
1987
+ " <button type=\"submit\" class=\"btn-primary cart-page__checkout\" disabled aria-disabled=\"true\">Pre-orders full</button>\n" +
1988
+ " <p class=\"pdp__soldout-note\" role=\"status\">Every pre-order reservation has been claimed.</p>\n" +
1989
+ " </div>";
1990
+ }
1991
+ if (!preorder.reserve_form) {
1992
+ // Edge render — no per-session CSRF token here, so render a sign-in
1993
+ // affordance instead of a token-less POST form. A signed-in customer's
1994
+ // request skips the edge cache and gets the container form below.
1995
+ return head +
1996
+ " <a class=\"btn-primary cart-page__checkout\" href=\"/account/login\">Sign in to reserve</a>\n" +
1997
+ " <p class=\"pdp__preorder-note\">A reservation holds your unit. We charge through secure checkout when it launches.</p>\n" +
1998
+ " </div>";
1999
+ }
2000
+ return head +
2001
+ " <form method=\"post\" action=\"/products/" + escAttr(preorder.product_slug) + "/preorder\">\n" +
2002
+ " <label class=\"pdp__variants-title\" for=\"preorder-qty\">Quantity</label>\n" +
2003
+ " <input id=\"preorder-qty\" type=\"number\" name=\"qty\" value=\"1\" min=\"1\" max=\"99\" class=\"variant-row__qty\" aria-label=\"Quantity\">\n" +
2004
+ " <button type=\"submit\" class=\"btn-primary cart-page__checkout\">Reserve your pre-order</button>\n" +
2005
+ " <p class=\"pdp__preorder-note\">A reservation holds your unit. We charge through secure checkout when it launches.</p>\n" +
2006
+ " </form>\n" +
2007
+ " </div>";
2008
+ }
2009
+
2010
+ // Build the renderProduct `preorder` opts shape from a campaign row + the
2011
+ // primitive's `availability` read + a price formatter. Returns null unless the
2012
+ // campaign is OPEN (status 'active') — a launched / closed campaign is no
2013
+ // longer reservable, so its PDP renders the standard buy box. Shared so the
2014
+ // container route + the edge handler derive the identical shape and the
2015
+ // dual-rendered CTA stays byte-consistent. `fmt(minor, currency)` is the
2016
+ // page's price formatter; `slug` is the product (URL) slug the reserve form
2017
+ // posts to.
2018
+ function preorderCtaShape(campaign, availability, fmt, slug) {
2019
+ if (!campaign || campaign.status !== "active") return null;
2020
+ var remaining = availability ? availability.remaining_units : null;
2021
+ var max = campaign.max_units_available == null ? null : campaign.max_units_available;
2022
+ var deposit = Number(campaign.deposit_minor) || 0;
2023
+ return {
2024
+ product_slug: slug,
2025
+ release_date_iso: new Date(Number(campaign.launch_at)).toISOString().slice(0, 10),
2026
+ remaining_units: remaining,
2027
+ max_units_available: max,
2028
+ full_price_str: fmt(campaign.full_price_minor, campaign.currency),
2029
+ deposit_str: deposit > 0 ? fmt(deposit, campaign.currency) : null,
2030
+ sold_out: remaining != null && remaining <= 0,
2031
+ // The container injects the per-request `_csrf` token into POST forms, so
2032
+ // the container render carries the real reserve form. The edge twin
2033
+ // (worker/render/product.js#preorderCtaShape) sets this false and renders a
2034
+ // sign-in affordance — see _buildPreorderCta.
2035
+ reserve_form: true,
2036
+ };
2037
+ }
2038
+
2039
+ // The reserve-PRG banner, keyed off the closed ?preorder marker set so a
2040
+ // forged query can never inject copy. Empty string for an absent / unknown
2041
+ // marker. Mirrored byte-for-byte by worker/render/product.js#_buildPreorderNotice.
2042
+ var _PREORDER_NOTICES = {
2043
+ reserved: { kind: "ok", copy: "Reserved. We'll email you when it ships and charge your card through secure checkout." },
2044
+ unavailable: { kind: "error", copy: "This pre-order couldn't be reserved — it may be full or closed. Nothing was charged." },
2045
+ closed: { kind: "error", copy: "This pre-order is no longer open." },
2046
+ };
2047
+ function _buildPreorderNotice(marker) {
2048
+ var n = marker && Object.prototype.hasOwnProperty.call(_PREORDER_NOTICES, marker)
2049
+ ? _PREORDER_NOTICES[marker] : null;
2050
+ if (!n) return "";
2051
+ var cls = n.kind === "error" ? "form-notice form-notice--error" : "form-notice form-notice--ok";
2052
+ var role = n.kind === "error" ? "alert" : "status";
2053
+ return "<p class=\"" + cls + "\" role=\"" + role + "\">" + b.template.escapeHtml(n.copy) + "</p>\n ";
2054
+ }
2055
+
1947
2056
  var PRODUCT_PAGE =
1948
2057
  "<section class=\"pdp\">\n" +
1949
2058
  " <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
@@ -4958,6 +5067,76 @@ function renderAccountSubscriptions(opts) {
4958
5067
  });
4959
5068
  }
4960
5069
 
5070
+ // The customer's pre-order reservations (`GET /account/preorders`). Each row
5071
+ // shows the campaign (the SKU's product context isn't loaded here — the
5072
+ // campaign slug + release date + status are the durable identity), its
5073
+ // release date, the reserved quantity, and the reservation status pill. An
5074
+ // ACTIVE reservation offers a Cancel control (ownership-scoped POST); a
5075
+ // converted (now an order) / cancelled reservation is read-only. `reservations`
5076
+ // rows are decorated with `.campaign` by the route. JS-off-native.
5077
+ function renderAccountPreorders(opts) {
5078
+ var esc = b.template.escapeHtml;
5079
+ var rows = opts.reservations || [];
5080
+ var rowsHtml = "";
5081
+ for (var i = 0; i < rows.length; i += 1) {
5082
+ var r = rows[i];
5083
+ var c = r.campaign || null;
5084
+ var releaseStr = c && c.launch_at
5085
+ ? new Date(Number(c.launch_at)).toISOString().slice(0, 10)
5086
+ : "";
5087
+ var priceStr = "";
5088
+ if (c) {
5089
+ try { priceStr = esc(pricing.format(Number(c.full_price_minor), String(c.currency || "USD"))) + " · "; }
5090
+ catch (_e) { priceStr = ""; }
5091
+ }
5092
+ var status = String(r.status || "active");
5093
+ var statusLabel = status === "converted" ? "Ordered" : status === "cancelled" ? "Canceled" : "Reserved";
5094
+ var releaseLine = releaseStr
5095
+ ? "Ships <time datetime=\"" + esc(releaseStr) + "\">" + esc(releaseStr) + "</time>"
5096
+ : "";
5097
+ var cancelControl = status === "active"
5098
+ ? "<form class=\"preorder-card__control\" method=\"post\" action=\"/account/preorders/" + esc(String(r.id)) + "/cancel\">" +
5099
+ "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Cancel reservation</button>" +
5100
+ "</form>"
5101
+ : "";
5102
+ rowsHtml +=
5103
+ "<li class=\"preorder-card preorder-card--" + esc(status) + "\">" +
5104
+ "<div class=\"preorder-card__head\">" +
5105
+ "<span class=\"preorder-card__title\">" + esc(c ? String(c.slug) : String(r.campaign_slug)) + "</span>" +
5106
+ "<span class=\"status-pill preorder-status--" + esc(status) + "\">" + esc(statusLabel) + "</span>" +
5107
+ "</div>" +
5108
+ "<p class=\"preorder-card__meta\">" + priceStr + "Qty " + esc(String(r.quantity)) + (releaseLine ? " · " + releaseLine : "") + "</p>" +
5109
+ cancelControl +
5110
+ "</li>";
5111
+ }
5112
+ var inner = rowsHtml
5113
+ ? "<ul class=\"preorder-list\">" + rowsHtml + "</ul>"
5114
+ : "<div class=\"account-empty\">" +
5115
+ "<p class=\"account-empty__lede\">You have no pre-order reservations.</p>" +
5116
+ "<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
5117
+ "</div>";
5118
+ var noticeHtml = opts.notice
5119
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(String(opts.notice)) + "</p>"
5120
+ : "";
5121
+ var body =
5122
+ "<section class=\"account-preorders\">" +
5123
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
5124
+ "<li><a href=\"/account\">Account</a></li>" +
5125
+ "<li aria-current=\"page\">Pre-orders</li>" +
5126
+ "</ol></nav>" +
5127
+ "<h1 class=\"account-preorders__title\">Pre-orders</h1>" +
5128
+ noticeHtml +
5129
+ inner +
5130
+ "</section>";
5131
+ return _wrap({
5132
+ title: "Pre-orders",
5133
+ shop_name: opts.shop_name || "blamejs.shop",
5134
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5135
+ theme_css: opts.theme_css,
5136
+ body: body,
5137
+ });
5138
+ }
5139
+
4961
5140
  // Server-rendered confirmation step for canceling a subscription. CSP
4962
5141
  // forbids an inline confirm() dialog, so the destructive choice is gated
4963
5142
  // by this page: it spells out the period-end date and, for the
@@ -5198,7 +5377,25 @@ function renderProduct(opts) {
5198
5377
  // loads ({ sku: { stock_on_hand, stock_held } }); absent it, the
5199
5378
  // product reads as in stock (never-block-on-missing-inventory stance).
5200
5379
  var availability = _resolveAvailability(variants, opts.inventory);
5201
- var buyboxHtml = _buildBuyBox(rendered, b.template.escapeHtml, availability);
5380
+ // An OPEN pre-order campaign for the lead SKU swaps the add-to-cart buy box
5381
+ // for the reservation CTA — a not-yet-released SKU isn't normally
5382
+ // purchasable, so the only honest action is to reserve a unit. The route
5383
+ // threads `opts.preorder_campaign` ({ campaign, remaining_units }) from a
5384
+ // live D1 read; the shape is built here with the page's own `fmt` so the
5385
+ // CTA's prices track the active currency context exactly as the buy-box
5386
+ // prices do, and the container + edge render byte-identically. Absent a
5387
+ // campaign (or a non-active one), the standard buy box renders unchanged.
5388
+ var preorderShape = opts.preorder_campaign
5389
+ ? preorderCtaShape(opts.preorder_campaign.campaign, { remaining_units: opts.preorder_campaign.remaining_units }, fmt, opts.product.slug)
5390
+ : null;
5391
+ var buyboxHtml = preorderShape
5392
+ ? _buildPreorderCta(preorderShape, b.template.escapeHtml)
5393
+ : _buildBuyBox(rendered, b.template.escapeHtml, availability);
5394
+ // The reserve PRG lands the shopper back on the PDP with a fixed
5395
+ // ?preorder=<reserved|unavailable|closed> marker; the banner prepends the
5396
+ // buy box. The marker set is closed (built from a lookup, never the raw
5397
+ // query), so a forged query can't inject copy. Mirrored at the edge.
5398
+ buyboxHtml = _buildPreorderNotice(opts.preorder_notice) + buyboxHtml;
5202
5399
  var availabilityHtml = _buildAvailability(availability);
5203
5400
  var shippingNoteHtml = _pdpShippingNote(availability);
5204
5401
  var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
@@ -7253,6 +7450,7 @@ var ACCOUNT_DASH_PAGE =
7253
7450
  " <a class=\"btn-secondary\" href=\"/account/credit\">Store credit</a>\n" +
7254
7451
  " <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
7255
7452
  " <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
7453
+ " RAW_PREORDER_LINK\n" +
7256
7454
  // begin: profile + passkey management actions
7257
7455
  " <a class=\"btn-secondary\" href=\"/account/profile\">Edit profile</a>\n" +
7258
7456
  " <a class=\"btn-secondary\" href=\"/account/passkeys\">Manage passkeys</a>\n" +
@@ -7357,7 +7555,13 @@ function renderAccount(opts) {
7357
7555
  member_since: memberSince,
7358
7556
  passkey_count: String(passkeyCount),
7359
7557
  order_rows: "RAW_ORDER_ROWS",
7360
- }).replace("RAW_ORDER_ROWS", rows);
7558
+ }).replace("RAW_ORDER_ROWS", rows)
7559
+ // The Pre-orders link only renders when the preorder primitive is wired
7560
+ // (the /account/preorders route is mounted) — a deploy without it never
7561
+ // links to a 404.
7562
+ .replace("RAW_PREORDER_LINK", opts.preorders_enabled
7563
+ ? "<a class=\"btn-secondary\" href=\"/account/preorders\">Pre-orders</a>"
7564
+ : "");
7361
7565
  return _wrap({
7362
7566
  title: "Account",
7363
7567
  shop_name: opts.shop_name || "blamejs.shop",
@@ -7964,6 +8168,12 @@ function mount(router, deps) {
7964
8168
  // cancel route additionally needs the payment handle (cancel composes
7965
8169
  // Stripe via the primitive); without it the list stays read-only.
7966
8170
  var subscriptions = deps.subscriptions || null;
8171
+ // Pre-order reservations — opts in the PDP reserve route + the
8172
+ // /account/preorders surface. The PDP swaps the add-to-cart buy box for a
8173
+ // reservation CTA when the lead SKU has an OPEN campaign; the reserve POST
8174
+ // pins the reservation to the signed-in session customer (no charge — the
8175
+ // launch flow converts the reservation into a Stripe-gated order).
8176
+ var preorder = deps.preorder || null;
7967
8177
 
7968
8178
  // Active cookie-consent policy version. `_liveConsentPolicy()` reads it
7969
8179
  // from the consent primitive per request so a runtime `policyVersion`
@@ -9482,10 +9692,32 @@ function mount(router, deps) {
9482
9692
  // mirrored at the edge). Best-effort inside the helper; an empty list
9483
9693
  // hides the rail.
9484
9694
  var related = await _relatedProductsFor(product.id, 4);
9695
+ // Pre-order campaign for the lead SKU — when an OPEN campaign exists, the
9696
+ // renderer swaps the add-to-cart buy box for the reservation CTA (release
9697
+ // date + remaining availability). One indexed read; degrades to "no
9698
+ // campaign" (the standard buy box) on a missing table / read failure so
9699
+ // the buy path renders regardless. Mirrors the edge resolution so the
9700
+ // dual-rendered CTA agrees across substrates.
9701
+ var preorderCampaign = null;
9702
+ if (deps.preorder && firstVariant) {
9703
+ try {
9704
+ var openCampaign = await deps.preorder.openCampaignForSku(firstVariant.sku);
9705
+ if (openCampaign) {
9706
+ var avail = await deps.preorder.availability({ slug: openCampaign.slug });
9707
+ preorderCampaign = { campaign: openCampaign, remaining_units: avail ? avail.remaining_units : null };
9708
+ }
9709
+ } catch (_e) { preorderCampaign = null; }
9710
+ }
9711
+ // The reserve PRG lands here with a ?preorder=<marker> the renderer maps
9712
+ // to a banner; only meaningful when a campaign is present.
9713
+ var pdpUrl = req.url ? new URL(req.url, "http://localhost") : null;
9714
+ var preorderNotice = pdpUrl ? pdpUrl.searchParams.get("preorder") : null;
9485
9715
  var html = renderProduct(Object.assign({
9486
9716
  product: product,
9487
9717
  variants: variants,
9488
9718
  prices: prices,
9719
+ preorder_campaign: preorderCampaign,
9720
+ preorder_notice: preorderNotice,
9489
9721
  media: media,
9490
9722
  inventory: inventory,
9491
9723
  review_summary: reviewSummary,
@@ -10836,6 +11068,7 @@ function mount(router, deps) {
10836
11068
  orders: orders,
10837
11069
  order_product_lookup: orderProductLookup,
10838
11070
  passkey_count: passkeyCount,
11071
+ preorders_enabled: !!preorder,
10839
11072
  shop_name: shopName,
10840
11073
  cart_count: cartCount,
10841
11074
  }));
@@ -12798,6 +13031,165 @@ function mount(router, deps) {
12798
13031
  }
12799
13032
  }
12800
13033
 
13034
+ // Pre-order reservations — the PDP reserve POST + the customer's
13035
+ // /account/preorders surface (list + cancel). A reservation is INTENT, not
13036
+ // a charge: reserve() writes a row pinned to the SIGNED-IN SESSION
13037
+ // customer (never a body/query id) + decrements the campaign's capacity;
13038
+ // the launch flow later converts it into a regular (Stripe-gated) order.
13039
+ // Mounts only when the preorder primitive is wired.
13040
+ if (preorder) {
13041
+ function _preorderAuth(req, res) {
13042
+ var auth;
13043
+ try { auth = _currentCustomer(req); }
13044
+ catch (e) {
13045
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
13046
+ throw e;
13047
+ }
13048
+ if (!auth) {
13049
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
13050
+ res.end ? res.end() : res.send("");
13051
+ return null;
13052
+ }
13053
+ return auth;
13054
+ }
13055
+
13056
+ // Defensive request-shape reader for the reserve form's quantity. A
13057
+ // missing / non-numeric / non-positive value defaults to 1 so a no-JS
13058
+ // submit still reserves a unit; the primitive is authoritative on the
13059
+ // cap (it refuses an over-cap quantity), and a >99 paste clamps to 99 to
13060
+ // match the form's max attribute.
13061
+ function _preorderQty(raw) {
13062
+ var n = parseInt(String(raw == null ? "" : raw), 10);
13063
+ if (!Number.isFinite(n) || n <= 0) return 1;
13064
+ return n > 99 ? 99 : n;
13065
+ }
13066
+
13067
+ // POST /products/:slug/preorder — reserve a unit of the lead SKU's OPEN
13068
+ // campaign. Auth-gated; the reservation is pinned to the session
13069
+ // customer (auth.customer_id), NEVER a body/query id. The campaign is
13070
+ // resolved from the product's lead SKU (not a client-supplied slug), so
13071
+ // a shopper can only reserve against the campaign the PDP actually
13072
+ // shows. Over-cap / closed / missing campaign → a clean 4xx PRG back to
13073
+ // the PDP with a fixed ?preorder error code (no raw error text).
13074
+ router.post("/products/:slug/preorder", async function (req, res) {
13075
+ var auth = _preorderAuth(req, res); if (!auth) return;
13076
+ var slug = req.params && req.params.slug;
13077
+ var enc = encodeURIComponent(slug || "");
13078
+ var product = slug ? await deps.catalog.products.bySlug(slug) : null;
13079
+ if (!product) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
13080
+ var variants = await deps.catalog.variants.listForProduct(product.id);
13081
+ var lead = variants[0] || null;
13082
+ // No lead variant, or no OPEN campaign for it → there's nothing
13083
+ // reservable here; bounce to the PDP with the closed marker.
13084
+ var campaign = null;
13085
+ if (lead) {
13086
+ try { campaign = await preorder.openCampaignForSku(lead.sku); }
13087
+ catch (_e) { campaign = null; }
13088
+ }
13089
+ if (!campaign) {
13090
+ res.status(303); res.setHeader && res.setHeader("location", "/products/" + enc + "?preorder=closed");
13091
+ return res.end ? res.end() : res.send("");
13092
+ }
13093
+ var qty = _preorderQty((req.body || {}).qty);
13094
+ try {
13095
+ await preorder.reserve({
13096
+ campaign_slug: campaign.slug,
13097
+ customer_id: auth.customer_id,
13098
+ quantity: qty,
13099
+ });
13100
+ } catch (e) {
13101
+ // A capacity / closed-campaign / shape refusal is the customer's
13102
+ // problem to see, not a 500 — map every TypeError to a fixed
13103
+ // ?preorder error marker (the reason copy is rendered on the PDP,
13104
+ // never the raw message). Anything else rethrows (a real 500).
13105
+ if (!(e instanceof TypeError)) throw e;
13106
+ res.status(303); res.setHeader && res.setHeader("location", "/products/" + enc + "?preorder=unavailable");
13107
+ return res.end ? res.end() : res.send("");
13108
+ }
13109
+ res.status(303); res.setHeader && res.setHeader("location", "/products/" + enc + "?preorder=reserved");
13110
+ return res.end ? res.end() : res.send("");
13111
+ });
13112
+
13113
+ // Load the reservation named in :id and confirm it belongs to the
13114
+ // signed-in customer. A malformed id (guardUuid TypeError), a missing
13115
+ // row, or another customer's reservation all return 404 after sending
13116
+ // it — never a 500, never a cross-customer cancel. The reservation
13117
+ // primitive cancels by id alone, so the route owns the ownership
13118
+ // decision.
13119
+ async function _ownedReservation(req, res, auth) {
13120
+ var resv;
13121
+ try { resv = await preorder.getReservation(req.params && req.params.id); }
13122
+ catch (e) {
13123
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
13124
+ throw e;
13125
+ }
13126
+ if (!resv || resv.customer_id !== auth.customer_id) {
13127
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
13128
+ return null;
13129
+ }
13130
+ return resv;
13131
+ }
13132
+
13133
+ // The customer's reservations, decorated with each campaign's status +
13134
+ // release date so the list reads "Operator Tee — ships 2026-09-01 —
13135
+ // active". Campaigns are batched: one getCampaign per distinct slug,
13136
+ // cached across rows. A read failure (table not migrated) degrades to an
13137
+ // empty list rather than 500-ing the account page.
13138
+ async function _preordersForCustomer(customerId) {
13139
+ var rows;
13140
+ try { rows = await preorder.reservationsForCustomer(customerId); }
13141
+ catch (e) {
13142
+ if (e instanceof TypeError) return [];
13143
+ throw e;
13144
+ }
13145
+ var campaignCache = {};
13146
+ for (var i = 0; i < rows.length; i += 1) {
13147
+ var cslug = rows[i].campaign_slug;
13148
+ if (cslug != null && !Object.prototype.hasOwnProperty.call(campaignCache, cslug)) {
13149
+ try { campaignCache[cslug] = await preorder.getCampaign(cslug); }
13150
+ catch (_e) { campaignCache[cslug] = null; }
13151
+ }
13152
+ rows[i].campaign = cslug != null ? campaignCache[cslug] : null;
13153
+ }
13154
+ return rows;
13155
+ }
13156
+
13157
+ router.get("/account/preorders", async function (req, res) {
13158
+ var auth = _preorderAuth(req, res); if (!auth) return;
13159
+ var rows = await _preordersForCustomer(auth.customer_id);
13160
+ var cartCount = await _cartCountForReq(req);
13161
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
13162
+ var okKind = url ? url.searchParams.get("ok") : null;
13163
+ var notice = okKind === "canceled" ? "Your pre-order reservation has been canceled." : null;
13164
+ _send(res, 200, renderAccountPreorders({
13165
+ reservations: rows,
13166
+ notice: notice,
13167
+ shop_name: shopName,
13168
+ cart_count: cartCount,
13169
+ }));
13170
+ });
13171
+
13172
+ // POST /account/preorders/:id/cancel — cancel the customer's own active
13173
+ // reservation, freeing the held capacity. Ownership-scoped: a malformed
13174
+ // / unknown / foreign reservation id 404s before any write. A
13175
+ // non-active reservation (already converted / cancelled) is a clean PRG
13176
+ // back to the list, not a 500.
13177
+ router.post("/account/preorders/:id/cancel", async function (req, res) {
13178
+ var auth = _preorderAuth(req, res); if (!auth) return;
13179
+ var resv = await _ownedReservation(req, res, auth); if (!resv) return;
13180
+ try {
13181
+ await preorder.cancelReservation({ reservation_id: resv.id, reason: "customer-cancelled" });
13182
+ } catch (e) {
13183
+ if (!(e instanceof TypeError)) throw e;
13184
+ // Already converted/cancelled → nothing to do; bounce back clean.
13185
+ res.status(303); res.setHeader && res.setHeader("location", "/account/preorders");
13186
+ return res.end ? res.end() : res.send("");
13187
+ }
13188
+ res.status(303); res.setHeader && res.setHeader("location", "/account/preorders?ok=canceled");
13189
+ return res.end ? res.end() : res.send("");
13190
+ });
13191
+ }
13192
+
12801
13193
  // Self-serve returns — a customer requests an RMA against one of
12802
13194
  // their own orders and tracks its status. Operators action it via
12803
13195
  // the admin /admin/returns queue. Needs the returns primitive + an
@@ -14657,6 +15049,7 @@ module.exports = {
14657
15049
  renderHome: renderHome,
14658
15050
  renderSearch: renderSearch,
14659
15051
  renderProduct: renderProduct,
15052
+ preorderCtaShape: preorderCtaShape,
14660
15053
  renderCollectionList: renderCollectionList,
14661
15054
  renderCollection: renderCollection,
14662
15055
  renderCategoryIndex: renderCategoryIndex,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.37",
3
+ "version": "0.3.39",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {