@blamejs/blamejs-shop 0.3.20 → 0.3.22
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 +433 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +93 -1
- 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.22 (2026-05-30) — **Create and publish content pages (About, Shipping, Returns, and the like) from the admin console.** The shop now has editable content pages. An admin Pages screen lets an operator create a page with a slug, title, Markdown body, layout, and meta fields, then publish, unpublish, archive, or restore it — and the storefront serves each published page at /pages/<slug>. Previously the content-page backend existed but had no admin screen and the storefront did not serve it, so it could not be used; a page is created as a draft and is not visible on the storefront until it is published. **Added:** *Content pages: author in the console, serve on the storefront* — A new Pages admin screen manages content pages through their full lifecycle (draft, published, archived, with restore). Published pages are served at /pages/<slug> with the page's chosen layout; a draft or archived slug returns not-found. The Markdown body is rendered with the same escaping and safe-link handling as the rest of the storefront. This makes a standard set of pages — about, shipping, returns, and similar — editable instead of requiring a code change.
|
|
12
|
+
|
|
13
|
+
- v0.3.21 (2026-05-30) — **Customers can cancel an order from their account before it ships.** A signed-in customer can now cancel one of their own orders from the order page, as long as it has not started fulfillment. The Cancel control appears only while the order is still cancellable — a paid or pending order — and is absent once the order is fulfilling, shipped, delivered, already cancelled, or refunded. The action is tied to the signed-in customer, so a customer can only cancel their own order. Note: cancelling a paid order changes its status to cancelled but does not by itself refund the charge — issue the refund from the admin console as usual; a customer-initiated cancellation of a paid order is your signal to do so. **Added:** *Cancel an unfulfilled order from the account* — The order page now shows a Cancel button while an order is still in a pre-fulfillment state (paid or pending). Cancelling moves the order to cancelled and shows a confirmation; the control does not appear once an order is fulfilling, shipped, delivered, cancelled, or refunded, and a customer can only cancel their own order. A paid-order cancellation updates the status but does not automatically refund the captured payment — refund it from the console; treat a customer cancellation of a paid order as the prompt to do so.
|
|
14
|
+
|
|
11
15
|
- v0.3.20 (2026-05-30) — **Served media carries hardened headers, primary-image changes are product-scoped, and image-by-URL imports are size-capped.** Three defense-in-depth fixes on product media. Media assets served from storage now carry X-Content-Type-Options: nosniff and Cross-Origin-Resource-Policy: same-origin, and SVG files additionally carry a content policy that prevents script from running when the file is opened directly while still rendering normally inside a product image. Setting a product's primary image now verifies the image actually belongs to the product named in the request rather than acting on the image alone. And importing a product image by URL now refuses a response larger than the same 10 MiB limit the file-upload path enforces, instead of buffering an unbounded download. **Changed:** *Setting the primary image is product-scoped* — The endpoint that makes an image a product's primary (hero) now verifies the image belongs to the product in the request path and returns not-found otherwise, instead of acting on the image regardless of the product named. A request whose product and image don't match is now rejected. · *Vendored runtime updated to v0.14.9* — The vendored blamejs runtime is updated to v0.14.9, a documentation-path and source-comment fix with no API, wire-format, or behavior changes. No operator action is required. **Security:** *Hardened response headers on served media* — Media served from storage now sets X-Content-Type-Options: nosniff and Cross-Origin-Resource-Policy: same-origin, so a mistyped or hostile upload can't be content-sniffed into something executable or embedded cross-origin. An SVG additionally carries a default-src 'none' sandbox content-security policy, so opening an SVG URL directly cannot run script (it still renders inside an img tag). This sits behind the existing SVG-upload sanitizer as a second layer. · *Importing an image by URL is size-capped* — Importing a product image from a URL now refuses a response larger than 10 MiB — the same limit a direct file upload uses — returning a clear error instead of buffering an unbounded download.
|
|
12
16
|
|
|
13
17
|
- v0.3.19 (2026-05-30) — **Edit announcement bars, automatic-discount terms, and subscription plans from the console.** Three admin entities could be created but only partly edited from the console — the rest of their fields were reachable only through the API, so changing them meant archive-and-recreate or a manual API call. Each now has a detail screen with the full edit form. An announcement bar can have its message, links, schedule, audience, and dismissibility changed in place. An automatic discount can have its actual terms changed — the amount, percentage, threshold, or buy-x-get-y values — not just its title, priority, and on/off state. A subscription plan can have its price, billing interval, trial length, active state, and variant edited. Create and archive behavior is unchanged. **Fixed:** *Announcement bars are editable in place* — A detail screen now lets you change an announcement's message, link, theme, audience, start/end schedule, and whether it can be dismissed — instead of archiving it and creating a new one (which lost the slug and any recorded dismissal state). · *Automatic-discount terms are editable from the console* — The discount detail screen now edits the rule's trigger and value — the amount off, percent off, cart threshold, or buy-x-get-y terms — which previously could only be changed through the API. The inline priority and on/off controls are unchanged. · *Subscription plans have an edit screen* — A subscription plan's price, billing interval count, trial length, active state, and variant can now be edited from a detail screen. The Stripe-bound fields (the price id, interval unit, and currency) remain read-only, as they were always immutable.
|
package/lib/admin.js
CHANGED
|
@@ -510,7 +510,7 @@ function mount(router, deps) {
|
|
|
510
510
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
511
511
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
512
512
|
// notice when the salesReports primitive isn't wired.
|
|
513
|
-
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
|
|
513
|
+
var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
|
|
514
514
|
|
|
515
515
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
516
516
|
|
|
@@ -4356,6 +4356,238 @@ function mount(router, deps) {
|
|
|
4356
4356
|
_blogTransition("archive", function (s) { return blog.archive(s); });
|
|
4357
4357
|
}
|
|
4358
4358
|
|
|
4359
|
+
// ---- storefront pages -----------------------------------------------
|
|
4360
|
+
// Author the operator's CMS content pages (About, Shipping, Returns,
|
|
4361
|
+
// Privacy, Terms, and the long tail every shop needs). The edge Worker
|
|
4362
|
+
// serves the customer-facing page at /pages/:slug, reading ONLY
|
|
4363
|
+
// published rows; this console writes them. A page is created as a draft
|
|
4364
|
+
// and stays invisible to the storefront until it's published — publish /
|
|
4365
|
+
// unpublish / archive / restore move it through the lifecycle (draft →
|
|
4366
|
+
// published → archived, with restore back to draft). Content-negotiated
|
|
4367
|
+
// like the other screens: bearer → the JSON contract; signed-in browser
|
|
4368
|
+
// → the HTML list + author forms.
|
|
4369
|
+
if (deps.storefrontPages) {
|
|
4370
|
+
var pages = deps.storefrontPages;
|
|
4371
|
+
|
|
4372
|
+
// The list pulls each lifecycle state separately (the primitive has
|
|
4373
|
+
// listDrafts / listArchived + listPublished, each returning an array)
|
|
4374
|
+
// and merges them for the console table. The console doesn't page the
|
|
4375
|
+
// page corpus — operators have a handful of content pages.
|
|
4376
|
+
async function _pageRows(filter) {
|
|
4377
|
+
var drafts = await pages.listDrafts();
|
|
4378
|
+
var archived = await pages.listArchived();
|
|
4379
|
+
var published = await pages.listPublished();
|
|
4380
|
+
if (filter === "draft") return drafts;
|
|
4381
|
+
if (filter === "published") return published;
|
|
4382
|
+
if (filter === "archived") return archived;
|
|
4383
|
+
// All: published first (newest-live), then drafts, then archived.
|
|
4384
|
+
return published.concat(drafts).concat(archived);
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
router.get("/admin/pages", _pageOrApi(true,
|
|
4388
|
+
R(async function (req, res) {
|
|
4389
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4390
|
+
var status = (url && url.searchParams.get("status")) || null;
|
|
4391
|
+
_json(res, 200, { rows: await _pageRows(status) });
|
|
4392
|
+
}),
|
|
4393
|
+
async function (req, res) {
|
|
4394
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4395
|
+
var status = (url && url.searchParams.get("status")) || null;
|
|
4396
|
+
var rows = await _pageRows(status);
|
|
4397
|
+
_sendHtml(res, 200, renderAdminPages({
|
|
4398
|
+
shop_name: deps.shop_name, nav_available: navAvailable, pages: rows,
|
|
4399
|
+
status_filter: status,
|
|
4400
|
+
created: url && url.searchParams.get("created"),
|
|
4401
|
+
updated: url && url.searchParams.get("updated"),
|
|
4402
|
+
published: url && url.searchParams.get("published"),
|
|
4403
|
+
archived: url && url.searchParams.get("archived"),
|
|
4404
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the page." : null,
|
|
4405
|
+
}));
|
|
4406
|
+
},
|
|
4407
|
+
));
|
|
4408
|
+
|
|
4409
|
+
// The new-page form. A standalone GET so the "New page" button on the
|
|
4410
|
+
// list has a target; the create POST lives at /admin/pages.
|
|
4411
|
+
router.get("/admin/pages/new", _pageOrApi(true,
|
|
4412
|
+
R(async function (_req, res) {
|
|
4413
|
+
return _problem(res, 405, "use-canonical-endpoint", "POST /admin/pages with a JSON body to create a page");
|
|
4414
|
+
}),
|
|
4415
|
+
async function (_req, res) {
|
|
4416
|
+
_sendHtml(res, 200, renderAdminPageDetail({
|
|
4417
|
+
shop_name: deps.shop_name, nav_available: navAvailable, page: null,
|
|
4418
|
+
}));
|
|
4419
|
+
},
|
|
4420
|
+
));
|
|
4421
|
+
|
|
4422
|
+
// Create content-negotiates: bearer → JSON 201 (a draft row); browser
|
|
4423
|
+
// form → defineDraft, then PRG to the page's detail screen to keep
|
|
4424
|
+
// authoring. Every page is born a DRAFT — never published on create —
|
|
4425
|
+
// so it can't reach the storefront before the operator publishes it.
|
|
4426
|
+
router.post("/admin/pages", _pageOrApi(false,
|
|
4427
|
+
W("page.create", async function (req, res) {
|
|
4428
|
+
var row = await pages.defineDraft(req.body || {});
|
|
4429
|
+
_json(res, 201, row);
|
|
4430
|
+
return { id: row.slug };
|
|
4431
|
+
}),
|
|
4432
|
+
async function (req, res) {
|
|
4433
|
+
var made;
|
|
4434
|
+
try {
|
|
4435
|
+
made = await pages.defineDraft(_pageFromForm(req.body || {}));
|
|
4436
|
+
} catch (e) {
|
|
4437
|
+
var n = _safeNotice(e, "page.create");
|
|
4438
|
+
return _sendHtml(res, n.status, renderAdminPageDetail({
|
|
4439
|
+
shop_name: deps.shop_name, nav_available: navAvailable, page: null,
|
|
4440
|
+
form_values: req.body || {},
|
|
4441
|
+
notice: n.message.replace(/^storefrontPages[.:]\s*/, ""),
|
|
4442
|
+
}));
|
|
4443
|
+
}
|
|
4444
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".page.create", outcome: "success", metadata: { slug: made.slug } });
|
|
4445
|
+
_redirect(res, "/admin/pages/" + encodeURIComponent(made.slug) + "?created=1");
|
|
4446
|
+
},
|
|
4447
|
+
));
|
|
4448
|
+
|
|
4449
|
+
// Detail content-negotiates: bearer → JSON (the row, any status);
|
|
4450
|
+
// browser → the edit form + the lifecycle action buttons. A bad /
|
|
4451
|
+
// unknown slug is a 404 page, never a 500.
|
|
4452
|
+
router.get("/admin/pages/:slug", _pageOrApi(true,
|
|
4453
|
+
R(async function (req, res) {
|
|
4454
|
+
var row;
|
|
4455
|
+
try { row = await pages.get(req.params.slug); }
|
|
4456
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 404, "page-not-found", e.message); throw e; }
|
|
4457
|
+
if (!row) return _problem(res, 404, "page-not-found");
|
|
4458
|
+
_json(res, 200, row);
|
|
4459
|
+
}),
|
|
4460
|
+
async function (req, res) {
|
|
4461
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4462
|
+
var row;
|
|
4463
|
+
try { row = await pages.get(req.params.slug); }
|
|
4464
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; row = null; }
|
|
4465
|
+
if (!row) return _sendHtml(res, 404, renderAdminPages({
|
|
4466
|
+
shop_name: deps.shop_name, nav_available: navAvailable, pages: [], notice: "Page not found.",
|
|
4467
|
+
}));
|
|
4468
|
+
_sendHtml(res, 200, renderAdminPageDetail({
|
|
4469
|
+
shop_name: deps.shop_name, nav_available: navAvailable, page: row,
|
|
4470
|
+
updated: url && url.searchParams.get("updated"),
|
|
4471
|
+
published: url && url.searchParams.get("published"),
|
|
4472
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the page." : null,
|
|
4473
|
+
}));
|
|
4474
|
+
},
|
|
4475
|
+
));
|
|
4476
|
+
|
|
4477
|
+
// Edit content-negotiates: bearer PATCH (the JSON contract) + browser
|
|
4478
|
+
// POST /edit (HTML forms can't PATCH). Both patch the editable columns
|
|
4479
|
+
// (title / body / meta / layout). Status is NOT editable here — it
|
|
4480
|
+
// moves via the lifecycle routes below.
|
|
4481
|
+
router.patch("/admin/pages/:slug", W("page.update", async function (req, res) {
|
|
4482
|
+
var row;
|
|
4483
|
+
try { row = await pages.update(req.params.slug, req.body || {}); }
|
|
4484
|
+
catch (e) {
|
|
4485
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
4486
|
+
throw e;
|
|
4487
|
+
}
|
|
4488
|
+
_json(res, 200, row);
|
|
4489
|
+
return { id: row.slug };
|
|
4490
|
+
}));
|
|
4491
|
+
|
|
4492
|
+
router.post("/admin/pages/:slug/edit", _pageOrApi(false,
|
|
4493
|
+
W("page.update", async function (req, res) {
|
|
4494
|
+
var row;
|
|
4495
|
+
try { row = await pages.update(req.params.slug, req.body || {}); }
|
|
4496
|
+
catch (e) {
|
|
4497
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
4498
|
+
throw e;
|
|
4499
|
+
}
|
|
4500
|
+
_json(res, 200, row);
|
|
4501
|
+
return { id: row.slug };
|
|
4502
|
+
}),
|
|
4503
|
+
async function (req, res) {
|
|
4504
|
+
var slug = req.params.slug;
|
|
4505
|
+
var enc = encodeURIComponent(slug);
|
|
4506
|
+
try {
|
|
4507
|
+
await pages.update(slug, _pagePatchFromForm(req.body || {}));
|
|
4508
|
+
} catch (e) {
|
|
4509
|
+
var n = _safeNotice(e, "page.update");
|
|
4510
|
+
var current = null;
|
|
4511
|
+
try { current = await pages.get(slug); } catch (_e) { current = null; }
|
|
4512
|
+
if (!current) return _redirect(res, "/admin/pages?err=1");
|
|
4513
|
+
return _sendHtml(res, n.status, renderAdminPageDetail({
|
|
4514
|
+
shop_name: deps.shop_name, nav_available: navAvailable, page: current,
|
|
4515
|
+
notice: n.message.replace(/^storefrontPages[.:]\s*/, ""),
|
|
4516
|
+
}));
|
|
4517
|
+
}
|
|
4518
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".page.update", outcome: "success", metadata: { slug: slug } });
|
|
4519
|
+
_redirect(res, "/admin/pages/" + enc + "?updated=1");
|
|
4520
|
+
},
|
|
4521
|
+
));
|
|
4522
|
+
|
|
4523
|
+
// Lifecycle transitions — each PRGs back to the detail. publish takes
|
|
4524
|
+
// a draft live (now visible on the storefront); unpublish pulls it
|
|
4525
|
+
// back to draft (gone from the storefront); restore returns an
|
|
4526
|
+
// archived page to draft. An illegal transition (wrong from-state) or
|
|
4527
|
+
// a missing slug throws a TypeError from the primitive — a ?err=1
|
|
4528
|
+
// notice, never a 500.
|
|
4529
|
+
function _pageTransition(action, fn) {
|
|
4530
|
+
router.post("/admin/pages/:slug/" + action, _pageOrApi(false,
|
|
4531
|
+
W("page." + action, async function (req, res) {
|
|
4532
|
+
var row;
|
|
4533
|
+
try { row = await fn(req.params.slug); }
|
|
4534
|
+
catch (e) {
|
|
4535
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
4536
|
+
throw e;
|
|
4537
|
+
}
|
|
4538
|
+
_json(res, 200, row);
|
|
4539
|
+
return { id: row.slug };
|
|
4540
|
+
}),
|
|
4541
|
+
async function (req, res) {
|
|
4542
|
+
var slug = req.params.slug;
|
|
4543
|
+
var enc = encodeURIComponent(slug);
|
|
4544
|
+
try {
|
|
4545
|
+
await fn(slug);
|
|
4546
|
+
} catch (e) {
|
|
4547
|
+
if (e instanceof TypeError) return _redirect(res, "/admin/pages/" + enc + "?err=1");
|
|
4548
|
+
throw e;
|
|
4549
|
+
}
|
|
4550
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".page." + action, outcome: "success", metadata: { slug: slug } });
|
|
4551
|
+
// archive lands back on the list (the page left the editable
|
|
4552
|
+
// set); the others stay on the detail with a status banner.
|
|
4553
|
+
if (action === "archive") return _redirect(res, "/admin/pages?archived=1");
|
|
4554
|
+
_redirect(res, "/admin/pages/" + enc + (action === "publish" ? "?published=1" : "?updated=1"));
|
|
4555
|
+
},
|
|
4556
|
+
));
|
|
4557
|
+
}
|
|
4558
|
+
_pageTransition("publish", function (s) { return pages.publish(s); });
|
|
4559
|
+
_pageTransition("unpublish", function (s) { return pages.unpublish(s); });
|
|
4560
|
+
_pageTransition("restore", function (s) { return pages.restore(s); });
|
|
4561
|
+
|
|
4562
|
+
// Archive removes the page from the live storefront and the editable
|
|
4563
|
+
// set, so the browser path confirms first — the CSP forbids a client
|
|
4564
|
+
// confirm() dialog. Reached by a GET link from the detail screen;
|
|
4565
|
+
// bearer clients POST /archive directly.
|
|
4566
|
+
router.get("/admin/pages/:slug/archive/confirm-page", _pageOrApi(true,
|
|
4567
|
+
R(async function (_req, res) {
|
|
4568
|
+
return _problem(res, 405, "use-canonical-endpoint", "POST /admin/pages/:slug/archive directly for the JSON API");
|
|
4569
|
+
}),
|
|
4570
|
+
async function (req, res) {
|
|
4571
|
+
var slug = req.params.slug;
|
|
4572
|
+
var enc = encodeURIComponent(slug);
|
|
4573
|
+
var row = null;
|
|
4574
|
+
try { row = await pages.get(slug); }
|
|
4575
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
4576
|
+
if (!row) return _redirect(res, "/admin/pages?err=1");
|
|
4577
|
+
_sendHtml(res, 200, renderAdminConfirm({
|
|
4578
|
+
shop_name: deps.shop_name, nav_available: navAvailable, active: "pages",
|
|
4579
|
+
heading: "Archive this page?",
|
|
4580
|
+
consequence: "Archiving removes the page from the live storefront. You can restore it to a draft later.",
|
|
4581
|
+
detail: "Page: " + (row.title || slug) + ".",
|
|
4582
|
+
action: "/admin/pages/" + _htmlEscape(enc) + "/archive",
|
|
4583
|
+
confirm_label: "Archive page",
|
|
4584
|
+
cancel_href: "/admin/pages/" + enc,
|
|
4585
|
+
}));
|
|
4586
|
+
},
|
|
4587
|
+
));
|
|
4588
|
+
_pageTransition("archive", function (s) { return pages.archive(s); });
|
|
4589
|
+
}
|
|
4590
|
+
|
|
4359
4591
|
// ---- customer surveys -----------------------------------------------
|
|
4360
4592
|
// Define NPS/CSAT/CES/custom surveys, issue token invitations (the
|
|
4361
4593
|
// plaintext link is shown once on issue), and read the rollup. The
|
|
@@ -6844,6 +7076,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
6844
7076
|
{ key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
|
|
6845
7077
|
{ key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
|
|
6846
7078
|
{ key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
|
|
7079
|
+
{ key: "pages", href: "/admin/pages", label: "Pages", requires: "storefrontPages" },
|
|
6847
7080
|
{ key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
|
|
6848
7081
|
{ key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
|
|
6849
7082
|
{ key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
|
|
@@ -9961,6 +10194,203 @@ function renderAdminBlogDetail(opts) {
|
|
|
9961
10194
|
return _renderAdminShell(opts.shop_name, isNew ? "New post" : "Post " + a.slug, body, "blog", opts.nav_available);
|
|
9962
10195
|
}
|
|
9963
10196
|
|
|
10197
|
+
// Coerce the create form into storefrontPages.defineDraft's shape: trimmed
|
|
10198
|
+
// slug, the required title/body, and the optional meta + layout fields (a
|
|
10199
|
+
// blank optional field is omitted so the primitive's default applies). The
|
|
10200
|
+
// primitive validates every field — this only shapes the form strings.
|
|
10201
|
+
function _pageFromForm(body) {
|
|
10202
|
+
body = body || {};
|
|
10203
|
+
var out = {
|
|
10204
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
10205
|
+
title: body.title,
|
|
10206
|
+
body: body.body,
|
|
10207
|
+
};
|
|
10208
|
+
var md = typeof body.meta_description === "string" ? body.meta_description.trim() : "";
|
|
10209
|
+
if (md) out.meta_description = md;
|
|
10210
|
+
var mk = typeof body.meta_keywords === "string" ? body.meta_keywords.trim() : "";
|
|
10211
|
+
if (mk) out.meta_keywords = mk;
|
|
10212
|
+
var layout = typeof body.layout === "string" ? body.layout.trim() : "";
|
|
10213
|
+
if (layout) out.layout = layout;
|
|
10214
|
+
return out;
|
|
10215
|
+
}
|
|
10216
|
+
|
|
10217
|
+
// Coerce the edit form into a storefrontPages.update patch. Only the
|
|
10218
|
+
// columns the form carries are included; an empty optional meta field is
|
|
10219
|
+
// sent as "" so the operator can clear a previously-set meta line (the
|
|
10220
|
+
// primitive accepts an empty string for the nullable meta columns). Status
|
|
10221
|
+
// / slug are NOT here — status moves via the lifecycle routes; the slug is
|
|
10222
|
+
// the PK and immutable after create.
|
|
10223
|
+
function _pagePatchFromForm(body) {
|
|
10224
|
+
body = body || {};
|
|
10225
|
+
var patch = {};
|
|
10226
|
+
if (typeof body.title === "string") patch.title = body.title;
|
|
10227
|
+
if (typeof body.body === "string") patch.body = body.body;
|
|
10228
|
+
if (typeof body.meta_description === "string") patch.meta_description = body.meta_description.trim();
|
|
10229
|
+
if (typeof body.meta_keywords === "string") patch.meta_keywords = body.meta_keywords.trim();
|
|
10230
|
+
if (typeof body.layout === "string" && body.layout.trim()) patch.layout = body.layout.trim();
|
|
10231
|
+
return patch;
|
|
10232
|
+
}
|
|
10233
|
+
|
|
10234
|
+
// One status pill class per lifecycle state, reusing the order-status pill
|
|
10235
|
+
// palette: published = paid (green), draft = pending (amber), archived =
|
|
10236
|
+
// cancelled (grey). Mirrors the blog pill so the two content surfaces read
|
|
10237
|
+
// the same.
|
|
10238
|
+
function _pageStatusPill(status) {
|
|
10239
|
+
var cls = status === "published" ? "paid" : status === "archived" ? "cancelled" : "pending";
|
|
10240
|
+
return "<span class=\"status-pill " + cls + "\">" + _htmlEscape(status) + "</span>";
|
|
10241
|
+
}
|
|
10242
|
+
|
|
10243
|
+
// Layout <select> — the closed enum the migration + primitive enforce.
|
|
10244
|
+
// `current` pre-selects the page's layout (default when absent).
|
|
10245
|
+
var _PAGE_LAYOUTS = ["default", "wide", "landing", "legal"];
|
|
10246
|
+
function _pageLayoutSelect(current) {
|
|
10247
|
+
var sel = (typeof current === "string" && _PAGE_LAYOUTS.indexOf(current) !== -1) ? current : "default";
|
|
10248
|
+
var opts = _PAGE_LAYOUTS.map(function (l) {
|
|
10249
|
+
return "<option value=\"" + l + "\"" + (l === sel ? " selected" : "") + ">" + l + "</option>";
|
|
10250
|
+
}).join("");
|
|
10251
|
+
return "<label class=\"form-field\"><span>Layout</span>" +
|
|
10252
|
+
"<select name=\"layout\">" + opts + "</select>" +
|
|
10253
|
+
"<small>Picks the storefront wrapper: default, wide, landing, or legal.</small></label>";
|
|
10254
|
+
}
|
|
10255
|
+
|
|
10256
|
+
function renderAdminPages(opts) {
|
|
10257
|
+
opts = opts || {};
|
|
10258
|
+
var rows = opts.pages || [];
|
|
10259
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Page created as a draft.</div>" : "";
|
|
10260
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Page saved.</div>" : "";
|
|
10261
|
+
var published = opts.published ? "<div class=\"banner banner--ok\">Page published — it's live on the storefront.</div>" : "";
|
|
10262
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Page archived.</div>" : "";
|
|
10263
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
10264
|
+
|
|
10265
|
+
var sf = opts.status_filter;
|
|
10266
|
+
var chips = "<div class=\"order-filters\">" +
|
|
10267
|
+
"<a class=\"chip" + (sf == null ? " chip--on" : "") + "\" href=\"/admin/pages\">All</a>" +
|
|
10268
|
+
"<a class=\"chip" + (sf === "published" ? " chip--on" : "") + "\" href=\"/admin/pages?status=published\">Published</a>" +
|
|
10269
|
+
"<a class=\"chip" + (sf === "draft" ? " chip--on" : "") + "\" href=\"/admin/pages?status=draft\">Drafts</a>" +
|
|
10270
|
+
"<a class=\"chip" + (sf === "archived" ? " chip--on" : "") + "\" href=\"/admin/pages?status=archived\">Archived</a>" +
|
|
10271
|
+
"</div>";
|
|
10272
|
+
|
|
10273
|
+
var bodyRows = rows.map(function (p) {
|
|
10274
|
+
var enc = encodeURIComponent(p.slug);
|
|
10275
|
+
var date = p.published_at != null ? _fmtDate(p.published_at) : "—";
|
|
10276
|
+
// Per-row lifecycle actions match the page's current state: a draft
|
|
10277
|
+
// can publish; a published page can unpublish or archive; an archived
|
|
10278
|
+
// page can restore. Edit is always offered.
|
|
10279
|
+
var actions = "<a class=\"btn btn--ghost\" href=\"/admin/pages/" + _htmlEscape(enc) + "\">Edit</a>";
|
|
10280
|
+
if (p.status === "draft") {
|
|
10281
|
+
actions += "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/publish\" class=\"form-inline\">" +
|
|
10282
|
+
"<button class=\"btn\" type=\"submit\">Publish</button></form>";
|
|
10283
|
+
} else if (p.status === "published") {
|
|
10284
|
+
actions += "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/unpublish\" class=\"form-inline\">" +
|
|
10285
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Unpublish</button></form>" +
|
|
10286
|
+
"<a class=\"btn btn--danger\" href=\"/admin/pages/" + _htmlEscape(enc) + "/archive/confirm-page\">Archive</a>";
|
|
10287
|
+
} else if (p.status === "archived") {
|
|
10288
|
+
actions += "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/restore\" class=\"form-inline\">" +
|
|
10289
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>";
|
|
10290
|
+
}
|
|
10291
|
+
return "<tr>" +
|
|
10292
|
+
"<td><a href=\"/admin/pages/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(p.title) + "</strong></a></td>" +
|
|
10293
|
+
"<td><code class=\"order-id\">" + _htmlEscape(p.slug) + "</code></td>" +
|
|
10294
|
+
"<td>" + _pageStatusPill(p.status) + "</td>" +
|
|
10295
|
+
"<td>" + _htmlEscape(date) + "</td>" +
|
|
10296
|
+
"<td><div class=\"actions-row\">" + actions + "</div></td>" +
|
|
10297
|
+
"</tr>";
|
|
10298
|
+
}).join("");
|
|
10299
|
+
|
|
10300
|
+
var table = rows.length
|
|
10301
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Slug</th><th scope=\"col\">Status</th><th scope=\"col\">Published</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
10302
|
+
: "<p class=\"empty\">No pages" + (sf ? " " + _htmlEscape(sf) : " yet") + ". Write your first one.</p>";
|
|
10303
|
+
|
|
10304
|
+
var newBtn = "<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/pages/new\">New page</a></div>";
|
|
10305
|
+
|
|
10306
|
+
var bodyHtml = "<section><h2>Pages</h2>" +
|
|
10307
|
+
created + updated + published + archived + notice +
|
|
10308
|
+
"<p class=\"meta\">Content pages shown on the storefront at /pages/<slug> (About, Shipping, Returns, and the like). A page is created as a draft and stays hidden until you publish it.</p>" +
|
|
10309
|
+
newBtn + chips + table + "</section>";
|
|
10310
|
+
return _renderAdminShell(opts.shop_name, "Pages", bodyHtml, "pages", opts.nav_available);
|
|
10311
|
+
}
|
|
10312
|
+
|
|
10313
|
+
// New-page form (page: null) or edit form (page set) for a single content
|
|
10314
|
+
// page, plus the lifecycle action row when editing an existing page.
|
|
10315
|
+
// `form_values` re-fills a failed create so the operator doesn't retype.
|
|
10316
|
+
function renderAdminPageDetail(opts) {
|
|
10317
|
+
opts = opts || {};
|
|
10318
|
+
var p = opts.page || null;
|
|
10319
|
+
var isNew = !p;
|
|
10320
|
+
var fv = opts.form_values || {};
|
|
10321
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Page saved.</div>" : "";
|
|
10322
|
+
var published = opts.published ? "<div class=\"banner banner--ok\">Page published — it's live on the storefront.</div>" : "";
|
|
10323
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
10324
|
+
|
|
10325
|
+
// Field values: the existing row when editing, else the failed-create
|
|
10326
|
+
// form values, else blank.
|
|
10327
|
+
function _val(col) {
|
|
10328
|
+
if (p && p[col] != null) return p[col];
|
|
10329
|
+
if (fv[col] != null) return fv[col];
|
|
10330
|
+
return "";
|
|
10331
|
+
}
|
|
10332
|
+
|
|
10333
|
+
var action = isNew ? "/admin/pages" : "/admin/pages/" + encodeURIComponent(p.slug) + "/edit";
|
|
10334
|
+
// Slug is the PK — editable only on create (the primitive keys update on
|
|
10335
|
+
// it). On edit it shows read-only so the operator sees the storefront URL.
|
|
10336
|
+
var slugField = isNew
|
|
10337
|
+
? _setupField("Slug", "slug", _val("slug"), "text", "Letters, digits, dots, hyphens, underscores — appears in /pages/<slug>.", " maxlength=\"80\" required")
|
|
10338
|
+
: "<label class=\"form-field\"><span>Slug</span><input type=\"text\" value=\"" + _htmlEscape(p.slug) + "\" readonly>" +
|
|
10339
|
+
"<small>The storefront URL: <code>/pages/" + _htmlEscape(p.slug) + "</code></small></label>";
|
|
10340
|
+
|
|
10341
|
+
var form =
|
|
10342
|
+
"<div class=\"panel mw-40\">" +
|
|
10343
|
+
"<h3 class=\"subhead\">" + (isNew ? "New page" : "Page details") + "</h3>" +
|
|
10344
|
+
"<form method=\"post\" action=\"" + _htmlEscape(action) + "\">" +
|
|
10345
|
+
slugField +
|
|
10346
|
+
_setupField("Title", "title", _val("title"), "text", "Shown as the page heading and the <title> tag.", " maxlength=\"200\" required") +
|
|
10347
|
+
"<label class=\"form-field\"><span>Body (Markdown)</span>" +
|
|
10348
|
+
"<textarea name=\"body\" rows=\"18\" maxlength=\"200000\" required>" + _htmlEscape(_val("body")) + "</textarea>" +
|
|
10349
|
+
"<small>Headings, lists, links, bold/italic. Raw HTML is escaped — links are https-only or /-rooted.</small></label>" +
|
|
10350
|
+
_pageLayoutSelect(p ? p.layout : (typeof fv.layout === "string" ? fv.layout : "default")) +
|
|
10351
|
+
_setupField("Meta description (optional)", "meta_description", _val("meta_description"), "text", "Shown in search results + the page's <head>.", " maxlength=\"320\"") +
|
|
10352
|
+
_setupField("Meta keywords (optional)", "meta_keywords", _val("meta_keywords"), "text", "", " maxlength=\"320\"") +
|
|
10353
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">" + (isNew ? "Create draft" : "Save") + "</button>" +
|
|
10354
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/pages\">Back</a></div>" +
|
|
10355
|
+
"</form>" +
|
|
10356
|
+
"</div>";
|
|
10357
|
+
|
|
10358
|
+
var lifecycle = "";
|
|
10359
|
+
if (!isNew) {
|
|
10360
|
+
var enc = encodeURIComponent(p.slug);
|
|
10361
|
+
var btns = "";
|
|
10362
|
+
if (p.status === "draft") {
|
|
10363
|
+
btns = "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/publish\" class=\"form-inline\">" +
|
|
10364
|
+
"<button class=\"btn\" type=\"submit\">Publish</button></form>" +
|
|
10365
|
+
"<span class=\"meta\">Publishing makes the page live on the storefront.</span>";
|
|
10366
|
+
} else if (p.status === "published") {
|
|
10367
|
+
btns = "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/unpublish\" class=\"form-inline\">" +
|
|
10368
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Unpublish</button></form>" +
|
|
10369
|
+
"<a class=\"btn btn--danger\" href=\"/admin/pages/" + _htmlEscape(enc) + "/archive/confirm-page\">Archive</a>" +
|
|
10370
|
+
"<span class=\"meta\">Unpublish pulls it back to a draft; archive removes it from the storefront.</span>";
|
|
10371
|
+
} else if (p.status === "archived") {
|
|
10372
|
+
btns = "<form method=\"post\" action=\"/admin/pages/" + _htmlEscape(enc) + "/restore\" class=\"form-inline\">" +
|
|
10373
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Restore to draft</button></form>";
|
|
10374
|
+
}
|
|
10375
|
+
lifecycle = "<div class=\"panel mt-1 mw-40\"><h3 class=\"subhead\">Status</h3>" +
|
|
10376
|
+
"<p class=\"meta\">Current state: " + _pageStatusPill(p.status) + "</p>" +
|
|
10377
|
+
"<div class=\"actions-row\">" + btns + "</div></div>";
|
|
10378
|
+
}
|
|
10379
|
+
|
|
10380
|
+
var head = isNew
|
|
10381
|
+
? "<p class=\"meta\"><a href=\"/admin/pages\">← Pages</a></p>"
|
|
10382
|
+
: "<p class=\"meta\"><a href=\"/admin/pages\">← Pages</a> · " +
|
|
10383
|
+
_pageStatusPill(p.status) +
|
|
10384
|
+
(p.status === "published"
|
|
10385
|
+
? " · <a href=\"/pages/" + _htmlEscape(encodeURIComponent(p.slug)) + "\" target=\"_blank\" rel=\"noreferrer\">View on storefront →</a>"
|
|
10386
|
+
: "") +
|
|
10387
|
+
"</p>";
|
|
10388
|
+
|
|
10389
|
+
var title = isNew ? "New page" : (p.title || p.slug);
|
|
10390
|
+
var body = "<section><h2>" + _htmlEscape(title) + "</h2>" + updated + published + notice + head + form + "</section>" + lifecycle;
|
|
10391
|
+
return _renderAdminShell(opts.shop_name, isNew ? "New page" : "Page " + p.slug, body, "pages", opts.nav_available);
|
|
10392
|
+
}
|
|
10393
|
+
|
|
9964
10394
|
// Standard question set per survey kind, so the console can create the
|
|
9965
10395
|
// common surveys without a dynamic question builder (fully custom question
|
|
9966
10396
|
// lists go through the JSON defineSurvey API). Each question carries a
|
|
@@ -10781,6 +11211,8 @@ module.exports = {
|
|
|
10781
11211
|
renderAdminCollection: renderAdminCollection,
|
|
10782
11212
|
renderAdminBlog: renderAdminBlog,
|
|
10783
11213
|
renderAdminBlogDetail: renderAdminBlogDetail,
|
|
11214
|
+
renderAdminPages: renderAdminPages,
|
|
11215
|
+
renderAdminPageDetail: renderAdminPageDetail,
|
|
10784
11216
|
renderAdminGiftCards: renderAdminGiftCards,
|
|
10785
11217
|
renderAdminGiftCard: renderAdminGiftCard,
|
|
10786
11218
|
renderAdminTaxRates: renderAdminTaxRates,
|
package/lib/asset-manifest.json
CHANGED
package/lib/storefront.js
CHANGED
|
@@ -4569,6 +4569,20 @@ function _orderEligibleForReorder(status) {
|
|
|
4569
4569
|
return status !== "pending";
|
|
4570
4570
|
}
|
|
4571
4571
|
|
|
4572
|
+
// Cancel is offered only while the order is still pre-fulfillment: the
|
|
4573
|
+
// order FSM (lib/order.js) accepts the `cancel` event from `pending`
|
|
4574
|
+
// (awaiting capture) and `paid` (captured, not yet picked) only. Once
|
|
4575
|
+
// the warehouse starts fulfilling — and through shipped / delivered —
|
|
4576
|
+
// the order is no longer the customer's to cancel; the terminal off-ramps
|
|
4577
|
+
// (cancelled / refunded) have no cancel edge either. Keeping this in lock-
|
|
4578
|
+
// step with the FSM's cancel edges means the button never offers a
|
|
4579
|
+
// transition the primitive would refuse. A cancel on a `paid` order does
|
|
4580
|
+
// NOT void the captured charge — the FSM only moves the status — so a paid
|
|
4581
|
+
// cancel leaves the operator to issue the refund from the console.
|
|
4582
|
+
function _orderEligibleForCancel(status) {
|
|
4583
|
+
return status === "pending" || status === "paid";
|
|
4584
|
+
}
|
|
4585
|
+
|
|
4572
4586
|
// Render the lifecycle timeline. Every step up to and including the
|
|
4573
4587
|
// current status is marked done; the current step is also marked
|
|
4574
4588
|
// current. A terminal off-ramp (refunded / cancelled) collapses the rail
|
|
@@ -4669,6 +4683,12 @@ function _orderActionsBlock(o) {
|
|
|
4669
4683
|
btns.push(
|
|
4670
4684
|
"<a class=\"btn-secondary order-action\" href=\"/account/orders/" + esc(String(o.id)) + "/return\">Request a return</a>");
|
|
4671
4685
|
}
|
|
4686
|
+
if (_orderEligibleForCancel(o.status)) {
|
|
4687
|
+
btns.push(
|
|
4688
|
+
"<form class=\"order-action\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/cancel\">" +
|
|
4689
|
+
"<button type=\"submit\" class=\"btn-ghost\">Cancel order</button>" +
|
|
4690
|
+
"</form>");
|
|
4691
|
+
}
|
|
4672
4692
|
if (!btns.length) return "";
|
|
4673
4693
|
return "<div class=\"order-page__actions\">" + btns.join("") + "</div>";
|
|
4674
4694
|
}
|
|
@@ -4733,6 +4753,7 @@ function renderOrder(opts) {
|
|
|
4733
4753
|
actions_html: actionsHtml,
|
|
4734
4754
|
can_return: _orderEligibleForReturn(o.status),
|
|
4735
4755
|
can_reorder: _orderEligibleForReorder(o.status),
|
|
4756
|
+
can_cancel: _orderEligibleForCancel(o.status),
|
|
4736
4757
|
recommendations: recs,
|
|
4737
4758
|
has_recommendations: recs.length > 0,
|
|
4738
4759
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
@@ -4759,6 +4780,14 @@ function renderOrder(opts) {
|
|
|
4759
4780
|
var reorderNotice = opts.reordered
|
|
4760
4781
|
? "<p class=\"form-notice form-notice--ok\" role=\"status\">Items from this order were added to your cart. <a href=\"/cart\">View cart →</a></p>"
|
|
4761
4782
|
: "";
|
|
4783
|
+
// Confirmation banner after a successful cancel (the POST redirects to
|
|
4784
|
+
// ?cancelled=1). A paid order's captured charge is not auto-voided by the
|
|
4785
|
+
// cancel — the refund is the operator's call from the console — so the
|
|
4786
|
+
// copy stays neutral ("cancelled") rather than promising a refund the
|
|
4787
|
+
// status transition didn't perform.
|
|
4788
|
+
var cancelNotice = opts.cancelled
|
|
4789
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">This order has been cancelled.</p>"
|
|
4790
|
+
: "";
|
|
4762
4791
|
var body = _render(ORDER_PAGE, {
|
|
4763
4792
|
order_id: o.id,
|
|
4764
4793
|
status: o.status,
|
|
@@ -4768,7 +4797,7 @@ function renderOrder(opts) {
|
|
|
4768
4797
|
shipping: shipping,
|
|
4769
4798
|
total: total,
|
|
4770
4799
|
}).replace("RAW_LINES", rows)
|
|
4771
|
-
.replace("RAW_REORDER_NOTICE", reorderNotice)
|
|
4800
|
+
.replace("RAW_REORDER_NOTICE", reorderNotice + cancelNotice)
|
|
4772
4801
|
.replace("RAW_ORDER_TIMELINE", timelineHtml)
|
|
4773
4802
|
.replace("RAW_ORDER_TRACKING", trackingHtml)
|
|
4774
4803
|
.replace("RAW_ORDER_ACTIONS", actionsHtml)
|
|
@@ -8557,6 +8586,7 @@ function mount(router, deps) {
|
|
|
8557
8586
|
recommendations: recommendations,
|
|
8558
8587
|
shipments: shipments,
|
|
8559
8588
|
reordered: ordUrl ? ordUrl.searchParams.get("reordered") === "1" : false,
|
|
8589
|
+
cancelled: ordUrl ? ordUrl.searchParams.get("cancelled") === "1" : false,
|
|
8560
8590
|
shop_name: shopName,
|
|
8561
8591
|
theme: theme,
|
|
8562
8592
|
}));
|
|
@@ -10895,6 +10925,68 @@ function mount(router, deps) {
|
|
|
10895
10925
|
res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id) + "?reordered=1");
|
|
10896
10926
|
return res.end ? res.end() : res.send("");
|
|
10897
10927
|
});
|
|
10928
|
+
|
|
10929
|
+
// POST /orders/:id/cancel — customer-initiated cancellation of an
|
|
10930
|
+
// order that hasn't been fulfilled yet. The order FSM (lib/order.js)
|
|
10931
|
+
// accepts the `cancel` event from `pending` and `paid` only; this
|
|
10932
|
+
// route gates on the same eligibility (_orderEligibleForCancel) so a
|
|
10933
|
+
// shipped / delivered / already-cancelled / refunded order can't be
|
|
10934
|
+
// cancelled here. Two refusals guard against IDOR: the order must
|
|
10935
|
+
// belong to the signed-in customer (a foreign or guest-owned order is
|
|
10936
|
+
// a clean 404, never acted on and never leaked), and only then is the
|
|
10937
|
+
// transition attempted. A cancel the FSM still refuses (a status that
|
|
10938
|
+
// raced past `paid` between the page render and the POST) maps to a
|
|
10939
|
+
// clean redirect back to the order rather than a 500. Cancelling a
|
|
10940
|
+
// `paid` order moves the status only — the captured charge is NOT
|
|
10941
|
+
// auto-voided by the transition, so the refund remains the operator's
|
|
10942
|
+
// action from the console.
|
|
10943
|
+
router.post("/orders/:order_id/cancel", async function (req, res) {
|
|
10944
|
+
var orderId = req.params && req.params.order_id;
|
|
10945
|
+
// Cancel is a logged-in-customer action — resolve the session first
|
|
10946
|
+
// so an unauthenticated POST goes to login, never near the order.
|
|
10947
|
+
var cancelAuth = _currentCustomerEnv(req);
|
|
10948
|
+
if (!cancelAuth) {
|
|
10949
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
10950
|
+
return res.end ? res.end() : res.send("");
|
|
10951
|
+
}
|
|
10952
|
+
var o;
|
|
10953
|
+
try { o = orderId ? await deps.order.get(orderId) : null; }
|
|
10954
|
+
catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
|
|
10955
|
+
// Ownership gate against IDOR: a missing order, a malformed id, an
|
|
10956
|
+
// order owned by another customer, OR a guest order with no owner
|
|
10957
|
+
// all 404 — the cancel never touches an order the caller doesn't own.
|
|
10958
|
+
if (!o || !o.customer_id || o.customer_id !== cancelAuth.customer_id) {
|
|
10959
|
+
return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
10960
|
+
}
|
|
10961
|
+
// Eligibility gate mirrors the FSM's cancel edges (pending | paid).
|
|
10962
|
+
// A non-cancellable status (fulfilling / shipped / delivered /
|
|
10963
|
+
// cancelled / refunded) bounces back to the order page unchanged —
|
|
10964
|
+
// a clean 303, no transition attempted, no 500.
|
|
10965
|
+
if (!_orderEligibleForCancel(o.status)) {
|
|
10966
|
+
res.status(303);
|
|
10967
|
+
res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
|
|
10968
|
+
return res.end ? res.end() : res.send("");
|
|
10969
|
+
}
|
|
10970
|
+
try {
|
|
10971
|
+
await deps.order.transition(o.id, "cancel", { reason: "customer-requested" });
|
|
10972
|
+
} catch (e) {
|
|
10973
|
+
// The FSM refuses the event (a status that advanced out of the
|
|
10974
|
+
// cancellable window between render and POST). order.transition
|
|
10975
|
+
// tags the refusal with .code = ORDER_TRANSITION_REFUSED; surface
|
|
10976
|
+
// it as a clean redirect to the order page rather than a 500.
|
|
10977
|
+
if (e && e.code === "ORDER_TRANSITION_REFUSED") {
|
|
10978
|
+
res.status(303);
|
|
10979
|
+
res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
|
|
10980
|
+
return res.end ? res.end() : res.send("");
|
|
10981
|
+
}
|
|
10982
|
+
throw e;
|
|
10983
|
+
}
|
|
10984
|
+
// PRG back to the order page with a confirmation banner (a refresh
|
|
10985
|
+
// doesn't re-fire the cancel).
|
|
10986
|
+
res.status(303);
|
|
10987
|
+
res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id) + "?cancelled=1");
|
|
10988
|
+
return res.end ? res.end() : res.send("");
|
|
10989
|
+
});
|
|
10898
10990
|
}
|
|
10899
10991
|
|
|
10900
10992
|
// POST /cart/lines — add a line. Reads variant_id + qty from the
|
package/package.json
CHANGED