@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 +4 -0
- package/lib/admin.js +238 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/customers.js +11 -4
- package/lib/loyalty.js +9 -3
- package/lib/order.js +14 -5
- package/lib/preorder.js +29 -0
- package/lib/store-credit.js +9 -3
- package/lib/storefront.js +395 -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.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 & 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/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
|
|
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 &&
|
|
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
|
|
457
|
-
var
|
|
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
|
|
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 &&
|
|
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
|
package/lib/store-credit.js
CHANGED
|
@@ -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
|
|
338
|
-
var
|
|
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 & 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
|
-
|
|
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