@blamejs/blamejs-shop 0.3.17 → 0.3.19
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 +866 -16
- package/lib/asset-manifest.json +1 -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.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.
|
|
12
|
+
|
|
13
|
+
- v0.3.18 (2026-05-30) — **Write and publish blog posts from the admin console.** The storefront already served a blog — the index at /blog, individual posts at /blog/:slug, an RSS feed, and sitemap entries — but there was no way to write a post, so it rendered empty. The admin console now has a Blog screen with the full lifecycle: create a draft, edit its title, body (Markdown), tags, hero image, and meta fields, publish it, unpublish it, and archive or restore it. A post is created as a draft and stays off the storefront until it is explicitly published, so work in progress is never visible to shoppers. **Added:** *Blog authoring in the admin console* — A new Blog screen lists posts by status and lets an operator create, edit, publish, unpublish, archive, and restore them. New posts start as drafts and do not appear on the storefront /blog, its RSS feed, or the sitemap until published; archiving removes a published post from the storefront and is reversible. This wires the admin over the blog backend that was already serving the customer-facing pages.
|
|
14
|
+
|
|
11
15
|
- v0.3.17 (2026-05-30) — **Search results paginate with an accurate count, and collection pages get canonical URLs.** Two customer-facing storefront fixes. Search no longer stops at the first 24 products: the result count now reports the true number of matches instead of the page size, and numbered page links (with prev/next) make every matching product reachable while preserving the active query and filters. Separately, collection pages now emit a correct canonical and og:url so search engines and shared links resolve to one address, while the cart and account pages — which should not be indexed — are explicitly marked noindex. **Fixed:** *Search shows the real match count and paginates* — The search results page previously capped at 24 products, discarded the real total, and reported the page size as the match count — so anything past the first 24 was unreachable and the count was wrong. It now computes the true total, shows it in the "Showing N matches" summary, and adds no-JavaScript numbered page links (with prev/next) that carry the active query and facet filters, so every matching product can be reached. · *Collection pages have canonical URLs; cart and account are noindex* — Collection pages now emit an absolute canonical and og:url derived from the request host, so indexers and shared links resolve to a single address instead of an empty tag. The cart and account pages, which are not meant to be indexed, now carry a noindex robots tag in their markup, consistent on both the edge and container render paths.
|
|
12
16
|
|
|
13
17
|
- v0.3.16 (2026-05-30) — **Uploaded SVGs are sanitized, and product images can be reordered with a chosen primary.** Two things for product media in the admin console. First, when an SVG image is uploaded it now passes through a strict sanitizer: an SVG that carries script or other active content is repaired, or refused outright when it can't be made safe, so a stored image can't become a script-execution vector. Second, a product's images can now be reordered and any image promoted to the primary (hero) slot directly from the console — previously the order was fixed at attach time and the first-attached image was always the hero. The primary image is the one shown first on the product page. **Added:** *Reorder product images and set the primary from the console* — Each image on a product now has a "Make primary" control that moves it to the hero slot shown first on the product page, and a reorder control sets the full order. Both are no-JavaScript form actions. The product page renders images in this order, so changing it here changes what shoppers see first. **Changed:** *Vendored runtime updated to v0.14.8* — The vendored blamejs runtime is updated to v0.14.8, an internal source-comment and lint-hygiene release with no API, wire-format, or behavior changes. No operator action is required. **Security:** *Uploaded SVG images are sanitized or refused* — An uploaded SVG is now checked by a strict sanitizer before it is stored: active content (scripts, event handlers, external references) is stripped, and an SVG that cannot be made safe is rejected with a clear error and never written. This closes the path where a crafted SVG, once stored and served, could execute script. Raster image uploads (PNG, JPEG, WebP, GIF) are unaffected.
|
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, 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, 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
|
|
|
@@ -4005,6 +4005,55 @@ function mount(router, deps) {
|
|
|
4005
4005
|
},
|
|
4006
4006
|
));
|
|
4007
4007
|
|
|
4008
|
+
// Detail screen: the single announcement + its edit form. Content-
|
|
4009
|
+
// negotiates like the list — bearer → the JSON row; browser cookie →
|
|
4010
|
+
// the rendered edit page. A bad / unknown slug is a 404 page (browser)
|
|
4011
|
+
// or 404 problem (bearer), never a 500.
|
|
4012
|
+
router.get("/admin/announcements/:slug", _pageOrApi(true,
|
|
4013
|
+
R(async function (req, res) {
|
|
4014
|
+
var row = await announcements.getAnnouncement(req.params.slug);
|
|
4015
|
+
if (!row) return _problem(res, 404, "announcement-not-found");
|
|
4016
|
+
_json(res, 200, row);
|
|
4017
|
+
}),
|
|
4018
|
+
async function (req, res) {
|
|
4019
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4020
|
+
var row = await announcements.getAnnouncement(req.params.slug);
|
|
4021
|
+
if (!row) return _sendHtml(res, 404, renderAdminAnnouncements({
|
|
4022
|
+
shop_name: deps.shop_name, nav_available: navAvailable, announcements: [], notice: "Announcement not found.",
|
|
4023
|
+
}));
|
|
4024
|
+
_sendHtml(res, 200, renderAdminAnnouncement({
|
|
4025
|
+
shop_name: deps.shop_name, nav_available: navAvailable, announcement: row,
|
|
4026
|
+
updated: url && url.searchParams.get("updated"),
|
|
4027
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the announcement." : null,
|
|
4028
|
+
}));
|
|
4029
|
+
},
|
|
4030
|
+
));
|
|
4031
|
+
|
|
4032
|
+
// Edit content-negotiates: bearer POST /edit (JSON) + browser POST
|
|
4033
|
+
// /edit (HTML forms can't PATCH). Both forward the full editable
|
|
4034
|
+
// column set — message, link, audience, schedule, dismissible — into
|
|
4035
|
+
// updateAnnouncement, preserving the slug + accumulated dismissal
|
|
4036
|
+
// state (archive-and-recreate would discard both). PRG to ?updated=1;
|
|
4037
|
+
// a bad shape is a clean 400 (bearer) / err notice (browser).
|
|
4038
|
+
router.post("/admin/announcements/:slug/edit", _pageOrApi(false,
|
|
4039
|
+
W("announcement.update", async function (req, res) {
|
|
4040
|
+
var row;
|
|
4041
|
+
try { row = await announcements.updateAnnouncement(req.params.slug, _announcementPatchFromForm(req.body || {})); }
|
|
4042
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
4043
|
+
if (!row) return _problem(res, 404, "announcement-not-found");
|
|
4044
|
+
_json(res, 200, row);
|
|
4045
|
+
return { id: row.slug };
|
|
4046
|
+
}),
|
|
4047
|
+
async function (req, res) {
|
|
4048
|
+
var slug = req.params.slug;
|
|
4049
|
+
var enc = encodeURIComponent(slug);
|
|
4050
|
+
try { await announcements.updateAnnouncement(slug, _announcementPatchFromForm(req.body || {})); }
|
|
4051
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/announcements/" + enc + "?err=1"); }
|
|
4052
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".announcement.update", outcome: "success", metadata: { slug: slug } });
|
|
4053
|
+
_redirect(res, "/admin/announcements/" + enc + "?updated=1");
|
|
4054
|
+
},
|
|
4055
|
+
));
|
|
4056
|
+
|
|
4008
4057
|
router.post("/admin/announcements/:slug/archive", _pageOrApi(false,
|
|
4009
4058
|
W("announcement.archive", async function (req, res) {
|
|
4010
4059
|
var row;
|
|
@@ -4026,6 +4075,245 @@ function mount(router, deps) {
|
|
|
4026
4075
|
));
|
|
4027
4076
|
}
|
|
4028
4077
|
|
|
4078
|
+
// ---- blog -----------------------------------------------------------
|
|
4079
|
+
// Author the operator's editorial blog. The edge Worker serves the
|
|
4080
|
+
// customer-facing /blog index + /blog/:slug posts + the RSS feed,
|
|
4081
|
+
// reading ONLY published rows; this console writes them. A post is
|
|
4082
|
+
// created as a draft and stays invisible to the storefront until it's
|
|
4083
|
+
// published — publish / unpublish / archive / restore move it through
|
|
4084
|
+
// the lifecycle (draft → published → archived, with restore back to
|
|
4085
|
+
// draft). Content-negotiated like the other screens: bearer → the JSON
|
|
4086
|
+
// contract; signed-in browser → the HTML list + author forms.
|
|
4087
|
+
if (deps.blog) {
|
|
4088
|
+
var blog = deps.blog;
|
|
4089
|
+
|
|
4090
|
+
// The list pulls each lifecycle state separately (the primitive has
|
|
4091
|
+
// listDrafts / listArchived + listPublished) and merges them for the
|
|
4092
|
+
// console table. The published set is the first page of the cursor-
|
|
4093
|
+
// paginated feed — an editorial corpus that the console doesn't page
|
|
4094
|
+
// (operators have tens of posts); the storefront's /blog is the
|
|
4095
|
+
// paginated reader.
|
|
4096
|
+
async function _blogRows(filter) {
|
|
4097
|
+
var drafts = await blog.listDrafts();
|
|
4098
|
+
var archived = await blog.listArchived();
|
|
4099
|
+
var published = (await blog.listPublished({ limit: blog.MAX_LIST_LIMIT })).rows;
|
|
4100
|
+
if (filter === "draft") return drafts;
|
|
4101
|
+
if (filter === "published") return published;
|
|
4102
|
+
if (filter === "archived") return archived;
|
|
4103
|
+
// All: published first (newest-live), then drafts, then archived.
|
|
4104
|
+
return published.concat(drafts).concat(archived);
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
router.get("/admin/blog", _pageOrApi(true,
|
|
4108
|
+
R(async function (req, res) {
|
|
4109
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4110
|
+
var status = (url && url.searchParams.get("status")) || null;
|
|
4111
|
+
_json(res, 200, { rows: await _blogRows(status) });
|
|
4112
|
+
}),
|
|
4113
|
+
async function (req, res) {
|
|
4114
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4115
|
+
var status = (url && url.searchParams.get("status")) || null;
|
|
4116
|
+
var rows = await _blogRows(status);
|
|
4117
|
+
_sendHtml(res, 200, renderAdminBlog({
|
|
4118
|
+
shop_name: deps.shop_name, nav_available: navAvailable, articles: rows,
|
|
4119
|
+
status_filter: status,
|
|
4120
|
+
created: url && url.searchParams.get("created"),
|
|
4121
|
+
updated: url && url.searchParams.get("updated"),
|
|
4122
|
+
published: url && url.searchParams.get("published"),
|
|
4123
|
+
archived: url && url.searchParams.get("archived"),
|
|
4124
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the post." : null,
|
|
4125
|
+
}));
|
|
4126
|
+
},
|
|
4127
|
+
));
|
|
4128
|
+
|
|
4129
|
+
// The new-post form. A standalone GET so the "New post" button on the
|
|
4130
|
+
// list has a target; the create POST lives at /admin/blog.
|
|
4131
|
+
router.get("/admin/blog/new", _pageOrApi(true,
|
|
4132
|
+
R(async function (_req, res) {
|
|
4133
|
+
return _problem(res, 405, "use-canonical-endpoint", "POST /admin/blog with a JSON body to create a post");
|
|
4134
|
+
}),
|
|
4135
|
+
async function (_req, res) {
|
|
4136
|
+
_sendHtml(res, 200, renderAdminBlogDetail({
|
|
4137
|
+
shop_name: deps.shop_name, nav_available: navAvailable, article: null,
|
|
4138
|
+
}));
|
|
4139
|
+
},
|
|
4140
|
+
));
|
|
4141
|
+
|
|
4142
|
+
// Create content-negotiates: bearer → JSON 201 (a draft row); browser
|
|
4143
|
+
// form → createDraft, then PRG to the post's detail screen to keep
|
|
4144
|
+
// authoring. Every post is born a DRAFT — never published on create —
|
|
4145
|
+
// so it can't reach the storefront before the operator publishes it.
|
|
4146
|
+
router.post("/admin/blog", _pageOrApi(false,
|
|
4147
|
+
W("blog.create", async function (req, res) {
|
|
4148
|
+
var row = await blog.createDraft(req.body || {});
|
|
4149
|
+
_json(res, 201, row);
|
|
4150
|
+
return { id: row.slug };
|
|
4151
|
+
}),
|
|
4152
|
+
async function (req, res) {
|
|
4153
|
+
var made;
|
|
4154
|
+
try {
|
|
4155
|
+
made = await blog.createDraft(_blogFromForm(req.body || {}));
|
|
4156
|
+
} catch (e) {
|
|
4157
|
+
var n = _safeNotice(e, "blog.create");
|
|
4158
|
+
return _sendHtml(res, n.status, renderAdminBlogDetail({
|
|
4159
|
+
shop_name: deps.shop_name, nav_available: navAvailable, article: null,
|
|
4160
|
+
form_values: req.body || {},
|
|
4161
|
+
notice: n.message.replace(/^blogArticles[.:]\s*/, ""),
|
|
4162
|
+
}));
|
|
4163
|
+
}
|
|
4164
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".blog.create", outcome: "success", metadata: { slug: made.slug } });
|
|
4165
|
+
_redirect(res, "/admin/blog/" + encodeURIComponent(made.slug) + "?created=1");
|
|
4166
|
+
},
|
|
4167
|
+
));
|
|
4168
|
+
|
|
4169
|
+
// Detail content-negotiates: bearer → JSON (the row, any status);
|
|
4170
|
+
// browser → the edit form + the lifecycle action buttons. A bad /
|
|
4171
|
+
// unknown slug is a 404 page, never a 500.
|
|
4172
|
+
router.get("/admin/blog/:slug", _pageOrApi(true,
|
|
4173
|
+
R(async function (req, res) {
|
|
4174
|
+
var row;
|
|
4175
|
+
try { row = await blog.get(req.params.slug); }
|
|
4176
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 404, "blog-post-not-found", e.message); throw e; }
|
|
4177
|
+
if (!row) return _problem(res, 404, "blog-post-not-found");
|
|
4178
|
+
_json(res, 200, row);
|
|
4179
|
+
}),
|
|
4180
|
+
async function (req, res) {
|
|
4181
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4182
|
+
var row;
|
|
4183
|
+
try { row = await blog.get(req.params.slug); }
|
|
4184
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; row = null; }
|
|
4185
|
+
if (!row) return _sendHtml(res, 404, renderAdminBlog({
|
|
4186
|
+
shop_name: deps.shop_name, nav_available: navAvailable, articles: [], notice: "Post not found.",
|
|
4187
|
+
}));
|
|
4188
|
+
_sendHtml(res, 200, renderAdminBlogDetail({
|
|
4189
|
+
shop_name: deps.shop_name, nav_available: navAvailable, article: row,
|
|
4190
|
+
updated: url && url.searchParams.get("updated"),
|
|
4191
|
+
published: url && url.searchParams.get("published"),
|
|
4192
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the post." : null,
|
|
4193
|
+
}));
|
|
4194
|
+
},
|
|
4195
|
+
));
|
|
4196
|
+
|
|
4197
|
+
// Edit content-negotiates: bearer PATCH (the JSON contract) + browser
|
|
4198
|
+
// POST /edit (HTML forms can't PATCH). Both patch the editable columns
|
|
4199
|
+
// (title / body / author / tags / meta / hero image). Status is NOT
|
|
4200
|
+
// editable here — it moves via the lifecycle routes below.
|
|
4201
|
+
router.patch("/admin/blog/:slug", W("blog.update", async function (req, res) {
|
|
4202
|
+
var row;
|
|
4203
|
+
try { row = await blog.update(req.params.slug, req.body || {}); }
|
|
4204
|
+
catch (e) {
|
|
4205
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
4206
|
+
if (e && e.code === "BLOG_ARTICLE_NOT_FOUND") return _problem(res, 404, "blog-post-not-found");
|
|
4207
|
+
throw e;
|
|
4208
|
+
}
|
|
4209
|
+
_json(res, 200, row);
|
|
4210
|
+
return { id: row.slug };
|
|
4211
|
+
}));
|
|
4212
|
+
|
|
4213
|
+
router.post("/admin/blog/:slug/edit", _pageOrApi(false,
|
|
4214
|
+
W("blog.update", async function (req, res) {
|
|
4215
|
+
var row;
|
|
4216
|
+
try { row = await blog.update(req.params.slug, req.body || {}); }
|
|
4217
|
+
catch (e) {
|
|
4218
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
4219
|
+
if (e && e.code === "BLOG_ARTICLE_NOT_FOUND") return _problem(res, 404, "blog-post-not-found");
|
|
4220
|
+
throw e;
|
|
4221
|
+
}
|
|
4222
|
+
_json(res, 200, row);
|
|
4223
|
+
return { id: row.slug };
|
|
4224
|
+
}),
|
|
4225
|
+
async function (req, res) {
|
|
4226
|
+
var slug = req.params.slug;
|
|
4227
|
+
var enc = encodeURIComponent(slug);
|
|
4228
|
+
try {
|
|
4229
|
+
await blog.update(slug, _blogPatchFromForm(req.body || {}));
|
|
4230
|
+
} catch (e) {
|
|
4231
|
+
if (e && e.code === "BLOG_ARTICLE_NOT_FOUND") return _redirect(res, "/admin/blog?err=1");
|
|
4232
|
+
var n = _safeNotice(e, "blog.update");
|
|
4233
|
+
var current = null;
|
|
4234
|
+
try { current = await blog.get(slug); } catch (_e) { current = null; }
|
|
4235
|
+
if (!current) return _redirect(res, "/admin/blog?err=1");
|
|
4236
|
+
return _sendHtml(res, n.status, renderAdminBlogDetail({
|
|
4237
|
+
shop_name: deps.shop_name, nav_available: navAvailable, article: current,
|
|
4238
|
+
notice: n.message.replace(/^blogArticles[.:]\s*/, ""),
|
|
4239
|
+
}));
|
|
4240
|
+
}
|
|
4241
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".blog.update", outcome: "success", metadata: { slug: slug } });
|
|
4242
|
+
_redirect(res, "/admin/blog/" + enc + "?updated=1");
|
|
4243
|
+
},
|
|
4244
|
+
));
|
|
4245
|
+
|
|
4246
|
+
// Lifecycle transitions — each PRGs back to the detail. publish takes
|
|
4247
|
+
// a draft live (now visible on the storefront); unpublish pulls it
|
|
4248
|
+
// back to draft (gone from the storefront); restore returns an
|
|
4249
|
+
// archived post to draft. An illegal transition (wrong from-state) or
|
|
4250
|
+
// a missing slug is a ?err=1 notice, never a 500.
|
|
4251
|
+
function _blogTransition(action, fn) {
|
|
4252
|
+
router.post("/admin/blog/:slug/" + action, _pageOrApi(false,
|
|
4253
|
+
W("blog." + action, async function (req, res) {
|
|
4254
|
+
var row;
|
|
4255
|
+
try { row = await fn(req.params.slug); }
|
|
4256
|
+
catch (e) {
|
|
4257
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
4258
|
+
if (e && e.code === "BLOG_ARTICLE_NOT_FOUND") return _problem(res, 404, "blog-post-not-found");
|
|
4259
|
+
if (e && e.code === "BLOG_ARTICLE_BAD_STATE") return _problem(res, 409, "conflict", e.message);
|
|
4260
|
+
throw e;
|
|
4261
|
+
}
|
|
4262
|
+
_json(res, 200, row);
|
|
4263
|
+
return { id: row.slug };
|
|
4264
|
+
}),
|
|
4265
|
+
async function (req, res) {
|
|
4266
|
+
var slug = req.params.slug;
|
|
4267
|
+
var enc = encodeURIComponent(slug);
|
|
4268
|
+
try {
|
|
4269
|
+
await fn(slug);
|
|
4270
|
+
} catch (e) {
|
|
4271
|
+
if (e instanceof TypeError || (e && (e.code === "BLOG_ARTICLE_NOT_FOUND" || e.code === "BLOG_ARTICLE_BAD_STATE"))) {
|
|
4272
|
+
return _redirect(res, "/admin/blog/" + enc + "?err=1");
|
|
4273
|
+
}
|
|
4274
|
+
throw e;
|
|
4275
|
+
}
|
|
4276
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".blog." + action, outcome: "success", metadata: { slug: slug } });
|
|
4277
|
+
// archive lands back on the list (the post left the editable
|
|
4278
|
+
// set); the others stay on the detail with a status banner.
|
|
4279
|
+
if (action === "archive") return _redirect(res, "/admin/blog?archived=1");
|
|
4280
|
+
_redirect(res, "/admin/blog/" + enc + (action === "publish" ? "?published=1" : "?updated=1"));
|
|
4281
|
+
},
|
|
4282
|
+
));
|
|
4283
|
+
}
|
|
4284
|
+
_blogTransition("publish", function (s) { return blog.publish(s); });
|
|
4285
|
+
_blogTransition("unpublish", function (s) { return blog.unpublish(s); });
|
|
4286
|
+
_blogTransition("restore", function (s) { return blog.restore(s); });
|
|
4287
|
+
|
|
4288
|
+
// Archive is destructive-ish (the post leaves the live storefront and
|
|
4289
|
+
// the editable set), so the browser path confirms first — the CSP
|
|
4290
|
+
// forbids a client confirm() dialog. Reached by a GET link from the
|
|
4291
|
+
// detail screen; bearer clients POST /archive directly.
|
|
4292
|
+
router.get("/admin/blog/:slug/archive/confirm-page", _pageOrApi(true,
|
|
4293
|
+
R(async function (_req, res) {
|
|
4294
|
+
return _problem(res, 405, "use-canonical-endpoint", "POST /admin/blog/:slug/archive directly for the JSON API");
|
|
4295
|
+
}),
|
|
4296
|
+
async function (req, res) {
|
|
4297
|
+
var slug = req.params.slug;
|
|
4298
|
+
var enc = encodeURIComponent(slug);
|
|
4299
|
+
var row = null;
|
|
4300
|
+
try { row = await blog.get(slug); }
|
|
4301
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
4302
|
+
if (!row) return _redirect(res, "/admin/blog?err=1");
|
|
4303
|
+
_sendHtml(res, 200, renderAdminConfirm({
|
|
4304
|
+
shop_name: deps.shop_name, nav_available: navAvailable, active: "blog",
|
|
4305
|
+
heading: "Archive this post?",
|
|
4306
|
+
consequence: "Archiving removes the post from the live storefront blog. You can restore it to a draft later.",
|
|
4307
|
+
detail: "Post: " + (row.title || slug) + ".",
|
|
4308
|
+
action: "/admin/blog/" + _htmlEscape(enc) + "/archive",
|
|
4309
|
+
confirm_label: "Archive post",
|
|
4310
|
+
cancel_href: "/admin/blog/" + enc,
|
|
4311
|
+
}));
|
|
4312
|
+
},
|
|
4313
|
+
));
|
|
4314
|
+
_blogTransition("archive", function (s) { return blog.archive(s); });
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4029
4317
|
// ---- customer surveys -----------------------------------------------
|
|
4030
4318
|
// Define NPS/CSAT/CES/custom surveys, issue token invitations (the
|
|
4031
4319
|
// plaintext link is shown once on issue), and read the rollup. The
|
|
@@ -4571,6 +4859,29 @@ function mount(router, deps) {
|
|
|
4571
4859
|
|
|
4572
4860
|
var subscriptions = deps.subscriptions || null;
|
|
4573
4861
|
if (subscriptions) {
|
|
4862
|
+
// Coerce the edit form into a plans.update patch. Only the mutable
|
|
4863
|
+
// columns are forwarded; an absent / blank field is omitted so a
|
|
4864
|
+
// partial edit leaves the rest untouched. amount_minor /
|
|
4865
|
+
// interval_count / trial_days go through the strict integer reader so
|
|
4866
|
+
// "", a float, or "12abc" is refused as a clean 400 rather than
|
|
4867
|
+
// coerced. active rides a hidden presence marker so a value-only edit
|
|
4868
|
+
// doesn't flip it. variant_id="" links the plan to no variant
|
|
4869
|
+
// (standalone); the immutable Stripe-bound columns are never on the
|
|
4870
|
+
// form.
|
|
4871
|
+
function _subscriptionPlanPatchFromForm(body) {
|
|
4872
|
+
body = body || {};
|
|
4873
|
+
var patch = {};
|
|
4874
|
+
if (body.amount_minor != null && body.amount_minor !== "") patch.amount_minor = _strictMinorInt(body.amount_minor, "subscriptions", "amount_minor (minor units)");
|
|
4875
|
+
if (body.interval_count != null && body.interval_count !== "") patch.interval_count = _strictMinorInt(body.interval_count, "subscriptions", "interval_count");
|
|
4876
|
+
if (body.trial_days != null && body.trial_days !== "") patch.trial_days = _strictMinorInt(body.trial_days, "subscriptions", "trial_days");
|
|
4877
|
+
if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
|
|
4878
|
+
if (Object.prototype.hasOwnProperty.call(body, "variant_id")) {
|
|
4879
|
+
var vid = typeof body.variant_id === "string" ? body.variant_id.trim() : body.variant_id;
|
|
4880
|
+
patch.variant_id = vid ? vid : null;
|
|
4881
|
+
}
|
|
4882
|
+
return patch;
|
|
4883
|
+
}
|
|
4884
|
+
|
|
4574
4885
|
// Create content-negotiates: bearer → JSON (unchanged for tooling);
|
|
4575
4886
|
// signed-in browser form → create, then PRG back to the catalog (a
|
|
4576
4887
|
// bad-shape submit re-renders the form with the validator's message
|
|
@@ -4635,19 +4946,74 @@ function mount(router, deps) {
|
|
|
4635
4946
|
},
|
|
4636
4947
|
));
|
|
4637
4948
|
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4949
|
+
// Detail screen content-negotiates: bearer → the JSON plan (the
|
|
4950
|
+
// tooling contract, unchanged); browser cookie → the rendered detail
|
|
4951
|
+
// + edit page. A malformed / unknown id is a 404 either way, never a
|
|
4952
|
+
// 500. (Previously this route was bearer-JSON-only, so the console
|
|
4953
|
+
// had no way to change a plan's price / interval-count / trial after
|
|
4954
|
+
// create — only archive-and-recreate against a fresh Stripe price.)
|
|
4955
|
+
router.get("/admin/subscription-plans/:id", _pageOrApi(true,
|
|
4956
|
+
R(async function (req, res) {
|
|
4957
|
+
var p;
|
|
4958
|
+
try { p = await subscriptions.plans.get(req.params.id); }
|
|
4959
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 404, "subscription-plan-not-found", e.message); throw e; }
|
|
4960
|
+
if (!p) return _problem(res, 404, "subscription-plan-not-found");
|
|
4961
|
+
_json(res, 200, p);
|
|
4962
|
+
}),
|
|
4963
|
+
async function (req, res) {
|
|
4964
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
4965
|
+
var p = null;
|
|
4966
|
+
try { p = await subscriptions.plans.get(req.params.id); }
|
|
4967
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; p = null; }
|
|
4968
|
+
if (!p) return _sendHtml(res, 404, renderAdminSubscriptionPlans({
|
|
4969
|
+
shop_name: deps.shop_name, nav_available: navAvailable, plans: [], notice: "Subscription plan not found.",
|
|
4970
|
+
}));
|
|
4971
|
+
_sendHtml(res, 200, renderAdminSubscriptionPlan({
|
|
4972
|
+
shop_name: deps.shop_name, nav_available: navAvailable, plan: p,
|
|
4973
|
+
updated: url && url.searchParams.get("updated"),
|
|
4974
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the plan." : null,
|
|
4975
|
+
}));
|
|
4976
|
+
},
|
|
4977
|
+
));
|
|
4643
4978
|
|
|
4644
4979
|
router.patch("/admin/subscription-plans/:id", W("subscription_plan.update", async function (req, res) {
|
|
4645
|
-
var p
|
|
4980
|
+
var p;
|
|
4981
|
+
try { p = await subscriptions.plans.update(req.params.id, req.body || {}); }
|
|
4982
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
4646
4983
|
if (!p) return _problem(res, 404, "subscription-plan-not-found");
|
|
4647
4984
|
_json(res, 200, p);
|
|
4648
4985
|
return p;
|
|
4649
4986
|
}));
|
|
4650
4987
|
|
|
4988
|
+
// Browser edit alias for the PATCH (HTML forms can't PATCH). Forwards
|
|
4989
|
+
// the mutable columns — amount / interval_count / trial_days / active
|
|
4990
|
+
// / variant_id — through plans.update; the immutable Stripe-bound
|
|
4991
|
+
// columns (stripe_price_id / interval / currency) are not on the form
|
|
4992
|
+
// (changing those is archive-and-recreate). A bad value is a clean
|
|
4993
|
+
// 400 (bearer) / err notice (browser), never a 500 or partial write.
|
|
4994
|
+
router.post("/admin/subscription-plans/:id/edit", _pageOrApi(false,
|
|
4995
|
+
W("subscription_plan.update", async function (req, res) {
|
|
4996
|
+
var p;
|
|
4997
|
+
try { p = await subscriptions.plans.update(req.params.id, _subscriptionPlanPatchFromForm(req.body || {})); }
|
|
4998
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
4999
|
+
if (!p) return _problem(res, 404, "subscription-plan-not-found");
|
|
5000
|
+
_json(res, 200, p);
|
|
5001
|
+
return p;
|
|
5002
|
+
}),
|
|
5003
|
+
async function (req, res) {
|
|
5004
|
+
var id = req.params.id;
|
|
5005
|
+
var enc = encodeURIComponent(id);
|
|
5006
|
+
var updated = null;
|
|
5007
|
+
try { updated = await subscriptions.plans.update(id, _subscriptionPlanPatchFromForm(req.body || {})); }
|
|
5008
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/subscription-plans/" + enc + "?err=1"); }
|
|
5009
|
+
// A well-formed-but-unknown id (stale / tampered form) updates no
|
|
5010
|
+
// row — plans.update returns null. Flag err, never a false success.
|
|
5011
|
+
if (!updated) return _redirect(res, "/admin/subscription-plans/" + enc + "?err=1");
|
|
5012
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".subscription_plan.update", outcome: "success", metadata: { id: id } });
|
|
5013
|
+
_redirect(res, "/admin/subscription-plans/" + enc + "?updated=1");
|
|
5014
|
+
},
|
|
5015
|
+
));
|
|
5016
|
+
|
|
4651
5017
|
// Browser confirmation interstitial for archive — terminal from the
|
|
4652
5018
|
// console (a retired plan is replaced by creating a new one against a
|
|
4653
5019
|
// fresh Stripe price id, never reactivated in place), so confirm it.
|
|
@@ -5619,6 +5985,30 @@ function mount(router, deps) {
|
|
|
5619
5985
|
return { id: rule.slug };
|
|
5620
5986
|
}));
|
|
5621
5987
|
|
|
5988
|
+
// Detail screen: the single rule + a full edit form covering the
|
|
5989
|
+
// trigger + value terms (not just priority/active). Content-
|
|
5990
|
+
// negotiates like the list — bearer → the JSON rule; browser cookie
|
|
5991
|
+
// → the rendered edit page.
|
|
5992
|
+
router.get("/admin/discounts/:slug", _pageOrApi(true,
|
|
5993
|
+
R(async function (req, res) {
|
|
5994
|
+
var rule = await autoDiscount.getRule(req.params.slug);
|
|
5995
|
+
if (!rule) return _problem(res, 404, "auto-discount-not-found");
|
|
5996
|
+
_json(res, 200, rule);
|
|
5997
|
+
}),
|
|
5998
|
+
async function (req, res) {
|
|
5999
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6000
|
+
var rule = await autoDiscount.getRule(req.params.slug);
|
|
6001
|
+
if (!rule) return _sendHtml(res, 404, await _renderDiscounts({
|
|
6002
|
+
notice: "Discount rule not found.",
|
|
6003
|
+
}));
|
|
6004
|
+
_sendHtml(res, 200, renderAdminDiscount({
|
|
6005
|
+
shop_name: deps.shop_name, nav_available: navAvailable, rule: rule,
|
|
6006
|
+
updated: url && url.searchParams.get("updated"),
|
|
6007
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the rule." : null,
|
|
6008
|
+
}));
|
|
6009
|
+
},
|
|
6010
|
+
));
|
|
6011
|
+
|
|
5622
6012
|
router.post("/admin/discounts/:slug/edit", _pageOrApi(false,
|
|
5623
6013
|
W("auto_discount.update", async function (req, res) {
|
|
5624
6014
|
var rule;
|
|
@@ -5628,9 +6018,18 @@ function mount(router, deps) {
|
|
|
5628
6018
|
return { id: rule.slug };
|
|
5629
6019
|
}),
|
|
5630
6020
|
async function (req, res) {
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
6021
|
+
var slug = req.params.slug;
|
|
6022
|
+
// A terms edit (trigger/value) comes from the detail screen and
|
|
6023
|
+
// returns there on error so the operator sees their input in
|
|
6024
|
+
// context; the inline row edit (priority/active only) stays on
|
|
6025
|
+
// the list. Success PRGs to the list's ?updated banner either way.
|
|
6026
|
+
var fromDetail = (req.body && (req.body.trigger_kind || req.body.value_kind));
|
|
6027
|
+
var errHref = fromDetail
|
|
6028
|
+
? "/admin/discounts/" + encodeURIComponent(slug) + "?err=1"
|
|
6029
|
+
: "/admin/discounts?err=1";
|
|
6030
|
+
try { await autoDiscount.updateRule(slug, _discountPatch(req.body || {})); }
|
|
6031
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, errHref); }
|
|
6032
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".auto_discount.update", outcome: "success", metadata: { slug: slug } });
|
|
5634
6033
|
_redirect(res, "/admin/discounts?updated=1");
|
|
5635
6034
|
},
|
|
5636
6035
|
));
|
|
@@ -6024,15 +6423,24 @@ function mount(router, deps) {
|
|
|
6024
6423
|
return { kind: "free_shipping" };
|
|
6025
6424
|
}
|
|
6026
6425
|
|
|
6027
|
-
// Translate the rule edit form into an updateRule patch
|
|
6028
|
-
//
|
|
6029
|
-
//
|
|
6030
|
-
//
|
|
6426
|
+
// Translate the rule edit form into an updateRule patch. The inline
|
|
6427
|
+
// row form covers the common "rename / re-prioritise / pause" gesture
|
|
6428
|
+
// (title / priority / active); the detail screen additionally posts a
|
|
6429
|
+
// trigger_kind + value_kind so an operator can change the actual
|
|
6430
|
+
// discount terms (amount / percentage / threshold / BOGO) from the
|
|
6431
|
+
// console, not just reprioritise. The trigger / value kind fields are
|
|
6432
|
+
// reused from the create form's vocabulary via _discountTrigger /
|
|
6433
|
+
// _discountValue, which throw a TypeError on a bad / missing required
|
|
6434
|
+
// field — so a bad terms edit degrades to a clean 400, never a 500,
|
|
6435
|
+
// and never a silent partial write. The richer applies_to / segment /
|
|
6436
|
+
// exclusion vocabulary stays on the bearer JSON PATCH.
|
|
6031
6437
|
function _discountPatch(body) {
|
|
6032
6438
|
var patch = {};
|
|
6033
6439
|
if (Object.prototype.hasOwnProperty.call(body, "title") && body.title !== "") patch.title = body.title;
|
|
6034
6440
|
if (body.priority != null && body.priority !== "") patch.priority = _strictMinorInt(body.priority, "autoDiscount", "priority");
|
|
6035
6441
|
if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
|
|
6442
|
+
if (body.trigger_kind != null && body.trigger_kind !== "") patch.trigger = _discountTrigger(body);
|
|
6443
|
+
if (body.value_kind != null && body.value_kind !== "") patch.value = _discountValue(body);
|
|
6036
6444
|
return patch;
|
|
6037
6445
|
}
|
|
6038
6446
|
|
|
@@ -6393,6 +6801,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
6393
6801
|
{ key: "shipping-labels", href: "/admin/shipping-labels", label: "Shipping labels", requires: "shippingLabels" },
|
|
6394
6802
|
{ key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
|
|
6395
6803
|
{ key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
|
|
6804
|
+
{ key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
|
|
6396
6805
|
{ key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
|
|
6397
6806
|
{ key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
|
|
6398
6807
|
{ key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
|
|
@@ -7923,6 +8332,56 @@ function renderAdminInventory(opts) {
|
|
|
7923
8332
|
return _renderAdminShell(opts.shop_name, "Inventory", bodyHtml, "inventory", opts.nav_available);
|
|
7924
8333
|
}
|
|
7925
8334
|
|
|
8335
|
+
// Single subscription-plan detail + edit form. The Stripe-bound columns
|
|
8336
|
+
// (price id / interval / currency) are immutable post-create — shown
|
|
8337
|
+
// read-only — because they mirror a recurring Stripe Price; to change
|
|
8338
|
+
// those an operator archives the plan and creates a new one against a
|
|
8339
|
+
// fresh price id. The mutable columns (amount / interval count / trial /
|
|
8340
|
+
// active / variant link) are editable here.
|
|
8341
|
+
function renderAdminSubscriptionPlan(opts) {
|
|
8342
|
+
opts = opts || {};
|
|
8343
|
+
var p = opts.plan;
|
|
8344
|
+
if (!p) {
|
|
8345
|
+
var nf = "<section><h2>Subscription plan</h2><p class=\"empty\">Subscription plan not found.</p>" +
|
|
8346
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/subscription-plans\">Back to plans</a></div></section>";
|
|
8347
|
+
return _renderAdminShell(opts.shop_name, "Subscription plan", nf, "subscriptions", opts.nav_available);
|
|
8348
|
+
}
|
|
8349
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Plan updated.</div>" : "";
|
|
8350
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
8351
|
+
var isActive = p.active === 1 || p.active === true;
|
|
8352
|
+
var cur = String(p.currency || "").toUpperCase();
|
|
8353
|
+
var every = p.interval_count > 1 ? p.interval_count + " " + p.interval + "s" : p.interval;
|
|
8354
|
+
|
|
8355
|
+
var summary =
|
|
8356
|
+
"<div class=\"panel\"><dl class=\"detail-grid\">" +
|
|
8357
|
+
"<div><dt>Price</dt><dd><strong>" + _htmlEscape(pricing.format(p.amount_minor, cur) + " / " + every) + "</strong></dd></div>" +
|
|
8358
|
+
"<div><dt>Stripe price</dt><dd><code class=\"order-id\">" + _htmlEscape(p.stripe_price_id) + "</code></dd></div>" +
|
|
8359
|
+
"<div><dt>Variant</dt><dd>" + (p.variant_id ? "<code class=\"order-id\">" + _htmlEscape(String(p.variant_id)) + "</code>" : "<span class=\"meta\">standalone</span>") + "</dd></div>" +
|
|
8360
|
+
"<div><dt>Trial</dt><dd>" + (p.trial_days ? _htmlEscape(String(p.trial_days)) + " days" : "<span class=\"meta\">none</span>") + "</dd></div>" +
|
|
8361
|
+
"<div><dt>Status</dt><dd><span class=\"status-pill " + (isActive ? "paid" : "cancelled") + "\">" + (isActive ? "active" : "archived") + "</span></dd></div>" +
|
|
8362
|
+
"</dl></div>";
|
|
8363
|
+
|
|
8364
|
+
var editForm = !isActive
|
|
8365
|
+
? "<p class=\"empty\">This plan is archived and can no longer be edited. Create a new plan against a fresh Stripe price id to offer it again.</p>"
|
|
8366
|
+
: "<div class=\"panel mw-34\">" +
|
|
8367
|
+
"<h3 class=\"subhead\">Edit plan</h3>" +
|
|
8368
|
+
"<p class=\"meta\">The Stripe price id, billing interval, and currency are fixed — they mirror the Stripe Price. To change those, archive this plan and create a new one.</p>" +
|
|
8369
|
+
"<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/edit\">" +
|
|
8370
|
+
"<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
|
|
8371
|
+
_setupField("Amount (minor units)", "amount_minor", String(p.amount_minor), "number", "In the currency's smallest unit — e.g. 1999 = $19.99.", " min=\"1\"") +
|
|
8372
|
+
_setupField("Interval count", "interval_count", String(p.interval_count), "number", "Bill every N " + _htmlEscape(p.interval) + "s (1–12).", " min=\"1\" max=\"12\"") +
|
|
8373
|
+
_setupField("Trial days", "trial_days", String(p.trial_days), "number", "Free trial length before the first charge (0–730).", " min=\"0\" max=\"730\"") +
|
|
8374
|
+
_setupField("Variant id (optional)", "variant_id", p.variant_id ? String(p.variant_id) : "", "text", "Link to a storefront variant, or clear for a standalone tier.", " maxlength=\"64\"") +
|
|
8375
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\" checked> Active</label>" +
|
|
8376
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
|
|
8377
|
+
"</form>" +
|
|
8378
|
+
"</div>";
|
|
8379
|
+
|
|
8380
|
+
var bodyHtml = "<section><h2>Subscription plan</h2>" + updated + notice + summary + editForm +
|
|
8381
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/subscription-plans\">Back to plans</a></div></section>";
|
|
8382
|
+
return _renderAdminShell(opts.shop_name, "Subscription plan", bodyHtml, "subscriptions", opts.nav_available);
|
|
8383
|
+
}
|
|
8384
|
+
|
|
7926
8385
|
function renderAdminSubscriptionPlans(opts) {
|
|
7927
8386
|
opts = opts || {};
|
|
7928
8387
|
var rows = opts.plans || [];
|
|
@@ -7948,9 +8407,10 @@ function renderAdminSubscriptionPlans(opts) {
|
|
|
7948
8407
|
var price = pricing.format(p.amount_minor, String(p.currency || "").toUpperCase()) + " / " + every;
|
|
7949
8408
|
var isActive = p.active === 1 || p.active === true;
|
|
7950
8409
|
var archiveCell = isActive
|
|
7951
|
-
? "<
|
|
8410
|
+
? "<a class=\"btn btn--ghost\" href=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "\">Edit</a> " +
|
|
8411
|
+
"<form method=\"post\" action=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "/archive/confirm\" class=\"form-inline\">" +
|
|
7952
8412
|
"<button class=\"btn btn--ghost\" type=\"submit\">Archive</button></form>"
|
|
7953
|
-
: "<
|
|
8413
|
+
: "<a class=\"btn btn--ghost\" href=\"/admin/subscription-plans/" + _htmlEscape(p.id) + "\">View</a>";
|
|
7954
8414
|
return "<tr>" +
|
|
7955
8415
|
"<td><strong>" + _htmlEscape(price) + "</strong>" +
|
|
7956
8416
|
(p.trial_days ? " <span class=\"status-pill pending\">" + _htmlEscape(String(p.trial_days)) + "d trial</span>" : "") + "</td>" +
|
|
@@ -8493,6 +8953,83 @@ function _fmtValue(v) {
|
|
|
8493
8953
|
return v.kind;
|
|
8494
8954
|
}
|
|
8495
8955
|
|
|
8956
|
+
// Single auto-discount rule detail + a full edit form. Unlike the
|
|
8957
|
+
// inline row form (priority/active only), this form re-collects the
|
|
8958
|
+
// trigger + value terms so an operator can change the amount / percent
|
|
8959
|
+
// / threshold / BOGO quantities from the console. The trigger / value
|
|
8960
|
+
// field set mirrors the create form exactly; each kind's fields are
|
|
8961
|
+
// prefilled from the rule's current terms, and the operator fills the
|
|
8962
|
+
// ones the chosen kind needs (the backend validates the required
|
|
8963
|
+
// fields for the kind). The hidden active_present marker keeps a
|
|
8964
|
+
// terms-only edit from flipping active.
|
|
8965
|
+
function renderAdminDiscount(opts) {
|
|
8966
|
+
opts = opts || {};
|
|
8967
|
+
var r = opts.rule;
|
|
8968
|
+
if (!r) {
|
|
8969
|
+
var nf = "<section><h2>Discount rule</h2><p class=\"empty\">Discount rule not found.</p>" +
|
|
8970
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/discounts\">Back to discounts</a></div></section>";
|
|
8971
|
+
return _renderAdminShell(opts.shop_name, "Discount rule", nf, "discounts", opts.nav_available);
|
|
8972
|
+
}
|
|
8973
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Rule updated.</div>" : "";
|
|
8974
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
8975
|
+
var isArchived = r.archived_at != null;
|
|
8976
|
+
var t = r.trigger || {};
|
|
8977
|
+
var v = r.value || {};
|
|
8978
|
+
|
|
8979
|
+
var TRIGGERS = [
|
|
8980
|
+
{ value: "cart_total_min", label: "Cart total ≥ (minor)" },
|
|
8981
|
+
{ value: "item_count_min", label: "Item count ≥" },
|
|
8982
|
+
{ value: "sku_purchase", label: "SKU purchased" },
|
|
8983
|
+
];
|
|
8984
|
+
var VALUES = [
|
|
8985
|
+
{ value: "percent_off", label: "Percent off" },
|
|
8986
|
+
{ value: "amount_off_total", label: "Amount off total" },
|
|
8987
|
+
{ value: "amount_off_each", label: "Amount off each" },
|
|
8988
|
+
{ value: "free_shipping", label: "Free shipping" },
|
|
8989
|
+
{ value: "bogo", label: "Buy X get Y" },
|
|
8990
|
+
];
|
|
8991
|
+
// For amount_off_total / amount_off_each the lib stores one `minor`
|
|
8992
|
+
// field; prefill it for whichever amount kind is active.
|
|
8993
|
+
var amountMinor = (v.kind === "amount_off_total" || v.kind === "amount_off_each") && v.minor != null ? String(v.minor) : "";
|
|
8994
|
+
|
|
8995
|
+
var editForm = isArchived
|
|
8996
|
+
? "<p class=\"empty\">This rule is archived and can no longer be edited.</p>"
|
|
8997
|
+
: "<div class=\"panel mw-42\">" +
|
|
8998
|
+
"<h3 class=\"subhead\">Edit rule</h3>" +
|
|
8999
|
+
"<p class=\"meta\">Change the trigger, the value, or the priority. Fill the fields for the trigger / value kind you pick — the others are ignored.</p>" +
|
|
9000
|
+
"<form method=\"post\" action=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "/edit\">" +
|
|
9001
|
+
"<input type=\"hidden\" name=\"active_present\" value=\"1\">" +
|
|
9002
|
+
_setupField("Title", "title", r.title, "text", "Operator-facing name.", " maxlength=\"200\"") +
|
|
9003
|
+
_selectField("Trigger kind", "trigger_kind", TRIGGERS, t.kind || "cart_total_min", "What fires the rule.", "") +
|
|
9004
|
+
_setupField("· Cart total min (minor)", "trigger_min_minor", t.kind === "cart_total_min" && t.min_minor != null ? String(t.min_minor) : "", "number", "For \"Cart total ≥\".", " min=\"0\"") +
|
|
9005
|
+
_setupField("· Item count min", "trigger_min_count", t.kind === "item_count_min" && t.min_count != null ? String(t.min_count) : "", "number", "For \"Item count ≥\".", " min=\"1\"") +
|
|
9006
|
+
_setupField("· SKUs (comma-separated)", "trigger_skus", t.kind === "sku_purchase" && Array.isArray(t.skus) ? t.skus.join(",") : "", "text", "For \"SKU purchased\".", " maxlength=\"2000\"") +
|
|
9007
|
+
_setupField("· SKU min quantity", "trigger_min_quantity", t.kind === "sku_purchase" && t.min_quantity != null ? String(t.min_quantity) : "", "number", "For \"SKU purchased\" (default 1).", " min=\"1\"") +
|
|
9008
|
+
_selectField("Value kind", "value_kind", VALUES, v.kind || "percent_off", "What the rule gives.", "") +
|
|
9009
|
+
_setupField("· Percent (basis points)", "value_basis_points", v.kind === "percent_off" && v.basis_points != null ? String(v.basis_points) : "", "number", "For \"Percent off\". 1000 = 10.00%.", " min=\"1\" max=\"10000\"") +
|
|
9010
|
+
_setupField("· Amount (minor)", "value_minor", amountMinor, "number", "For \"Amount off total / each\".", " min=\"1\"") +
|
|
9011
|
+
_setupField("· BOGO buy qty", "value_buy_qty", v.kind === "bogo" && v.buy_qty != null ? String(v.buy_qty) : "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
|
|
9012
|
+
_setupField("· BOGO get qty", "value_get_qty", v.kind === "bogo" && v.get_qty != null ? String(v.get_qty) : "", "number", "For \"Buy X get Y\".", " min=\"1\"") +
|
|
9013
|
+
_setupField("Priority", "priority", String(r.priority), "number", "Higher wins ties.", " min=\"0\"") +
|
|
9014
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\"" + (r.active ? " checked" : "") + "> Active</label>" +
|
|
9015
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
|
|
9016
|
+
"</form>" +
|
|
9017
|
+
"</div>";
|
|
9018
|
+
|
|
9019
|
+
var bodyHtml = "<section><h2>Discount rule</h2>" + updated + notice +
|
|
9020
|
+
"<div class=\"panel\"><dl class=\"detail-grid\">" +
|
|
9021
|
+
"<div><dt>Rule</dt><dd><strong>" + _htmlEscape(r.title) + "</strong><br><code class=\"order-id\">" + _htmlEscape(r.slug) + "</code></dd></div>" +
|
|
9022
|
+
"<div><dt>Trigger</dt><dd>" + _htmlEscape(_fmtTrigger(r.trigger)) + "</dd></div>" +
|
|
9023
|
+
"<div><dt>Value</dt><dd>" + _htmlEscape(_fmtValue(r.value)) + "</dd></div>" +
|
|
9024
|
+
"<div><dt>Priority</dt><dd>" + _htmlEscape(String(r.priority)) + "</dd></div>" +
|
|
9025
|
+
"<div><dt>Status</dt><dd><span class=\"status-pill " + (isArchived ? "cancelled" : (r.active ? "paid" : "pending")) + "\">" + (isArchived ? "archived" : (r.active ? "active" : "paused")) + "</span></dd></div>" +
|
|
9026
|
+
"</dl></div>" +
|
|
9027
|
+
editForm +
|
|
9028
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/discounts\">Back to discounts</a></div>" +
|
|
9029
|
+
"</section>";
|
|
9030
|
+
return _renderAdminShell(opts.shop_name, "Discount rule", bodyHtml, "discounts", opts.nav_available);
|
|
9031
|
+
}
|
|
9032
|
+
|
|
8496
9033
|
function renderAdminDiscounts(opts) {
|
|
8497
9034
|
opts = opts || {};
|
|
8498
9035
|
var rules = opts.rules || [];
|
|
@@ -8515,6 +9052,7 @@ function renderAdminDiscounts(opts) {
|
|
|
8515
9052
|
"<label class=\"kv\"><input type=\"checkbox\" name=\"active\"" + (r.active ? " checked" : "") + "> on</label>" +
|
|
8516
9053
|
"<button class=\"btn btn--ghost\" type=\"submit\">Save</button>" +
|
|
8517
9054
|
"</form> " +
|
|
9055
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "\">Edit terms</a> " +
|
|
8518
9056
|
"<form method=\"post\" action=\"/admin/discounts/" + _htmlEscape(encodeURIComponent(r.slug)) + "/archive\" class=\"form-inline\">" +
|
|
8519
9057
|
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>";
|
|
8520
9058
|
return "<tr>" +
|
|
@@ -9009,6 +9547,112 @@ function _announcementFromForm(body) {
|
|
|
9009
9547
|
return out;
|
|
9010
9548
|
}
|
|
9011
9549
|
|
|
9550
|
+
// Coerce the edit form into an updateAnnouncement patch. Every editable
|
|
9551
|
+
// column the primitive's whitelist accepts is forwarded so a console
|
|
9552
|
+
// edit reaches the same surface as the bearer PATCH: message, theme,
|
|
9553
|
+
// audience, dismissible, the link pair, and the two schedule bounds.
|
|
9554
|
+
// The schedule + link fields use the form's hidden presence markers so
|
|
9555
|
+
// a partial edit doesn't unintentionally clear an unrelated column: a
|
|
9556
|
+
// link_present marker maps a blank pair to null (clear the link); a
|
|
9557
|
+
// starts_present / expires_present marker maps a blank bound to null
|
|
9558
|
+
// (open the schedule). Absent markers leave the column untouched. The
|
|
9559
|
+
// primitive validates the result (audience<->segment, link both-or-
|
|
9560
|
+
// neither, expires>starts) and throws a TypeError on a bad shape, which
|
|
9561
|
+
// both surfaces degrade to a clean 400 / err notice.
|
|
9562
|
+
function _announcementPatchFromForm(body) {
|
|
9563
|
+
body = body || {};
|
|
9564
|
+
var patch = {};
|
|
9565
|
+
if (Object.prototype.hasOwnProperty.call(body, "message") && body.message !== "") patch.message = body.message;
|
|
9566
|
+
if (body.theme != null && body.theme !== "") patch.theme = body.theme;
|
|
9567
|
+
if (body.audience != null && body.audience !== "") patch.audience = body.audience;
|
|
9568
|
+
// The dismissible checkbox is present (on/1) when checked, absent when
|
|
9569
|
+
// unchecked — only treat it as a field when the form declared the
|
|
9570
|
+
// hidden marker so a partial JSON edit doesn't flip it.
|
|
9571
|
+
if (body.dismissible_present === "1") patch.dismissible = (body.dismissible === "on" || body.dismissible === "1");
|
|
9572
|
+
if (body.link_present === "1") {
|
|
9573
|
+
var lu = typeof body.link_url === "string" ? body.link_url.trim() : "";
|
|
9574
|
+
var ll = typeof body.link_label === "string" ? body.link_label.trim() : "";
|
|
9575
|
+
if (lu || ll) { patch.link_url = lu; patch.link_label = ll; }
|
|
9576
|
+
else { patch.link_url = null; patch.link_label = null; }
|
|
9577
|
+
}
|
|
9578
|
+
if (body.starts_present === "1") patch.starts_at = _epochFromForm(body.starts_at);
|
|
9579
|
+
if (body.expires_present === "1") patch.expires_at = _epochFromForm(body.expires_at);
|
|
9580
|
+
return patch;
|
|
9581
|
+
}
|
|
9582
|
+
|
|
9583
|
+
// epoch ms → the <datetime-local> value an <input> renders back. Returns
|
|
9584
|
+
// "" for a null/absent bound so the field stays empty (open-ended).
|
|
9585
|
+
function _datetimeLocalValue(epochMs) {
|
|
9586
|
+
if (epochMs == null) return "";
|
|
9587
|
+
var d = new Date(Number(epochMs));
|
|
9588
|
+
if (isNaN(d.getTime())) return "";
|
|
9589
|
+
function _pad(n) { return n < 10 ? "0" + n : String(n); }
|
|
9590
|
+
return d.getUTCFullYear() + "-" + _pad(d.getUTCMonth() + 1) + "-" + _pad(d.getUTCDate()) +
|
|
9591
|
+
"T" + _pad(d.getUTCHours()) + ":" + _pad(d.getUTCMinutes());
|
|
9592
|
+
}
|
|
9593
|
+
|
|
9594
|
+
// Single-announcement detail + edit screen. The form prefills every
|
|
9595
|
+
// editable column and posts to /edit; the hidden presence markers tell
|
|
9596
|
+
// the patch coercion which columns the form is authoritative for, so a
|
|
9597
|
+
// blank schedule / link clears rather than being ignored.
|
|
9598
|
+
function renderAdminAnnouncement(opts) {
|
|
9599
|
+
opts = opts || {};
|
|
9600
|
+
var a = opts.announcement;
|
|
9601
|
+
if (!a) {
|
|
9602
|
+
var nf = "<section><h2>Announcement</h2><p class=\"empty\">Announcement not found.</p>" +
|
|
9603
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/announcements\">Back to announcements</a></div></section>";
|
|
9604
|
+
return _renderAdminShell(opts.shop_name, "Announcement", nf, "announcements", opts.nav_available);
|
|
9605
|
+
}
|
|
9606
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Announcement updated.</div>" : "";
|
|
9607
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
9608
|
+
var isArchived = a.archived_at != null;
|
|
9609
|
+
|
|
9610
|
+
var themeOpts = ["urgency", "promo", "info", "success"].map(function (t) {
|
|
9611
|
+
return "<option value=\"" + t + "\"" + (t === a.theme ? " selected" : "") + ">" + t + "</option>";
|
|
9612
|
+
}).join("");
|
|
9613
|
+
// segment audience needs an isMember handle that isn't wired in the
|
|
9614
|
+
// console (see the create screen's note), so the picker offers the
|
|
9615
|
+
// three reachable audiences; an existing segment row keeps its value
|
|
9616
|
+
// as a disabled-looking selected option so an edit doesn't silently
|
|
9617
|
+
// re-target it.
|
|
9618
|
+
var audValues = ["all", "guest", "logged_in"];
|
|
9619
|
+
if (a.audience === "segment") audValues = ["segment"].concat(audValues);
|
|
9620
|
+
var audienceOpts = audValues.map(function (au) {
|
|
9621
|
+
return "<option value=\"" + au + "\"" + (au === a.audience ? " selected" : "") + ">" + au + "</option>";
|
|
9622
|
+
}).join("");
|
|
9623
|
+
|
|
9624
|
+
var editForm = isArchived
|
|
9625
|
+
? "<p class=\"empty\">This announcement is archived and can no longer be edited.</p>"
|
|
9626
|
+
: "<div class=\"panel mw-40\">" +
|
|
9627
|
+
"<h3 class=\"subhead\">Edit announcement</h3>" +
|
|
9628
|
+
"<form method=\"post\" action=\"/admin/announcements/" + _htmlEscape(encodeURIComponent(a.slug)) + "/edit\">" +
|
|
9629
|
+
"<input type=\"hidden\" name=\"dismissible_present\" value=\"1\">" +
|
|
9630
|
+
"<input type=\"hidden\" name=\"link_present\" value=\"1\">" +
|
|
9631
|
+
"<input type=\"hidden\" name=\"starts_present\" value=\"1\">" +
|
|
9632
|
+
"<input type=\"hidden\" name=\"expires_present\" value=\"1\">" +
|
|
9633
|
+
"<label class=\"form-field\"><span>Message</span><textarea name=\"message\" maxlength=\"500\" required>" + _htmlEscape(a.message) + "</textarea></label>" +
|
|
9634
|
+
_setupField("Link URL (optional)", "link_url", a.link_url || "", "text", "https:// or a /-rooted path. Clear both to remove the link.", " maxlength=\"2048\"") +
|
|
9635
|
+
_setupField("Link label (optional)", "link_label", a.link_label || "", "text", "", " maxlength=\"120\"") +
|
|
9636
|
+
"<label class=\"form-field\"><span>Theme</span><select name=\"theme\">" + themeOpts + "</select></label>" +
|
|
9637
|
+
"<label class=\"form-field\"><span>Audience</span><select name=\"audience\">" + audienceOpts + "</select></label>" +
|
|
9638
|
+
"<label class=\"form-field\"><span>Starts at (optional)</span><input type=\"datetime-local\" name=\"starts_at\" value=\"" + _htmlEscape(_datetimeLocalValue(a.starts_at)) + "\"></label>" +
|
|
9639
|
+
"<label class=\"form-field\"><span>Expires at (optional)</span><input type=\"datetime-local\" name=\"expires_at\" value=\"" + _htmlEscape(_datetimeLocalValue(a.expires_at)) + "\"></label>" +
|
|
9640
|
+
"<label class=\"kv\"><input type=\"checkbox\" name=\"dismissible\" value=\"on\"" + (a.dismissible ? " checked" : "") + "> Visitors can dismiss this</label>" +
|
|
9641
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Save changes</button></div>" +
|
|
9642
|
+
"</form>" +
|
|
9643
|
+
"</div>";
|
|
9644
|
+
|
|
9645
|
+
var bodyHtml = "<section><h2>Announcement</h2>" + updated + notice +
|
|
9646
|
+
"<div class=\"panel\"><dl class=\"detail-grid\">" +
|
|
9647
|
+
"<div><dt>Slug</dt><dd><code class=\"order-id\">" + _htmlEscape(a.slug) + "</code></dd></div>" +
|
|
9648
|
+
"<div><dt>Status</dt><dd><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></dd></div>" +
|
|
9649
|
+
"</dl></div>" +
|
|
9650
|
+
editForm +
|
|
9651
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/announcements\">Back to announcements</a></div>" +
|
|
9652
|
+
"</section>";
|
|
9653
|
+
return _renderAdminShell(opts.shop_name, "Announcement", bodyHtml, "announcements", opts.nav_available);
|
|
9654
|
+
}
|
|
9655
|
+
|
|
9012
9656
|
function renderAdminAnnouncements(opts) {
|
|
9013
9657
|
opts = opts || {};
|
|
9014
9658
|
var rows = opts.announcements || [];
|
|
@@ -9032,6 +9676,7 @@ function renderAdminAnnouncements(opts) {
|
|
|
9032
9676
|
"<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
|
|
9033
9677
|
"<td><div class=\"actions-row\">" +
|
|
9034
9678
|
(isArchived ? "" :
|
|
9679
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/announcements/" + _htmlEscape(encodeURIComponent(a.slug)) + "\">Edit</a> " +
|
|
9035
9680
|
"<form method=\"post\" action=\"/admin/announcements/" + _htmlEscape(encodeURIComponent(a.slug)) + "/archive\" class=\"form-inline\">" +
|
|
9036
9681
|
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
|
|
9037
9682
|
"</div></td>" +
|
|
@@ -9071,6 +9716,209 @@ function renderAdminAnnouncements(opts) {
|
|
|
9071
9716
|
return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
|
|
9072
9717
|
}
|
|
9073
9718
|
|
|
9719
|
+
// ---- blog form coercion + render --------------------------------------
|
|
9720
|
+
|
|
9721
|
+
// A comma- or newline-separated tag field → the normalized string array
|
|
9722
|
+
// the blog primitive validates. Empty entries are dropped; the primitive
|
|
9723
|
+
// enforces the per-tag shape + the dedupe + the count cap, so this only
|
|
9724
|
+
// splits and trims.
|
|
9725
|
+
function _csvTags(v) {
|
|
9726
|
+
if (typeof v !== "string" || !v.trim()) return [];
|
|
9727
|
+
return v.split(/[,\n]/).map(function (s) { return s.trim(); }).filter(Boolean);
|
|
9728
|
+
}
|
|
9729
|
+
|
|
9730
|
+
// Coerce the create form into blogArticles.createDraft's shape: trimmed
|
|
9731
|
+
// slug, the required title/body/author, the optional tags array, and the
|
|
9732
|
+
// optional hero-image + meta fields (a blank optional field is omitted so
|
|
9733
|
+
// the primitive's null default applies). The primitive validates every
|
|
9734
|
+
// field — this only shapes the form strings.
|
|
9735
|
+
function _blogFromForm(body) {
|
|
9736
|
+
body = body || {};
|
|
9737
|
+
var out = {
|
|
9738
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
9739
|
+
title: body.title,
|
|
9740
|
+
body: body.body,
|
|
9741
|
+
author_id: typeof body.author_id === "string" ? body.author_id.trim() : body.author_id,
|
|
9742
|
+
};
|
|
9743
|
+
var tags = _csvTags(body.tags);
|
|
9744
|
+
if (tags.length) out.tags = tags;
|
|
9745
|
+
var hero = typeof body.hero_image_url === "string" ? body.hero_image_url.trim() : "";
|
|
9746
|
+
if (hero) out.hero_image_url = hero;
|
|
9747
|
+
var md = typeof body.meta_description === "string" ? body.meta_description.trim() : "";
|
|
9748
|
+
if (md) out.meta_description = md;
|
|
9749
|
+
var mk = typeof body.meta_keywords === "string" ? body.meta_keywords.trim() : "";
|
|
9750
|
+
if (mk) out.meta_keywords = mk;
|
|
9751
|
+
return out;
|
|
9752
|
+
}
|
|
9753
|
+
|
|
9754
|
+
// Coerce the edit form into a blogArticles.update patch. Only the columns
|
|
9755
|
+
// the form actually carries are included; an empty optional field is sent
|
|
9756
|
+
// as "" so the operator can clear a previously-set meta line / hero image
|
|
9757
|
+
// (the primitive accepts an empty string for the nullable meta columns).
|
|
9758
|
+
// Tags always ride (an empty list clears them). Status is NOT here — it
|
|
9759
|
+
// moves via the lifecycle routes.
|
|
9760
|
+
function _blogPatchFromForm(body) {
|
|
9761
|
+
body = body || {};
|
|
9762
|
+
var patch = { tags: _csvTags(body.tags) };
|
|
9763
|
+
if (typeof body.title === "string") patch.title = body.title;
|
|
9764
|
+
if (typeof body.body === "string") patch.body = body.body;
|
|
9765
|
+
if (typeof body.author_id === "string") patch.author_id = body.author_id.trim();
|
|
9766
|
+
if (typeof body.hero_image_url === "string") patch.hero_image_url = body.hero_image_url.trim() || null;
|
|
9767
|
+
if (typeof body.meta_description === "string") patch.meta_description = body.meta_description.trim();
|
|
9768
|
+
if (typeof body.meta_keywords === "string") patch.meta_keywords = body.meta_keywords.trim();
|
|
9769
|
+
return patch;
|
|
9770
|
+
}
|
|
9771
|
+
|
|
9772
|
+
// One status pill class per lifecycle state, reusing the order-status pill
|
|
9773
|
+
// palette: published = paid (green), draft = pending (amber), archived =
|
|
9774
|
+
// cancelled (grey).
|
|
9775
|
+
function _blogStatusPill(status) {
|
|
9776
|
+
var cls = status === "published" ? "paid" : status === "archived" ? "cancelled" : "pending";
|
|
9777
|
+
return "<span class=\"status-pill " + cls + "\">" + _htmlEscape(status) + "</span>";
|
|
9778
|
+
}
|
|
9779
|
+
|
|
9780
|
+
function renderAdminBlog(opts) {
|
|
9781
|
+
opts = opts || {};
|
|
9782
|
+
var rows = opts.articles || [];
|
|
9783
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Post created as a draft.</div>" : "";
|
|
9784
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Post saved.</div>" : "";
|
|
9785
|
+
var published = opts.published ? "<div class=\"banner banner--ok\">Post published — it's live on the storefront blog.</div>" : "";
|
|
9786
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Post archived.</div>" : "";
|
|
9787
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
9788
|
+
|
|
9789
|
+
var sf = opts.status_filter;
|
|
9790
|
+
var chips = "<div class=\"order-filters\">" +
|
|
9791
|
+
"<a class=\"chip" + (sf == null ? " chip--on" : "") + "\" href=\"/admin/blog\">All</a>" +
|
|
9792
|
+
"<a class=\"chip" + (sf === "published" ? " chip--on" : "") + "\" href=\"/admin/blog?status=published\">Published</a>" +
|
|
9793
|
+
"<a class=\"chip" + (sf === "draft" ? " chip--on" : "") + "\" href=\"/admin/blog?status=draft\">Drafts</a>" +
|
|
9794
|
+
"<a class=\"chip" + (sf === "archived" ? " chip--on" : "") + "\" href=\"/admin/blog?status=archived\">Archived</a>" +
|
|
9795
|
+
"</div>";
|
|
9796
|
+
|
|
9797
|
+
var bodyRows = rows.map(function (a) {
|
|
9798
|
+
var enc = encodeURIComponent(a.slug);
|
|
9799
|
+
var date = a.published_at != null ? _fmtDate(a.published_at) : "—";
|
|
9800
|
+
// Per-row lifecycle actions match the post's current state: a draft
|
|
9801
|
+
// can publish; a published post can unpublish or archive; an archived
|
|
9802
|
+
// post can restore. Edit + the storefront link are always offered.
|
|
9803
|
+
var actions = "<a class=\"btn btn--ghost\" href=\"/admin/blog/" + _htmlEscape(enc) + "\">Edit</a>";
|
|
9804
|
+
if (a.status === "draft") {
|
|
9805
|
+
actions += "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/publish\" class=\"form-inline\">" +
|
|
9806
|
+
"<button class=\"btn\" type=\"submit\">Publish</button></form>";
|
|
9807
|
+
} else if (a.status === "published") {
|
|
9808
|
+
actions += "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/unpublish\" class=\"form-inline\">" +
|
|
9809
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Unpublish</button></form>" +
|
|
9810
|
+
"<a class=\"btn btn--danger\" href=\"/admin/blog/" + _htmlEscape(enc) + "/archive/confirm-page\">Archive</a>";
|
|
9811
|
+
} else if (a.status === "archived") {
|
|
9812
|
+
actions += "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/restore\" class=\"form-inline\">" +
|
|
9813
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Restore</button></form>";
|
|
9814
|
+
}
|
|
9815
|
+
return "<tr>" +
|
|
9816
|
+
"<td><a href=\"/admin/blog/" + _htmlEscape(enc) + "\"><strong>" + _htmlEscape(a.title) + "</strong></a></td>" +
|
|
9817
|
+
"<td><code class=\"order-id\">" + _htmlEscape(a.slug) + "</code></td>" +
|
|
9818
|
+
"<td>" + _blogStatusPill(a.status) + "</td>" +
|
|
9819
|
+
"<td>" + _htmlEscape(date) + "</td>" +
|
|
9820
|
+
"<td><div class=\"actions-row\">" + actions + "</div></td>" +
|
|
9821
|
+
"</tr>";
|
|
9822
|
+
}).join("");
|
|
9823
|
+
|
|
9824
|
+
var table = rows.length
|
|
9825
|
+
? "<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>"
|
|
9826
|
+
: "<p class=\"empty\">No posts" + (sf ? " " + _htmlEscape(sf) : " yet") + ". Write your first one.</p>";
|
|
9827
|
+
|
|
9828
|
+
var newBtn = "<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/blog/new\">New post</a></div>";
|
|
9829
|
+
|
|
9830
|
+
var bodyHtml = "<section><h2>Blog</h2>" +
|
|
9831
|
+
created + updated + published + archived + notice +
|
|
9832
|
+
"<p class=\"meta\">Editorial posts shown on the storefront blog (/blog). A post is created as a draft and stays hidden until you publish it.</p>" +
|
|
9833
|
+
newBtn + chips + table + "</section>";
|
|
9834
|
+
return _renderAdminShell(opts.shop_name, "Blog", bodyHtml, "blog", opts.nav_available);
|
|
9835
|
+
}
|
|
9836
|
+
|
|
9837
|
+
// New-post form (article: null) or edit form (article set) for a single
|
|
9838
|
+
// blog post, plus the lifecycle action row when editing an existing post.
|
|
9839
|
+
// `form_values` re-fills a failed create so the operator doesn't retype.
|
|
9840
|
+
function renderAdminBlogDetail(opts) {
|
|
9841
|
+
opts = opts || {};
|
|
9842
|
+
var a = opts.article || null;
|
|
9843
|
+
var isNew = !a;
|
|
9844
|
+
var fv = opts.form_values || {};
|
|
9845
|
+
var updated = opts.updated ? "<div class=\"banner banner--ok\">Post saved.</div>" : "";
|
|
9846
|
+
var published = opts.published ? "<div class=\"banner banner--ok\">Post published — it's live on the storefront blog.</div>" : "";
|
|
9847
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
9848
|
+
|
|
9849
|
+
// Field values: the existing row when editing, else the failed-create
|
|
9850
|
+
// form values, else blank.
|
|
9851
|
+
function _val(col) {
|
|
9852
|
+
if (a && a[col] != null) return a[col];
|
|
9853
|
+
if (fv[col] != null) return fv[col];
|
|
9854
|
+
return "";
|
|
9855
|
+
}
|
|
9856
|
+
var tagsVal = a && Array.isArray(a.tags) ? a.tags.join(", ")
|
|
9857
|
+
: (typeof fv.tags === "string" ? fv.tags : "");
|
|
9858
|
+
|
|
9859
|
+
var action = isNew ? "/admin/blog" : "/admin/blog/" + encodeURIComponent(a.slug) + "/edit";
|
|
9860
|
+
// Slug is the PK — editable only on create (the primitive keys update on
|
|
9861
|
+
// it). On edit it shows read-only so the operator sees the storefront URL.
|
|
9862
|
+
var slugField = isNew
|
|
9863
|
+
? _setupField("Slug", "slug", _val("slug"), "text", "Lowercase letters, digits, and hyphens — appears in /blog/<slug>.", " maxlength=\"120\" required")
|
|
9864
|
+
: "<label class=\"form-field\"><span>Slug</span><input type=\"text\" value=\"" + _htmlEscape(a.slug) + "\" readonly>" +
|
|
9865
|
+
"<small>The storefront URL: <code>/blog/" + _htmlEscape(a.slug) + "</code></small></label>";
|
|
9866
|
+
|
|
9867
|
+
var form =
|
|
9868
|
+
"<div class=\"panel mw-40\">" +
|
|
9869
|
+
"<h3 class=\"subhead\">" + (isNew ? "New post" : "Post details") + "</h3>" +
|
|
9870
|
+
"<form method=\"post\" action=\"" + _htmlEscape(action) + "\">" +
|
|
9871
|
+
slugField +
|
|
9872
|
+
_setupField("Title", "title", _val("title"), "text", "", " maxlength=\"200\" required") +
|
|
9873
|
+
_setupField("Author", "author_id", _val("author_id"), "text", "An author id (your editorial team's handle).", " maxlength=\"80\" required") +
|
|
9874
|
+
"<label class=\"form-field\"><span>Body (Markdown)</span>" +
|
|
9875
|
+
"<textarea name=\"body\" rows=\"16\" maxlength=\"200000\" required>" + _htmlEscape(_val("body")) + "</textarea>" +
|
|
9876
|
+
"<small>Headings, lists, links, bold/italic. Raw HTML is escaped — links are https-only.</small></label>" +
|
|
9877
|
+
_setupField("Tags (optional)", "tags", tagsVal, "text", "Comma-separated, e.g. guides, buying-tips.", " maxlength=\"1000\"") +
|
|
9878
|
+
_setupField("Hero image URL (optional)", "hero_image_url", _val("hero_image_url"), "text", "https:// or a /-rooted path.", " maxlength=\"2048\"") +
|
|
9879
|
+
_setupField("Meta description (optional)", "meta_description", _val("meta_description"), "text", "Shown in search results + the post's <head>.", " maxlength=\"320\"") +
|
|
9880
|
+
_setupField("Meta keywords (optional)", "meta_keywords", _val("meta_keywords"), "text", "", " maxlength=\"320\"") +
|
|
9881
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">" + (isNew ? "Create draft" : "Save") + "</button>" +
|
|
9882
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/blog\">Back</a></div>" +
|
|
9883
|
+
"</form>" +
|
|
9884
|
+
"</div>";
|
|
9885
|
+
|
|
9886
|
+
var lifecycle = "";
|
|
9887
|
+
if (!isNew) {
|
|
9888
|
+
var enc = encodeURIComponent(a.slug);
|
|
9889
|
+
var btns = "";
|
|
9890
|
+
if (a.status === "draft") {
|
|
9891
|
+
btns = "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/publish\" class=\"form-inline\">" +
|
|
9892
|
+
"<button class=\"btn\" type=\"submit\">Publish</button></form>" +
|
|
9893
|
+
"<span class=\"meta\">Publishing makes the post live on the storefront blog.</span>";
|
|
9894
|
+
} else if (a.status === "published") {
|
|
9895
|
+
btns = "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/unpublish\" class=\"form-inline\">" +
|
|
9896
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Unpublish</button></form>" +
|
|
9897
|
+
"<a class=\"btn btn--danger\" href=\"/admin/blog/" + _htmlEscape(enc) + "/archive/confirm-page\">Archive</a>" +
|
|
9898
|
+
"<span class=\"meta\">Unpublish pulls it back to a draft; archive removes it from the storefront.</span>";
|
|
9899
|
+
} else if (a.status === "archived") {
|
|
9900
|
+
btns = "<form method=\"post\" action=\"/admin/blog/" + _htmlEscape(enc) + "/restore\" class=\"form-inline\">" +
|
|
9901
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Restore to draft</button></form>";
|
|
9902
|
+
}
|
|
9903
|
+
lifecycle = "<div class=\"panel mt-1 mw-40\"><h3 class=\"subhead\">Status</h3>" +
|
|
9904
|
+
"<p class=\"meta\">Current state: " + _blogStatusPill(a.status) + "</p>" +
|
|
9905
|
+
"<div class=\"actions-row\">" + btns + "</div></div>";
|
|
9906
|
+
}
|
|
9907
|
+
|
|
9908
|
+
var head = isNew
|
|
9909
|
+
? "<p class=\"meta\"><a href=\"/admin/blog\">← Blog</a></p>"
|
|
9910
|
+
: "<p class=\"meta\"><a href=\"/admin/blog\">← Blog</a> · " +
|
|
9911
|
+
_blogStatusPill(a.status) +
|
|
9912
|
+
(a.status === "published"
|
|
9913
|
+
? " · <a href=\"/blog/" + _htmlEscape(encodeURIComponent(a.slug)) + "\" target=\"_blank\" rel=\"noreferrer\">View on storefront →</a>"
|
|
9914
|
+
: "") +
|
|
9915
|
+
"</p>";
|
|
9916
|
+
|
|
9917
|
+
var title = isNew ? "New post" : (a.title || a.slug);
|
|
9918
|
+
var body = "<section><h2>" + _htmlEscape(title) + "</h2>" + updated + published + notice + head + form + "</section>" + lifecycle;
|
|
9919
|
+
return _renderAdminShell(opts.shop_name, isNew ? "New post" : "Post " + a.slug, body, "blog", opts.nav_available);
|
|
9920
|
+
}
|
|
9921
|
+
|
|
9074
9922
|
// Standard question set per survey kind, so the console can create the
|
|
9075
9923
|
// common surveys without a dynamic question builder (fully custom question
|
|
9076
9924
|
// lists go through the JSON defineSurvey API). Each question carries a
|
|
@@ -9889,6 +10737,8 @@ module.exports = {
|
|
|
9889
10737
|
renderAdminQuestion: renderAdminQuestion,
|
|
9890
10738
|
renderAdminCollections: renderAdminCollections,
|
|
9891
10739
|
renderAdminCollection: renderAdminCollection,
|
|
10740
|
+
renderAdminBlog: renderAdminBlog,
|
|
10741
|
+
renderAdminBlogDetail: renderAdminBlogDetail,
|
|
9892
10742
|
renderAdminGiftCards: renderAdminGiftCards,
|
|
9893
10743
|
renderAdminGiftCard: renderAdminGiftCard,
|
|
9894
10744
|
renderAdminTaxRates: renderAdminTaxRates,
|
package/lib/asset-manifest.json
CHANGED
package/package.json
CHANGED