@blamejs/blamejs-shop 0.2.4 → 0.2.6
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 +412 -1
- package/lib/asset-manifest.json +3 -3
- package/lib/customer-surveys.js +40 -0
- package/lib/storefront.js +293 -4
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +4 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/middleware/body-parser.js +95 -49
- package/lib/vendor/blamejs/lib/template.js +159 -23
- package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs/lib/vendor/simplewebauthn-server.cjs +31 -28
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.17.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.18.json +18 -0
- package/lib/vendor/blamejs/test/00-primitives.js +144 -3
- 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.2.x
|
|
10
10
|
|
|
11
|
+
- v0.2.6 (2026-05-27) — **Business-hours page, a graceful unconfigured-checkout state, and the brand shield on the product page.** Three storefront changes. Operators can publish their open hours: define schedules (timezone + per-weekday open/close) from a new Hours screen in the admin console, and a public /hours page renders each as a weekly grid with a live open/closed status computed per request in the schedule's timezone. The cart now degrades gracefully when no payment provider is configured — instead of a checkout button that leads to a dead route, it shows a clear, disabled 'checkout isn't set up yet' notice, and a direct visit to /checkout returns a tidy 'unavailable' page rather than a 404. And the product page's post-quantum-checkout trust badge now uses the real brand shield mark (the site favicon) in place of a CSS approximation. **Added:** *Public /hours page* — A server-rendered page listing every active schedule as a weekday grid (closed days marked), with a live open/closed pill and the next transition ("Opens Monday at 09:00" / "Closes at 17:00"). Timezone-aware and computed per request, so it isn't cached stale. · *Admin console: Hours* — A new Hours screen (and matching JSON API) to create a schedule with a timezone and per-weekday open/close times (a blank day is closed), list schedules, and archive ones no longer in use. Holiday closures and one-off date exceptions — which override the weekly base — are available through the API. The screen appears only when the business-hours primitive is wired. **Changed:** *Cart and /checkout degrade gracefully without a payment provider* — When checkout isn't configured (no payment provider), the cart no longer shows a checkout button that leads nowhere — it shows a disabled control with a clear note that online checkout isn't set up yet and the cart is saved. A direct visit to /checkout now returns a tidy 503 'checkout unavailable' page instead of a 404. Configuring a payment provider restores the full cart → shipping → payment → confirmation flow with no other change. · *Product-page trust badge uses the brand shield* — The post-quantum-secured-checkout badge on the product page now renders the actual brand shield mark (the site icon) rather than a CSS-drawn hex approximation, so the badge matches the brand identity used elsewhere.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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, businessHours: !!deps.businessHours };
|
|
304
304
|
|
|
305
305
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
306
306
|
|
|
@@ -2171,6 +2171,198 @@ 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
|
+
|
|
2299
|
+
// ---- business hours -------------------------------------------------
|
|
2300
|
+
// Define open/close schedules surfaced on the public /hours page. The
|
|
2301
|
+
// console create form takes a per-weekday open/close pair (blank = closed
|
|
2302
|
+
// that day); holidays + one-off exceptions are managed via the JSON API.
|
|
2303
|
+
// Content-negotiated like the other screens.
|
|
2304
|
+
if (deps.businessHours) {
|
|
2305
|
+
var hours = deps.businessHours;
|
|
2306
|
+
|
|
2307
|
+
router.get("/admin/hours", _pageOrApi(true,
|
|
2308
|
+
R(async function (req, res) {
|
|
2309
|
+
_json(res, 200, { rows: await hours.listSchedules() });
|
|
2310
|
+
}),
|
|
2311
|
+
async function (req, res) {
|
|
2312
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
2313
|
+
var rows = await hours.listSchedules();
|
|
2314
|
+
_sendHtml(res, 200, renderAdminHours({
|
|
2315
|
+
shop_name: deps.shop_name, nav_available: navAvailable, schedules: rows,
|
|
2316
|
+
created: url && url.searchParams.get("created"),
|
|
2317
|
+
archived: url && url.searchParams.get("archived"),
|
|
2318
|
+
notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the schedule." : null,
|
|
2319
|
+
}));
|
|
2320
|
+
},
|
|
2321
|
+
));
|
|
2322
|
+
|
|
2323
|
+
router.post("/admin/hours", _pageOrApi(false,
|
|
2324
|
+
W("hours.define", async function (req, res) {
|
|
2325
|
+
var row = await hours.defineSchedule(req.body || {});
|
|
2326
|
+
_json(res, 201, row);
|
|
2327
|
+
return { id: row.slug };
|
|
2328
|
+
}),
|
|
2329
|
+
async function (req, res) {
|
|
2330
|
+
try {
|
|
2331
|
+
await hours.defineSchedule(_scheduleFromForm(req.body || {}));
|
|
2332
|
+
} catch (e) {
|
|
2333
|
+
if (!(e instanceof TypeError)) throw e;
|
|
2334
|
+
var rows = await hours.listSchedules();
|
|
2335
|
+
return _sendHtml(res, 400, renderAdminHours({
|
|
2336
|
+
shop_name: deps.shop_name, nav_available: navAvailable, schedules: rows,
|
|
2337
|
+
notice: (e.message || "Couldn't save that schedule.").replace(/^businessHours[.:]\s*/, ""),
|
|
2338
|
+
}));
|
|
2339
|
+
}
|
|
2340
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".hours.define", outcome: "success" });
|
|
2341
|
+
_redirect(res, "/admin/hours?created=1");
|
|
2342
|
+
},
|
|
2343
|
+
));
|
|
2344
|
+
|
|
2345
|
+
router.post("/admin/hours/:slug/archive", _pageOrApi(false,
|
|
2346
|
+
W("hours.archive", async function (req, res) {
|
|
2347
|
+
var row;
|
|
2348
|
+
try { row = await hours.archiveSchedule(req.params.slug); }
|
|
2349
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
2350
|
+
if (!row) return _problem(res, 404, "schedule-not-found");
|
|
2351
|
+
_json(res, 200, row);
|
|
2352
|
+
return { id: row.slug };
|
|
2353
|
+
}),
|
|
2354
|
+
async function (req, res) {
|
|
2355
|
+
var slug = req.params.slug;
|
|
2356
|
+
var row = null;
|
|
2357
|
+
try { row = await hours.archiveSchedule(slug); }
|
|
2358
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; }
|
|
2359
|
+
if (!row) return _redirect(res, "/admin/hours?err=1");
|
|
2360
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".hours.archive", outcome: "success", metadata: { slug: slug } });
|
|
2361
|
+
_redirect(res, "/admin/hours?archived=1");
|
|
2362
|
+
},
|
|
2363
|
+
));
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2174
2366
|
// ---- analytics ------------------------------------------------------
|
|
2175
2367
|
|
|
2176
2368
|
var analytics = deps.analytics || null;
|
|
@@ -2846,6 +3038,8 @@ var ADMIN_NAV_ITEMS = [
|
|
|
2846
3038
|
{ key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
|
|
2847
3039
|
{ key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
|
|
2848
3040
|
{ key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
|
|
3041
|
+
{ key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
|
|
3042
|
+
{ key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
|
|
2849
3043
|
{ key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
|
|
2850
3044
|
{ key: "webhooks", href: "/admin/webhooks", label: "Webhooks", requires: "webhooks" },
|
|
2851
3045
|
{ key: "integrations", href: "/admin/integrations", label: "Integrations" },
|
|
@@ -4076,6 +4270,223 @@ function renderAdminAnnouncements(opts) {
|
|
|
4076
4270
|
return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
|
|
4077
4271
|
}
|
|
4078
4272
|
|
|
4273
|
+
// Standard question set per survey kind, so the console can create the
|
|
4274
|
+
// common surveys without a dynamic question builder (fully custom question
|
|
4275
|
+
// lists go through the JSON defineSurvey API). Each question carries a
|
|
4276
|
+
// stable id so historical responses survive a later edit.
|
|
4277
|
+
function _standardSurveyQuestions(kind) {
|
|
4278
|
+
if (kind === "nps") {
|
|
4279
|
+
return [
|
|
4280
|
+
{ id: "score", kind: "rating", label: "How likely are you to recommend us to a friend?", max: 10, required: true },
|
|
4281
|
+
{ id: "reason", kind: "free_text", label: "What's the main reason for your score?", required: false },
|
|
4282
|
+
];
|
|
4283
|
+
}
|
|
4284
|
+
if (kind === "ces") {
|
|
4285
|
+
return [
|
|
4286
|
+
{ id: "effort", kind: "rating", label: "How easy was it to get what you needed?", max: 7, required: true },
|
|
4287
|
+
{ id: "reason", kind: "free_text", label: "Anything that made it harder than it should have been?", required: false },
|
|
4288
|
+
];
|
|
4289
|
+
}
|
|
4290
|
+
if (kind === "csat") {
|
|
4291
|
+
return [
|
|
4292
|
+
{ id: "score", kind: "rating", label: "How satisfied were you with your experience?", max: 5, required: true },
|
|
4293
|
+
{ id: "comment", kind: "free_text", label: "Anything you'd like to add?", required: false },
|
|
4294
|
+
];
|
|
4295
|
+
}
|
|
4296
|
+
// custom — a single open-ended prompt the operator can refine via the API.
|
|
4297
|
+
return [
|
|
4298
|
+
{ id: "feedback", kind: "free_text", label: "Your feedback", required: true },
|
|
4299
|
+
];
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
function renderAdminSurveys(opts) {
|
|
4303
|
+
opts = opts || {};
|
|
4304
|
+
var rows = opts.surveys || [];
|
|
4305
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Survey created.</div>" : "";
|
|
4306
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Survey archived.</div>" : "";
|
|
4307
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
4308
|
+
|
|
4309
|
+
var af = opts.active_filter;
|
|
4310
|
+
var chips = "<div class=\"order-filters\">" +
|
|
4311
|
+
"<a class=\"chip" + (af == null ? " chip--on" : "") + "\" href=\"/admin/surveys\">All</a>" +
|
|
4312
|
+
"<a class=\"chip" + (af === "1" ? " chip--on" : "") + "\" href=\"/admin/surveys?active=1\">Active</a>" +
|
|
4313
|
+
"</div>";
|
|
4314
|
+
|
|
4315
|
+
var bodyRows = rows.map(function (s) {
|
|
4316
|
+
var isArchived = s.archived_at != null;
|
|
4317
|
+
var enc = _htmlEscape(encodeURIComponent(s.slug));
|
|
4318
|
+
return "<tr>" +
|
|
4319
|
+
"<td><a href=\"/admin/surveys/" + enc + "\"><strong>" + _htmlEscape(s.title) + "</strong></a></td>" +
|
|
4320
|
+
"<td><code class=\"order-id\">" + _htmlEscape(s.slug) + "</code></td>" +
|
|
4321
|
+
"<td><span class=\"status-pill\">" + _htmlEscape(s.kind) + "</span></td>" +
|
|
4322
|
+
"<td>" + _htmlEscape(s.trigger_event || s.trigger || "manual") + "</td>" +
|
|
4323
|
+
"<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
|
|
4324
|
+
"<td><div class=\"actions-row\">" +
|
|
4325
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/surveys/" + enc + "\">Open</a>" +
|
|
4326
|
+
(isArchived ? "" :
|
|
4327
|
+
"<form method=\"post\" action=\"/admin/surveys/" + enc + "/archive\" class=\"form-inline\">" +
|
|
4328
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
|
|
4329
|
+
"</div></td>" +
|
|
4330
|
+
"</tr>";
|
|
4331
|
+
}).join("");
|
|
4332
|
+
|
|
4333
|
+
var table = rows.length
|
|
4334
|
+
? "<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>"
|
|
4335
|
+
: "<p class=\"empty\">No surveys" + (af === "1" ? " active" : " yet") + ".</p>";
|
|
4336
|
+
|
|
4337
|
+
var kindOpts = [["nps", "NPS — recommend score"], ["csat", "CSAT — satisfaction"], ["ces", "CES — ease of effort"], ["custom", "Custom — open feedback"]]
|
|
4338
|
+
.map(function (k) { return "<option value=\"" + k[0] + "\">" + _htmlEscape(k[1]) + "</option>"; }).join("");
|
|
4339
|
+
var trigOpts = [["manual", "Manual (issue invitations yourself)"], ["after_delivery", "After delivery"], ["after_support_close", "After support close"], ["after_refund", "After refund"]]
|
|
4340
|
+
.map(function (t) { return "<option value=\"" + t[0] + "\">" + _htmlEscape(t[1]) + "</option>"; }).join("");
|
|
4341
|
+
|
|
4342
|
+
var createForm =
|
|
4343
|
+
"<div class=\"panel mt mw-40\">" +
|
|
4344
|
+
"<h3 class=\"subhead\">Create a survey</h3>" +
|
|
4345
|
+
"<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>" +
|
|
4346
|
+
"<form method=\"post\" action=\"/admin/surveys\">" +
|
|
4347
|
+
_setupField("Title", "title", "", "text", "", " maxlength=\"200\" required") +
|
|
4348
|
+
_setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — a stable id.", " maxlength=\"64\" required") +
|
|
4349
|
+
"<label class=\"form-field\"><span>Kind</span><select name=\"kind\">" + kindOpts + "</select></label>" +
|
|
4350
|
+
"<label class=\"form-field\"><span>Trigger</span><select name=\"trigger\">" + trigOpts + "</select></label>" +
|
|
4351
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create survey</button></div>" +
|
|
4352
|
+
"</form>" +
|
|
4353
|
+
"</div>";
|
|
4354
|
+
|
|
4355
|
+
var bodyHtml = "<section><h2>Surveys</h2>" + created + archived + notice + chips + table + createForm + "</section>";
|
|
4356
|
+
return _renderAdminShell(opts.shop_name, "Surveys", bodyHtml, "surveys", opts.nav_available);
|
|
4357
|
+
}
|
|
4358
|
+
|
|
4359
|
+
function renderAdminSurveyDetail(opts) {
|
|
4360
|
+
opts = opts || {};
|
|
4361
|
+
var survey = opts.survey;
|
|
4362
|
+
if (!survey) {
|
|
4363
|
+
var nf = "<section><h2>Survey</h2><p class=\"empty\">Survey not found.</p>" +
|
|
4364
|
+
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/surveys\">Back to surveys</a></div></section>";
|
|
4365
|
+
return _renderAdminShell(opts.shop_name, "Survey", nf, "surveys", opts.nav_available);
|
|
4366
|
+
}
|
|
4367
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
4368
|
+
|
|
4369
|
+
// Freshly-issued invitation link — shown exactly once (the plaintext
|
|
4370
|
+
// token is never persisted). The operator copies it into their own
|
|
4371
|
+
// email/SMS to the customer.
|
|
4372
|
+
var linkReveal = "";
|
|
4373
|
+
if (opts.issued_link) {
|
|
4374
|
+
var path = "/survey/" + _htmlEscape(opts.issued_link);
|
|
4375
|
+
linkReveal =
|
|
4376
|
+
"<div class=\"banner banner--ok\">Invitation issued. Copy this single-use link and send it to the customer — it won't be shown again:" +
|
|
4377
|
+
"<br><code class=\"code-block\">" + path + "</code></div>";
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
var roll = opts.rollup || { response_count: 0, per_question: [] };
|
|
4381
|
+
var headline = "";
|
|
4382
|
+
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)";
|
|
4383
|
+
else if (roll.csat) headline = "CSAT: <strong>" + _htmlEscape(String(roll.csat.positive_pct)) + "%</strong> positive";
|
|
4384
|
+
else if (roll.ces) headline = "CES mean: <strong>" + _htmlEscape(String(roll.ces.mean)) + "</strong>";
|
|
4385
|
+
|
|
4386
|
+
var qRows = (roll.per_question || []).map(function (pq) {
|
|
4387
|
+
var detail;
|
|
4388
|
+
if (pq.kind === "rating") detail = "mean " + _htmlEscape(String(pq.mean)) + " over " + _htmlEscape(String(pq.count)) + " answers";
|
|
4389
|
+
else if (pq.kind === "select") {
|
|
4390
|
+
detail = Object.keys(pq.buckets || {}).map(function (o) { return _htmlEscape(o) + ": " + _htmlEscape(String(pq.buckets[o])); }).join(", ");
|
|
4391
|
+
} else detail = _htmlEscape(String(pq.count)) + " written responses";
|
|
4392
|
+
return "<tr><td><code class=\"order-id\">" + _htmlEscape(pq.id) + "</code></td><td>" + _htmlEscape(pq.kind) + "</td><td>" + detail + "</td></tr>";
|
|
4393
|
+
}).join("");
|
|
4394
|
+
var rollupPanel =
|
|
4395
|
+
"<div class=\"panel\">" +
|
|
4396
|
+
"<p class=\"meta\">" + _htmlEscape(String(roll.response_count)) + " response(s)." + (headline ? " " + headline : "") + "</p>" +
|
|
4397
|
+
(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>" : "") +
|
|
4398
|
+
"</div>";
|
|
4399
|
+
|
|
4400
|
+
var enc = _htmlEscape(encodeURIComponent(survey.slug));
|
|
4401
|
+
var issueForm =
|
|
4402
|
+
"<div class=\"panel mt mw-40\">" +
|
|
4403
|
+
"<h3 class=\"subhead\">Issue an invitation</h3>" +
|
|
4404
|
+
"<p class=\"meta\">Generates a single-use survey link for one customer. You send the link; the customer answers without signing in.</p>" +
|
|
4405
|
+
"<form method=\"post\" action=\"/admin/surveys/" + enc + "/issue\">" +
|
|
4406
|
+
_setupField("Customer id", "customer_id", "", "text", "The customer's UUID.", " maxlength=\"64\" required") +
|
|
4407
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Issue invitation</button>" +
|
|
4408
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/surveys\">Back to surveys</a></div>" +
|
|
4409
|
+
"</form>" +
|
|
4410
|
+
"</div>";
|
|
4411
|
+
|
|
4412
|
+
var bodyHtml = "<section><h2>" + _htmlEscape(survey.title) + "</h2>" + notice + linkReveal + rollupPanel + issueForm + "</section>";
|
|
4413
|
+
return _renderAdminShell(opts.shop_name, survey.title, bodyHtml, "surveys", opts.nav_available);
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
// Build a defineSchedule input from the per-weekday open/close form fields
|
|
4417
|
+
// (d0_open/d0_close .. d6_open/d6_close). A day with both a start and end
|
|
4418
|
+
// time becomes a weekly_hours entry; a blank day is closed. Sun=0.
|
|
4419
|
+
function _scheduleFromForm(body) {
|
|
4420
|
+
body = body || {};
|
|
4421
|
+
var weekly = [];
|
|
4422
|
+
for (var d = 0; d < 7; d += 1) {
|
|
4423
|
+
var open = body["d" + d + "_open"];
|
|
4424
|
+
var close = body["d" + d + "_close"];
|
|
4425
|
+
if (typeof open === "string" && open.trim() && typeof close === "string" && close.trim()) {
|
|
4426
|
+
weekly.push({ day: d, open: open.trim(), close: close.trim() });
|
|
4427
|
+
}
|
|
4428
|
+
}
|
|
4429
|
+
return {
|
|
4430
|
+
slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
|
|
4431
|
+
timezone: body.timezone,
|
|
4432
|
+
weekly_hours: weekly,
|
|
4433
|
+
};
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
function renderAdminHours(opts) {
|
|
4437
|
+
opts = opts || {};
|
|
4438
|
+
var rows = opts.schedules || [];
|
|
4439
|
+
var created = opts.created ? "<div class=\"banner banner--ok\">Schedule saved.</div>" : "";
|
|
4440
|
+
var archived = opts.archived ? "<div class=\"banner banner--ok\">Schedule archived.</div>" : "";
|
|
4441
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
4442
|
+
var DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
4443
|
+
|
|
4444
|
+
var bodyRows = rows.map(function (s) {
|
|
4445
|
+
var isArchived = s.archived_at != null;
|
|
4446
|
+
var enc = _htmlEscape(encodeURIComponent(s.slug));
|
|
4447
|
+
var openDays = ((s.weekly_hours || []).reduce(function (set, w) { set[w.day] = 1; return set; }, {}));
|
|
4448
|
+
var dayCount = Object.keys(openDays).length;
|
|
4449
|
+
return "<tr>" +
|
|
4450
|
+
"<td><code class=\"order-id\">" + _htmlEscape(s.slug) + "</code></td>" +
|
|
4451
|
+
"<td>" + _htmlEscape(s.timezone) + "</td>" +
|
|
4452
|
+
"<td class=\"num\">" + dayCount + " open day(s)</td>" +
|
|
4453
|
+
"<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
|
|
4454
|
+
"<td><div class=\"actions-row\">" +
|
|
4455
|
+
(isArchived ? "" :
|
|
4456
|
+
"<form method=\"post\" action=\"/admin/hours/" + enc + "/archive\" class=\"form-inline\">" +
|
|
4457
|
+
"<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
|
|
4458
|
+
"</div></td>" +
|
|
4459
|
+
"</tr>";
|
|
4460
|
+
}).join("");
|
|
4461
|
+
|
|
4462
|
+
var table = rows.length
|
|
4463
|
+
? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Schedule</th><th scope=\"col\">Timezone</th><th scope=\"col\" class=\"num\">Days</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
|
|
4464
|
+
: "<p class=\"empty\">No schedules yet.</p>";
|
|
4465
|
+
|
|
4466
|
+
var dayFields = DOW.map(function (label, d) {
|
|
4467
|
+
return "<div class=\"actions-row\">" +
|
|
4468
|
+
"<span class=\"u-mute\">" + label + "</span>" +
|
|
4469
|
+
"<input type=\"time\" name=\"d" + d + "_open\" aria-label=\"" + label + " open\">" +
|
|
4470
|
+
"<input type=\"time\" name=\"d" + d + "_close\" aria-label=\"" + label + " close\">" +
|
|
4471
|
+
"</div>";
|
|
4472
|
+
}).join("");
|
|
4473
|
+
|
|
4474
|
+
var createForm =
|
|
4475
|
+
"<div class=\"panel mt mw-42\">" +
|
|
4476
|
+
"<h3 class=\"subhead\">Create a schedule</h3>" +
|
|
4477
|
+
"<p class=\"meta\">Set an open + close time per weekday (leave a day blank to mark it closed). Times are in the schedule's timezone. Holidays + one-off exceptions are managed via the API.</p>" +
|
|
4478
|
+
"<form method=\"post\" action=\"/admin/hours\">" +
|
|
4479
|
+
_setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — e.g. support or store.", " maxlength=\"64\" required") +
|
|
4480
|
+
_setupField("Timezone", "timezone", "", "text", "IANA name, e.g. America/New_York.", " maxlength=\"64\" required") +
|
|
4481
|
+
"<fieldset class=\"box\"><legend class=\"legend-sm\">Weekly hours (open / close)</legend>" + dayFields + "</fieldset>" +
|
|
4482
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create schedule</button></div>" +
|
|
4483
|
+
"</form>" +
|
|
4484
|
+
"</div>";
|
|
4485
|
+
|
|
4486
|
+
var bodyHtml = "<section><h2>Business hours</h2>" + created + archived + notice + table + createForm + "</section>";
|
|
4487
|
+
return _renderAdminShell(opts.shop_name, "Hours", bodyHtml, "hours", opts.nav_available);
|
|
4488
|
+
}
|
|
4489
|
+
|
|
4079
4490
|
function renderAdminCollection(opts) {
|
|
4080
4491
|
opts = opts || {};
|
|
4081
4492
|
var col = opts.collection;
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.6",
|
|
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-rjXOXDbgXWPBR/7YqM07j7oAN9Gx9SIY31GUdViVAFG/Tw4HD519zbVvXmYEpUrz",
|
|
10
|
+
"fingerprinted": "css/main.6825424436cea4c0.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,
|