@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 +4 -0
- package/lib/admin.js +238 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/preorder.js +29 -0
- package/lib/storefront.js +1315 -2
- package/package.json +1 -1
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 & 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
|
package/lib/asset-manifest.json
CHANGED
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
|