@blamejs/blamejs-shop 0.2.4 → 0.2.5
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 +2 -0
- package/lib/admin.js +270 -1
- package/lib/asset-manifest.json +3 -3
- package/lib/customer-surveys.js +40 -0
- package/lib/storefront.js +166 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.2.x
|
|
10
10
|
|
|
11
|
+
- v0.2.5 (2026-05-27) — **Customer surveys — token-gated NPS / CSAT / CES feedback with an admin rollup.** Operators can now collect structured post-experience feedback. Define a survey (NPS, CSAT, CES, or a custom open-ended prompt) from a new Surveys screen in the admin console, then issue a single-use invitation for a customer — the console shows the survey link once for you to send. The customer opens the link and answers without signing in; the token itself is the access. Responses roll up on the survey's detail page with the kind-appropriate score (NPS index, CSAT positive-rate, CES mean) plus a per-question breakdown. Surveys can be archived when a campaign ends; existing invitation links keep working. **Added:** *Token-gated survey page* — A `/survey/:token` page renders the survey's questions — a rating scale, single-select options, or a free-text box — and accepts one response per invitation. The single-use token is the access (no login), and the page degrades cleanly to a clear notice when a link is already used, expired, closed, or invalid. The required-answer check re-renders the form with a message rather than losing the visitor's work. · *Admin console: Surveys* — A new Surveys screen (and matching JSON API) to create a survey for the chosen kind (the console seeds the standard question set; fully custom question lists go through the API), issue a single-use invitation for a customer id (the link is revealed exactly once), read the response rollup with the kind's headline score and per-question results, and archive finished surveys. The screen appears only when the surveys primitive is wired.
|
|
12
|
+
|
|
11
13
|
- v0.2.4 (2026-05-27) — **Announcement bar — publish a sitewide promo or notice strip from the admin console.** Operators can now publish a sitewide announcement bar — a strip at the very top of every storefront page for a sale, a shipping notice, or an urgent alert. Manage it from a new Announcements screen in the admin console: set the message, an optional call-to-action link, a theme (urgency, promo, info, or success), the audience (everyone, signed-out visitors, or signed-in customers), an optional start/expiry schedule, and whether visitors can dismiss it. The highest-priority active announcement shows automatically (urgency outranks promo outranks info outranks success). The bar renders on both the edge and container paths so it appears on every page including the cached ones, and a dismissed bar stays hidden across navigation. Until an announcement is published, nothing changes. **Added:** *Sitewide announcement bar* — A page-top strip rendered on every storefront page (home, search, product, cart, blog, and the storefront chrome) in one of four themes. The single highest-priority active announcement for the viewer is shown, chosen by theme rank (urgency > promo > info > success). Audience targeting covers everyone, signed-out visitors, and signed-in customers, with an optional scheduled start and expiry. An optional call-to-action link is included; its URL is restricted to https:// or a site-relative path. · *Admin console: Announcements* — A new Announcements screen (and matching JSON API) to create announcements, filter the list to those active right now, and archive ones that are done. The screen appears in the console only when the announcement primitive is wired. · *Dismiss without a round-trip* — Dismissible announcements carry a close control. With JavaScript it's hidden in place and remembered in a local cookie so it doesn't reappear as you browse; with JavaScript off, a form submission records the dismissal and returns you to the page. The bar is always server-rendered identically for everyone, so a dismissed bar never leaks back in from a cached page — it's hidden per-visitor on the client.
|
|
12
14
|
|
|
13
15
|
- v0.2.3 (2026-05-27) — **Admin console adopts the dark violet brand — the operator dashboard now matches the storefront.** The admin console has been restyled from its previous light theme and orange accent onto the same dark violet identity as the storefront: a neutral near-black canvas with violet reserved for accents, the self-hosted Hanken Grotesk and Space Mono typefaces in place of Inter, and Space Mono carrying the terminal-style chrome (header mark, navigation, micro-labels, figures, SKUs, order ids, and code). Stat cards, tables, status pills, forms, buttons, banners, nav cards, and the sign-in card are all recoloured for the dark surface with contrast preserved — order statuses keep their meaning (green for paid/shipped, amber for pending, violet for refunded, muted for cancelled), form inputs read clearly on the dark wells, and the revenue sparkline is drawn in the brand violet. Operators on the default theme get the new console on upgrade; no admin functionality, routes, or data change. **Changed:** *Dark violet admin theme* — The admin stylesheet is rebuilt on the storefront's palette — near-black surfaces, neutral hairline borders, and violet reserved for accents (active nav, primary buttons, the `/ admin` mark, focus states, and the revenue sparkline) rather than the former light paper and orange. Self-hosted Hanken Grotesk (display/body) and Space Mono (chrome/mono) replace Inter, loaded under the strict `font-src 'self'` policy. · *Components recoloured for the dark surface* — Stat cards, the orders table, status pills, filter chips, forms, buttons (primary/ghost/danger), banners, nav cards, the gift-card detail grid, and the sign-in card are all restyled for dark with contrast maintained. Order-status pills keep their semantics — green for paid/shipped/fulfilling/delivered, amber for pending, violet for refunded, muted for cancelled. Form inputs sit on recessed dark wells with light text and a violet focus ring.
|
package/lib/admin.js
CHANGED
|
@@ -300,7 +300,7 @@ function mount(router, deps) {
|
|
|
300
300
|
// Which optional console sections are wired — gates their nav links so a
|
|
301
301
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
302
302
|
// into every authed render call as `nav_available`.
|
|
303
|
-
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 };
|
|
303
|
+
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 };
|
|
304
304
|
|
|
305
305
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
306
306
|
|
|
@@ -2171,6 +2171,131 @@ function mount(router, deps) {
|
|
|
2171
2171
|
));
|
|
2172
2172
|
}
|
|
2173
2173
|
|
|
2174
|
+
// ---- customer surveys -----------------------------------------------
|
|
2175
|
+
// Define NPS/CSAT/CES/custom surveys, issue token invitations (the
|
|
2176
|
+
// plaintext link is shown once on issue), and read the rollup. The
|
|
2177
|
+
// console creates surveys with a standard question set for the chosen
|
|
2178
|
+
// kind; fully custom question lists go through the JSON API. Content-
|
|
2179
|
+
// negotiated like the other screens.
|
|
2180
|
+
if (deps.customerSurveys) {
|
|
2181
|
+
var surveys = deps.customerSurveys;
|
|
2182
|
+
|
|
2183
|
+
router.get("/admin/surveys", _pageOrApi(true,
|
|
2184
|
+
R(async function (req, res) {
|
|
2185
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
2186
|
+
var activeS = url && url.searchParams.get("active");
|
|
2187
|
+
_json(res, 200, { rows: await surveys.listSurveys(activeS === "1" ? { active_only: true } : {}) });
|
|
2188
|
+
}),
|
|
2189
|
+
async function (req, res) {
|
|
2190
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
2191
|
+
var activeS = url && url.searchParams.get("active");
|
|
2192
|
+
var rows = await surveys.listSurveys(activeS === "1" ? { active_only: true } : {});
|
|
2193
|
+
_sendHtml(res, 200, renderAdminSurveys({
|
|
2194
|
+
shop_name: deps.shop_name, nav_available: navAvailable, surveys: rows,
|
|
2195
|
+
active_filter: activeS,
|
|
2196
|
+
created: url && url.searchParams.get("created"),
|
|
2197
|
+
archived: url && url.searchParams.get("archived"),
|
|
2198
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the survey." : null,
|
|
2199
|
+
}));
|
|
2200
|
+
},
|
|
2201
|
+
));
|
|
2202
|
+
|
|
2203
|
+
router.post("/admin/surveys", _pageOrApi(false,
|
|
2204
|
+
W("survey.define", async function (req, res) {
|
|
2205
|
+
var row = await surveys.defineSurvey(req.body || {});
|
|
2206
|
+
_json(res, 201, row);
|
|
2207
|
+
return { id: row.slug };
|
|
2208
|
+
}),
|
|
2209
|
+
async function (req, res) {
|
|
2210
|
+
var body = req.body || {};
|
|
2211
|
+
try {
|
|
2212
|
+
await surveys.defineSurvey({
|
|
2213
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
2214
|
+
title: body.title,
|
|
2215
|
+
kind: body.kind,
|
|
2216
|
+
trigger: body.trigger || "manual",
|
|
2217
|
+
questions: _standardSurveyQuestions(body.kind),
|
|
2218
|
+
});
|
|
2219
|
+
} catch (e) {
|
|
2220
|
+
if (!(e instanceof TypeError)) throw e;
|
|
2221
|
+
var rows = await surveys.listSurveys({});
|
|
2222
|
+
return _sendHtml(res, 400, renderAdminSurveys({
|
|
2223
|
+
shop_name: deps.shop_name, nav_available: navAvailable, surveys: rows,
|
|
2224
|
+
notice: (e.message || "Couldn't create that survey.").replace(/^customerSurveys[.:]\s*/, ""),
|
|
2225
|
+
}));
|
|
2226
|
+
}
|
|
2227
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".survey.define", outcome: "success" });
|
|
2228
|
+
_redirect(res, "/admin/surveys?created=1");
|
|
2229
|
+
},
|
|
2230
|
+
));
|
|
2231
|
+
|
|
2232
|
+
router.post("/admin/surveys/:slug/archive", _pageOrApi(false,
|
|
2233
|
+
W("survey.archive", async function (req, res) {
|
|
2234
|
+
var row;
|
|
2235
|
+
try { row = await surveys.archiveSurvey(req.params.slug); }
|
|
2236
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
2237
|
+
if (!row) return _problem(res, 404, "survey-not-found");
|
|
2238
|
+
_json(res, 200, row);
|
|
2239
|
+
return { id: row.slug };
|
|
2240
|
+
}),
|
|
2241
|
+
async function (req, res) {
|
|
2242
|
+
var slug = req.params.slug;
|
|
2243
|
+
var row = null;
|
|
2244
|
+
try { row = await surveys.archiveSurvey(slug); }
|
|
2245
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
2246
|
+
if (!row) return _redirect(res, "/admin/surveys?err=1");
|
|
2247
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".survey.archive", outcome: "success", metadata: { slug: slug } });
|
|
2248
|
+
_redirect(res, "/admin/surveys?archived=1");
|
|
2249
|
+
},
|
|
2250
|
+
));
|
|
2251
|
+
|
|
2252
|
+
// Detail: rollup + the issue-invitation form. The freshly-issued token
|
|
2253
|
+
// link is rendered once via the ?token= flash (never stored plaintext).
|
|
2254
|
+
router.get("/admin/surveys/:slug", _pageOrApi(true,
|
|
2255
|
+
R(async function (req, res) {
|
|
2256
|
+
var roll = await surveys.rollup({ slug: req.params.slug });
|
|
2257
|
+
if (!roll) return _problem(res, 404, "survey-not-found");
|
|
2258
|
+
_json(res, 200, roll);
|
|
2259
|
+
}),
|
|
2260
|
+
async function (req, res) {
|
|
2261
|
+
var slug = req.params.slug;
|
|
2262
|
+
var survey = await surveys.getSurvey(slug);
|
|
2263
|
+
if (!survey) return _sendHtml(res, 404, renderAdminSurveyDetail({ shop_name: deps.shop_name, nav_available: navAvailable, survey: null }));
|
|
2264
|
+
var roll = await surveys.rollup({ slug: slug });
|
|
2265
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
2266
|
+
_sendHtml(res, 200, renderAdminSurveyDetail({
|
|
2267
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
2268
|
+
survey: survey, rollup: roll,
|
|
2269
|
+
issued_link: url && url.searchParams.get("link"),
|
|
2270
|
+
notice: (url && url.searchParams.get("err")) ? "Couldn't issue that invitation — check the customer id." : null,
|
|
2271
|
+
}));
|
|
2272
|
+
},
|
|
2273
|
+
));
|
|
2274
|
+
|
|
2275
|
+
router.post("/admin/surveys/:slug/issue", _pageOrApi(false,
|
|
2276
|
+
W("survey.issue", async function (req, res) {
|
|
2277
|
+
var out = await surveys.issueInvitation(Object.assign({}, req.body || {}, { survey_slug: req.params.slug }));
|
|
2278
|
+
_json(res, 201, out);
|
|
2279
|
+
return { id: out.invitation_id };
|
|
2280
|
+
}),
|
|
2281
|
+
async function (req, res) {
|
|
2282
|
+
var slug = req.params.slug;
|
|
2283
|
+
var enc = encodeURIComponent(slug);
|
|
2284
|
+
var out;
|
|
2285
|
+
try {
|
|
2286
|
+
out = await surveys.issueInvitation({ survey_slug: slug, customer_id: (req.body && req.body.customer_id) });
|
|
2287
|
+
} catch (e) {
|
|
2288
|
+
if (!(e instanceof TypeError)) throw e;
|
|
2289
|
+
return _redirect(res, "/admin/surveys/" + enc + "?err=1");
|
|
2290
|
+
}
|
|
2291
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".survey.issue", outcome: "success", metadata: { slug: slug } });
|
|
2292
|
+
// The plaintext token is shown exactly once — pass it back via the
|
|
2293
|
+
// detail redirect so the operator can copy the survey link.
|
|
2294
|
+
_redirect(res, "/admin/surveys/" + enc + "?link=" + encodeURIComponent(out.plaintext_token));
|
|
2295
|
+
},
|
|
2296
|
+
));
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2174
2299
|
// ---- analytics ------------------------------------------------------
|
|
2175
2300
|
|
|
2176
2301
|
var analytics = deps.analytics || null;
|
|
@@ -2846,6 +2971,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
2846
2971
|
{ key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
|
|
2847
2972
|
{ key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
|
|
2848
2973
|
{ key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
|
|
2974
|
+
{ key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
|
|
2849
2975
|
{ key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
|
|
2850
2976
|
{ key: "webhooks", href: "/admin/webhooks", label: "Webhooks", requires: "webhooks" },
|
|
2851
2977
|
{ key: "integrations", href: "/admin/integrations", label: "Integrations" },
|
|
@@ -4076,6 +4202,149 @@ function renderAdminAnnouncements(opts) {
|
|
|
4076
4202
|
return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
|
|
4077
4203
|
}
|
|
4078
4204
|
|
|
4205
|
+
// Standard question set per survey kind, so the console can create the
|
|
4206
|
+
// common surveys without a dynamic question builder (fully custom question
|
|
4207
|
+
// lists go through the JSON defineSurvey API). Each question carries a
|
|
4208
|
+
// stable id so historical responses survive a later edit.
|
|
4209
|
+
function _standardSurveyQuestions(kind) {
|
|
4210
|
+
if (kind === "nps") {
|
|
4211
|
+
return [
|
|
4212
|
+
{ id: "score", kind: "rating", label: "How likely are you to recommend us to a friend?", max: 10, required: true },
|
|
4213
|
+
{ id: "reason", kind: "free_text", label: "What's the main reason for your score?", required: false },
|
|
4214
|
+
];
|
|
4215
|
+
}
|
|
4216
|
+
if (kind === "ces") {
|
|
4217
|
+
return [
|
|
4218
|
+
{ id: "effort", kind: "rating", label: "How easy was it to get what you needed?", max: 7, required: true },
|
|
4219
|
+
{ id: "reason", kind: "free_text", label: "Anything that made it harder than it should have been?", required: false },
|
|
4220
|
+
];
|
|
4221
|
+
}
|
|
4222
|
+
if (kind === "csat") {
|
|
4223
|
+
return [
|
|
4224
|
+
{ id: "score", kind: "rating", label: "How satisfied were you with your experience?", max: 5, required: true },
|
|
4225
|
+
{ id: "comment", kind: "free_text", label: "Anything you'd like to add?", required: false },
|
|
4226
|
+
];
|
|
4227
|
+
}
|
|
4228
|
+
// custom — a single open-ended prompt the operator can refine via the API.
|
|
4229
|
+
return [
|
|
4230
|
+
{ id: "feedback", kind: "free_text", label: "Your feedback", required: true },
|
|
4231
|
+
];
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
function renderAdminSurveys(opts) {
|
|
4235
|
+
opts = opts || {};
|
|
4236
|
+
var rows = opts.surveys || [];
|
|
4237
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Survey created.</div>" : "";
|
|
4238
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Survey archived.</div>" : "";
|
|
4239
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
4240
|
+
|
|
4241
|
+
var af = opts.active_filter;
|
|
4242
|
+
var chips = "<div class=\"order-filters\">" +
|
|
4243
|
+
"<a class=\"chip" + (af == null ? " chip--on" : "") + "\" href=\"/admin/surveys\">All</a>" +
|
|
4244
|
+
"<a class=\"chip" + (af === "1" ? " chip--on" : "") + "\" href=\"/admin/surveys?active=1\">Active</a>" +
|
|
4245
|
+
"</div>";
|
|
4246
|
+
|
|
4247
|
+
var bodyRows = rows.map(function (s) {
|
|
4248
|
+
var isArchived = s.archived_at != null;
|
|
4249
|
+
var enc = _htmlEscape(encodeURIComponent(s.slug));
|
|
4250
|
+
return "<tr>" +
|
|
4251
|
+
"<td><a href=\"/admin/surveys/" + enc + "\"><strong>" + _htmlEscape(s.title) + "</strong></a></td>" +
|
|
4252
|
+
"<td><code class=\"order-id\">" + _htmlEscape(s.slug) + "</code></td>" +
|
|
4253
|
+
"<td><span class=\"status-pill\">" + _htmlEscape(s.kind) + "</span></td>" +
|
|
4254
|
+
"<td>" + _htmlEscape(s.trigger_event || s.trigger || "manual") + "</td>" +
|
|
4255
|
+
"<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
|
|
4256
|
+
"<td><div class=\"actions-row\">" +
|
|
4257
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/surveys/" + enc + "\">Open</a>" +
|
|
4258
|
+
(isArchived ? "" :
|
|
4259
|
+
"<form method=\"post\" action=\"/admin/surveys/" + enc + "/archive\" class=\"form-inline\">" +
|
|
4260
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
|
|
4261
|
+
"</div></td>" +
|
|
4262
|
+
"</tr>";
|
|
4263
|
+
}).join("");
|
|
4264
|
+
|
|
4265
|
+
var table = rows.length
|
|
4266
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Title</th><th scope=\"col\">Slug</th><th scope=\"col\">Kind</th><th scope=\"col\">Trigger</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
4267
|
+
: "<p class=\"empty\">No surveys" + (af === "1" ? " active" : " yet") + ".</p>";
|
|
4268
|
+
|
|
4269
|
+
var kindOpts = [["nps", "NPS — recommend score"], ["csat", "CSAT — satisfaction"], ["ces", "CES — ease of effort"], ["custom", "Custom — open feedback"]]
|
|
4270
|
+
.map(function (k) { return "<option value=\"" + k[0] + "\">" + _htmlEscape(k[1]) + "</option>"; }).join("");
|
|
4271
|
+
var trigOpts = [["manual", "Manual (issue invitations yourself)"], ["after_delivery", "After delivery"], ["after_support_close", "After support close"], ["after_refund", "After refund"]]
|
|
4272
|
+
.map(function (t) { return "<option value=\"" + t[0] + "\">" + _htmlEscape(t[1]) + "</option>"; }).join("");
|
|
4273
|
+
|
|
4274
|
+
var createForm =
|
|
4275
|
+
"<div class=\"panel mt mw-40\">" +
|
|
4276
|
+
"<h3 class=\"subhead\">Create a survey</h3>" +
|
|
4277
|
+
"<p class=\"meta\">The console seeds a standard question set for the chosen kind. After creating, open the survey to issue invitation links + read the rollup.</p>" +
|
|
4278
|
+
"<form method=\"post\" action=\"/admin/surveys\">" +
|
|
4279
|
+
_setupField("Title", "title", "", "text", "", " maxlength=\"200\" required") +
|
|
4280
|
+
_setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — a stable id.", " maxlength=\"64\" required") +
|
|
4281
|
+
"<label class=\"form-field\"><span>Kind</span><select name=\"kind\">" + kindOpts + "</select></label>" +
|
|
4282
|
+
"<label class=\"form-field\"><span>Trigger</span><select name=\"trigger\">" + trigOpts + "</select></label>" +
|
|
4283
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create survey</button></div>" +
|
|
4284
|
+
"</form>" +
|
|
4285
|
+
"</div>";
|
|
4286
|
+
|
|
4287
|
+
var bodyHtml = "<section><h2>Surveys</h2>" + created + archived + notice + chips + table + createForm + "</section>";
|
|
4288
|
+
return _renderAdminShell(opts.shop_name, "Surveys", bodyHtml, "surveys", opts.nav_available);
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
function renderAdminSurveyDetail(opts) {
|
|
4292
|
+
opts = opts || {};
|
|
4293
|
+
var survey = opts.survey;
|
|
4294
|
+
if (!survey) {
|
|
4295
|
+
var nf = "<section><h2>Survey</h2><p class=\"empty\">Survey not found.</p>" +
|
|
4296
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/surveys\">Back to surveys</a></div></section>";
|
|
4297
|
+
return _renderAdminShell(opts.shop_name, "Survey", nf, "surveys", opts.nav_available);
|
|
4298
|
+
}
|
|
4299
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
4300
|
+
|
|
4301
|
+
// Freshly-issued invitation link — shown exactly once (the plaintext
|
|
4302
|
+
// token is never persisted). The operator copies it into their own
|
|
4303
|
+
// email/SMS to the customer.
|
|
4304
|
+
var linkReveal = "";
|
|
4305
|
+
if (opts.issued_link) {
|
|
4306
|
+
var path = "/survey/" + _htmlEscape(opts.issued_link);
|
|
4307
|
+
linkReveal =
|
|
4308
|
+
"<div class=\"banner banner--ok\">Invitation issued. Copy this single-use link and send it to the customer — it won't be shown again:" +
|
|
4309
|
+
"<br><code class=\"code-block\">" + path + "</code></div>";
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
var roll = opts.rollup || { response_count: 0, per_question: [] };
|
|
4313
|
+
var headline = "";
|
|
4314
|
+
if (roll.nps) headline = "NPS score: <strong>" + _htmlEscape(String(roll.nps.score)) + "</strong> (" + _htmlEscape(String(roll.nps.promoter_pct)) + "% promoters, " + _htmlEscape(String(roll.nps.detractor_pct)) + "% detractors)";
|
|
4315
|
+
else if (roll.csat) headline = "CSAT: <strong>" + _htmlEscape(String(roll.csat.positive_pct)) + "%</strong> positive";
|
|
4316
|
+
else if (roll.ces) headline = "CES mean: <strong>" + _htmlEscape(String(roll.ces.mean)) + "</strong>";
|
|
4317
|
+
|
|
4318
|
+
var qRows = (roll.per_question || []).map(function (pq) {
|
|
4319
|
+
var detail;
|
|
4320
|
+
if (pq.kind === "rating") detail = "mean " + _htmlEscape(String(pq.mean)) + " over " + _htmlEscape(String(pq.count)) + " answers";
|
|
4321
|
+
else if (pq.kind === "select") {
|
|
4322
|
+
detail = Object.keys(pq.buckets || {}).map(function (o) { return _htmlEscape(o) + ": " + _htmlEscape(String(pq.buckets[o])); }).join(", ");
|
|
4323
|
+
} else detail = _htmlEscape(String(pq.count)) + " written responses";
|
|
4324
|
+
return "<tr><td><code class=\"order-id\">" + _htmlEscape(pq.id) + "</code></td><td>" + _htmlEscape(pq.kind) + "</td><td>" + detail + "</td></tr>";
|
|
4325
|
+
}).join("");
|
|
4326
|
+
var rollupPanel =
|
|
4327
|
+
"<div class=\"panel\">" +
|
|
4328
|
+
"<p class=\"meta\">" + _htmlEscape(String(roll.response_count)) + " response(s)." + (headline ? " " + headline : "") + "</p>" +
|
|
4329
|
+
(qRows ? "<table><thead><tr><th scope=\"col\">Question</th><th scope=\"col\">Kind</th><th scope=\"col\">Result</th></tr></thead><tbody>" + qRows + "</tbody></table>" : "") +
|
|
4330
|
+
"</div>";
|
|
4331
|
+
|
|
4332
|
+
var enc = _htmlEscape(encodeURIComponent(survey.slug));
|
|
4333
|
+
var issueForm =
|
|
4334
|
+
"<div class=\"panel mt mw-40\">" +
|
|
4335
|
+
"<h3 class=\"subhead\">Issue an invitation</h3>" +
|
|
4336
|
+
"<p class=\"meta\">Generates a single-use survey link for one customer. You send the link; the customer answers without signing in.</p>" +
|
|
4337
|
+
"<form method=\"post\" action=\"/admin/surveys/" + enc + "/issue\">" +
|
|
4338
|
+
_setupField("Customer id", "customer_id", "", "text", "The customer's UUID.", " maxlength=\"64\" required") +
|
|
4339
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Issue invitation</button>" +
|
|
4340
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/surveys\">Back to surveys</a></div>" +
|
|
4341
|
+
"</form>" +
|
|
4342
|
+
"</div>";
|
|
4343
|
+
|
|
4344
|
+
var bodyHtml = "<section><h2>" + _htmlEscape(survey.title) + "</h2>" + notice + linkReveal + rollupPanel + issueForm + "</section>";
|
|
4345
|
+
return _renderAdminShell(opts.shop_name, survey.title, bodyHtml, "surveys", opts.nav_available);
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4079
4348
|
function renderAdminCollection(opts) {
|
|
4080
4349
|
opts = opts || {};
|
|
4081
4350
|
var col = opts.collection;
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.5",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
5
|
"integrity": "sha384-FgqvXcZygYkJjjOtXAeu9xi6AtYqkchPnJKcS0OeHm8lynhd9r4RfdpoT7P7j7/l",
|
|
6
6
|
"fingerprinted": "css/admin.afd7b964c1f6fe1b.css"
|
|
7
7
|
},
|
|
8
8
|
"css/main.css": {
|
|
9
|
-
"integrity": "sha384-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-G7yMuGZ4ojVoEpiRX70qcuR+YcQU4A1K/jRhRE/yPD7PXkqUH4LruBlu6MPCv+R8",
|
|
10
|
+
"fingerprinted": "css/main.b99ea24f442deef8.css"
|
|
11
11
|
},
|
|
12
12
|
"js/announcement.js": {
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
package/lib/customer-surveys.js
CHANGED
|
@@ -537,6 +537,25 @@ function create(opts) {
|
|
|
537
537
|
return _decodeSurvey(await _surveyRow(slug));
|
|
538
538
|
}
|
|
539
539
|
|
|
540
|
+
// List defined surveys for the operator console, newest first. With
|
|
541
|
+
// `{ active_only: true }` archived surveys are dropped; otherwise all are
|
|
542
|
+
// returned so the console can show an archived filter.
|
|
543
|
+
async function listSurveys(listOpts) {
|
|
544
|
+
listOpts = listOpts || {};
|
|
545
|
+
var sql, params;
|
|
546
|
+
if (listOpts.active_only === true) {
|
|
547
|
+
sql = "SELECT * FROM customer_surveys WHERE archived_at IS NULL ORDER BY updated_at DESC, slug ASC";
|
|
548
|
+
params = [];
|
|
549
|
+
} else {
|
|
550
|
+
sql = "SELECT * FROM customer_surveys ORDER BY updated_at DESC, slug ASC";
|
|
551
|
+
params = [];
|
|
552
|
+
}
|
|
553
|
+
var r = await query(sql, params);
|
|
554
|
+
var out = [];
|
|
555
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeSurvey(r.rows[i]));
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
|
|
540
559
|
async function archiveSurvey(slug) {
|
|
541
560
|
_slug(slug, "slug");
|
|
542
561
|
var ts = _now();
|
|
@@ -609,6 +628,25 @@ function create(opts) {
|
|
|
609
628
|
return _decodeInvitation(r.rows[0]);
|
|
610
629
|
}
|
|
611
630
|
|
|
631
|
+
// Resolve an invitation + its parent survey from the single-use plaintext
|
|
632
|
+
// token, for rendering the survey page (the token IS the access — no login
|
|
633
|
+
// needed). Returns { invitation, survey } or null when the token matches
|
|
634
|
+
// nothing. The caller inspects invitation.status / expires_at to decide
|
|
635
|
+
// whether to render the form, a thank-you, or an expired/closed notice;
|
|
636
|
+
// this read never mutates state. Constant-time hash compare mirrors
|
|
637
|
+
// submitResponse so a malformed token can't be a timing oracle.
|
|
638
|
+
async function previewByToken(token) {
|
|
639
|
+
var canon = _canonicalToken(token);
|
|
640
|
+
var hash = _hashToken(canon);
|
|
641
|
+
var r = await query("SELECT * FROM survey_invitations WHERE token_hash = ?1", [hash]);
|
|
642
|
+
if (!r.rows.length) return null;
|
|
643
|
+
var inv = r.rows[0];
|
|
644
|
+
if (!b.crypto.timingSafeEqual(inv.token_hash, hash)) return null;
|
|
645
|
+
var surveyRow = await _surveyRow(inv.survey_slug);
|
|
646
|
+
if (!surveyRow) return null;
|
|
647
|
+
return { invitation: _decodeInvitation(inv), survey: _decodeSurvey(surveyRow) };
|
|
648
|
+
}
|
|
649
|
+
|
|
612
650
|
async function invitationsForCustomer(customerId, listOpts) {
|
|
613
651
|
var custId = _uuid(customerId, "customer_id");
|
|
614
652
|
listOpts = listOpts || {};
|
|
@@ -989,6 +1027,8 @@ function create(opts) {
|
|
|
989
1027
|
archiveSurvey: archiveSurvey,
|
|
990
1028
|
issueInvitation: issueInvitation,
|
|
991
1029
|
getInvitation: getInvitation,
|
|
1030
|
+
previewByToken: previewByToken,
|
|
1031
|
+
listSurveys: listSurveys,
|
|
992
1032
|
invitationsForCustomer: invitationsForCustomer,
|
|
993
1033
|
submitResponse: submitResponse,
|
|
994
1034
|
responsesForSurvey: responsesForSurvey,
|
package/lib/storefront.js
CHANGED
|
@@ -4656,6 +4656,85 @@ function renderAccount(opts) {
|
|
|
4656
4656
|
});
|
|
4657
4657
|
}
|
|
4658
4658
|
|
|
4659
|
+
// ---- customer survey page ----------------------------------------------
|
|
4660
|
+
|
|
4661
|
+
// Render one survey question as a fieldset. Rating → a 0/1..max radio
|
|
4662
|
+
// scale; select → radio options; free_text → a textarea. Field name is
|
|
4663
|
+
// `q_<id>`; the post handler maps it back. All operator-authored strings
|
|
4664
|
+
// are HTML-escaped at the sink.
|
|
4665
|
+
function _surveyQuestion(q) {
|
|
4666
|
+
var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
|
|
4667
|
+
var name = "q_" + esc(q.id);
|
|
4668
|
+
var req = q.required ? " <abbr class=\"survey-req\" title=\"Required\">*</abbr>" : "";
|
|
4669
|
+
var control = "";
|
|
4670
|
+
if (q.kind === "rating") {
|
|
4671
|
+
var lo = (q.max >= 9) ? 0 : 1; // 0..10 for NPS-scale, else 1..max
|
|
4672
|
+
var scale = "";
|
|
4673
|
+
for (var n = lo; n <= q.max; n += 1) {
|
|
4674
|
+
scale += "<label class=\"survey-scale__opt\"><input type=\"radio\" name=\"" + name + "\" value=\"" + n + "\"" + (q.required ? " required" : "") + "><span>" + n + "</span></label>";
|
|
4675
|
+
}
|
|
4676
|
+
control = "<div class=\"survey-scale\">" + scale + "</div>";
|
|
4677
|
+
} else if (q.kind === "select") {
|
|
4678
|
+
var opts = "";
|
|
4679
|
+
for (var k = 0; k < q.options.length; k += 1) {
|
|
4680
|
+
var ov = esc(q.options[k]);
|
|
4681
|
+
opts += "<label class=\"survey-opt\"><input type=\"radio\" name=\"" + name + "\" value=\"" + ov + "\"" + (q.required ? " required" : "") + "> " + ov + "</label>";
|
|
4682
|
+
}
|
|
4683
|
+
control = "<div class=\"survey-opts\">" + opts + "</div>";
|
|
4684
|
+
} else {
|
|
4685
|
+
control = "<textarea name=\"" + name + "\" maxlength=\"" + (q.max || 2000) + "\"" + (q.required ? " required" : "") + "></textarea>";
|
|
4686
|
+
}
|
|
4687
|
+
return "<fieldset class=\"survey-q\"><legend>" + esc(q.label) + req + "</legend>" + control + "</fieldset>";
|
|
4688
|
+
}
|
|
4689
|
+
|
|
4690
|
+
// The token survey page. `state` selects the panel: form (answerable),
|
|
4691
|
+
// thankyou (just submitted), responded (already done), expired, closed, or
|
|
4692
|
+
// notfound. The token is rendered into the form action so the POST carries
|
|
4693
|
+
// it back (it's path-segment-safe — 43 base64url chars).
|
|
4694
|
+
function renderSurveyPage(opts) {
|
|
4695
|
+
opts = opts || {};
|
|
4696
|
+
var esc = function (s) { return b.template.escapeHtml(String(s == null ? "" : s)); };
|
|
4697
|
+
var state = opts.state || "notfound";
|
|
4698
|
+
var body;
|
|
4699
|
+
if (state === "form") {
|
|
4700
|
+
var survey = opts.survey || {};
|
|
4701
|
+
var qs = (survey.questions || []).map(_surveyQuestion).join("");
|
|
4702
|
+
var notice = opts.notice ? "<p class=\"form-notice\">" + esc(opts.notice) + "</p>" : "";
|
|
4703
|
+
body =
|
|
4704
|
+
"<section class=\"survey-page\"><div class=\"survey-page__inner\">" +
|
|
4705
|
+
"<p class=\"eyebrow\">Your feedback</p>" +
|
|
4706
|
+
"<h1 class=\"survey-page__title\">" + esc(survey.title) + "</h1>" +
|
|
4707
|
+
notice +
|
|
4708
|
+
"<form class=\"survey-form\" method=\"post\" action=\"/survey/" + esc(opts.token) + "\">" +
|
|
4709
|
+
qs +
|
|
4710
|
+
"<div class=\"survey-form__actions\"><button class=\"btn-primary\" type=\"submit\">Submit feedback</button></div>" +
|
|
4711
|
+
"</form>" +
|
|
4712
|
+
"</div></section>";
|
|
4713
|
+
} else {
|
|
4714
|
+
var heads = {
|
|
4715
|
+
thankyou: ["Thank you", "Your feedback's in — we appreciate you taking the time."],
|
|
4716
|
+
responded: ["Already answered", "This feedback link has already been used. Thank you."],
|
|
4717
|
+
expired: ["This link has expired", "The feedback window for this survey has closed."],
|
|
4718
|
+
closed: ["This survey is closed", "This feedback link is no longer active."],
|
|
4719
|
+
notfound: ["Survey not found", "This feedback link isn't valid. Check the link from your email."],
|
|
4720
|
+
};
|
|
4721
|
+
var h = heads[state] || heads.notfound;
|
|
4722
|
+
body =
|
|
4723
|
+
"<section class=\"survey-page\"><div class=\"survey-page__inner survey-page__inner--msg\">" +
|
|
4724
|
+
"<h1 class=\"survey-page__title\">" + esc(h[0]) + "</h1>" +
|
|
4725
|
+
"<p class=\"survey-page__lede\">" + esc(h[1]) + "</p>" +
|
|
4726
|
+
"<a class=\"btn-ghost\" href=\"/\">Back to the shop</a>" +
|
|
4727
|
+
"</div></section>";
|
|
4728
|
+
}
|
|
4729
|
+
return _wrap({
|
|
4730
|
+
title: opts.title || "Survey",
|
|
4731
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
4732
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
4733
|
+
theme_css: opts.theme_css,
|
|
4734
|
+
body: body,
|
|
4735
|
+
});
|
|
4736
|
+
}
|
|
4737
|
+
|
|
4659
4738
|
function mount(router, deps) {
|
|
4660
4739
|
if (!router || typeof router.get !== "function") throw new TypeError("storefront.mount: router with .get() required");
|
|
4661
4740
|
if (!deps || !deps.catalog || !deps.cart) throw new TypeError("storefront.mount: deps.catalog + deps.cart required");
|
|
@@ -5046,6 +5125,93 @@ function mount(router, deps) {
|
|
|
5046
5125
|
});
|
|
5047
5126
|
}
|
|
5048
5127
|
|
|
5128
|
+
// ---- customer survey (token-gated) ----------------------------------
|
|
5129
|
+
// The invitation token IS the access — no login. GET renders the survey
|
|
5130
|
+
// (or a state notice); POST records the response. Container-only (the
|
|
5131
|
+
// token page is never edge-cached). Resilient: an unknown/garbage token,
|
|
5132
|
+
// a used/expired/closed invitation, and a missing surveys table all
|
|
5133
|
+
// resolve to a clean state page, never a 500.
|
|
5134
|
+
if (deps.customerSurveys) {
|
|
5135
|
+
var _surveyCtx = function (_req) {
|
|
5136
|
+
return {
|
|
5137
|
+
shop_name: (deps.config && deps.config.shop_name) || "blamejs.shop",
|
|
5138
|
+
cart_count: 0,
|
|
5139
|
+
theme_css: (deps.theme && deps.theme.assetUrl) ? deps.theme.assetUrl("css/main.css") : DEFAULT_THEME_CSS_URL,
|
|
5140
|
+
};
|
|
5141
|
+
};
|
|
5142
|
+
|
|
5143
|
+
router.get("/survey/:token", async function (req, res) {
|
|
5144
|
+
var token = (req.params && req.params.token) || "";
|
|
5145
|
+
var state = "notfound", survey = null;
|
|
5146
|
+
try {
|
|
5147
|
+
var preview = await deps.customerSurveys.previewByToken(token);
|
|
5148
|
+
if (preview) {
|
|
5149
|
+
survey = preview.survey;
|
|
5150
|
+
var inv = preview.invitation;
|
|
5151
|
+
if (inv.status === "responded") state = "responded";
|
|
5152
|
+
else if (inv.status === "closed") state = "closed";
|
|
5153
|
+
else if (inv.status === "expired" || Number(inv.expires_at) < Date.now()) state = "expired";
|
|
5154
|
+
else state = "form";
|
|
5155
|
+
}
|
|
5156
|
+
} catch (_e) { state = "notfound"; }
|
|
5157
|
+
var ctx = _surveyCtx(req);
|
|
5158
|
+
_send(res, state === "notfound" ? 404 : 200, renderSurveyPage({
|
|
5159
|
+
state: state, survey: survey, token: token,
|
|
5160
|
+
shop_name: ctx.shop_name, cart_count: ctx.cart_count, theme_css: ctx.theme_css,
|
|
5161
|
+
}));
|
|
5162
|
+
});
|
|
5163
|
+
|
|
5164
|
+
router.post("/survey/:token", async function (req, res) {
|
|
5165
|
+
var token = (req.params && req.params.token) || "";
|
|
5166
|
+
var body = req.body || {};
|
|
5167
|
+
var ctx = _surveyCtx(req);
|
|
5168
|
+
// Resolve the survey first so we can re-render the form on a
|
|
5169
|
+
// validation error (and map the token to its questions).
|
|
5170
|
+
var preview = null;
|
|
5171
|
+
try { preview = await deps.customerSurveys.previewByToken(token); }
|
|
5172
|
+
catch (_e) { preview = null; }
|
|
5173
|
+
if (!preview) {
|
|
5174
|
+
return _send(res, 404, renderSurveyPage({ state: "notfound", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
|
|
5175
|
+
}
|
|
5176
|
+
// Build the answers object from the q_<id> fields, typed per question
|
|
5177
|
+
// kind (rating → integer, select/free_text → string). Blank fields are
|
|
5178
|
+
// omitted so optional questions stay unanswered.
|
|
5179
|
+
var answers = {};
|
|
5180
|
+
var questions = preview.survey.questions || [];
|
|
5181
|
+
for (var i = 0; i < questions.length; i += 1) {
|
|
5182
|
+
var q = questions[i];
|
|
5183
|
+
var raw = body["q_" + q.id];
|
|
5184
|
+
if (raw == null || raw === "") continue;
|
|
5185
|
+
if (q.kind === "rating") {
|
|
5186
|
+
var n = parseInt(raw, 10);
|
|
5187
|
+
if (isFinite(n)) answers[q.id] = n;
|
|
5188
|
+
} else {
|
|
5189
|
+
answers[q.id] = String(raw);
|
|
5190
|
+
}
|
|
5191
|
+
}
|
|
5192
|
+
try {
|
|
5193
|
+
await deps.customerSurveys.submitResponse({ token: token, answers: answers });
|
|
5194
|
+
} catch (e) {
|
|
5195
|
+
// Known terminal states → the matching notice; a validation
|
|
5196
|
+
// TypeError → re-render the form with the cleaned message.
|
|
5197
|
+
var code = e && e.code;
|
|
5198
|
+
if (code === "SURVEY_INVITATION_ALREADY_RESPONDED") return _send(res, 200, renderSurveyPage({ state: "responded", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
|
|
5199
|
+
if (code === "SURVEY_INVITATION_EXPIRED") return _send(res, 200, renderSurveyPage({ state: "expired", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
|
|
5200
|
+
if (code === "SURVEY_INVITATION_CLOSED") return _send(res, 200, renderSurveyPage({ state: "closed", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
|
|
5201
|
+
if (code === "SURVEY_INVITATION_NOT_FOUND") return _send(res, 404, renderSurveyPage({ state: "notfound", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
|
|
5202
|
+
if (e instanceof TypeError) {
|
|
5203
|
+
return _send(res, 400, renderSurveyPage({
|
|
5204
|
+
state: "form", survey: preview.survey, token: token,
|
|
5205
|
+
notice: (e.message || "Please check your answers.").replace(/^customerSurveys[.:]\s*/, ""),
|
|
5206
|
+
shop_name: ctx.shop_name, theme_css: ctx.theme_css,
|
|
5207
|
+
}));
|
|
5208
|
+
}
|
|
5209
|
+
throw e;
|
|
5210
|
+
}
|
|
5211
|
+
_send(res, 200, renderSurveyPage({ state: "thankyou", token: token, shop_name: ctx.shop_name, theme_css: ctx.theme_css }));
|
|
5212
|
+
});
|
|
5213
|
+
}
|
|
5214
|
+
|
|
5049
5215
|
// Persist a locale choice. A GET form (works with JS off) from the
|
|
5050
5216
|
// footer switcher submits `lang` (the chosen tag) + `to` (the path to
|
|
5051
5217
|
// return to). We validate the tag against the active locale set, set
|
package/package.json
CHANGED