@blamejs/blamejs-shop 0.2.3 → 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 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.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
+
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.
14
+
11
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.
12
16
 
13
17
  - v0.2.2 (2026-05-27) — **Bespoke line-art illustrations across the storefront — category tiles, image placeholders, and every empty state.** The storefront's stand-in graphics are now a single, cohesive set of hand-drawn line illustrations in the brand violet, replacing the previous mix of gradient-only tiles, single-letter image marks, and emoji. The six featured-collection tiles carry a matching icon (apparel, hardware, digital, subscriptions, bundles, gift cards). Products without an image — in the grid and on the product page — show a bespoke "no image yet" placeholder instead of a letter glyph. Every empty state has its own illustration: cart, search, wishlist, saved-for-later, addresses, returns, and orders. The artwork is inline SVG (no extra files fetched, consistent with the strict asset policy) and adapts to the surface — the fine detail strokes follow the text colour, so the same set reads correctly on a dark or a light background. **Changed:** *Category tiles carry line-art icons* — Each of the six featured-collection tiles now shows a violet line icon for its category, centered over the existing tile gradient. The tile links and labels are unchanged. · *Bespoke image placeholder replaces the letter mark* — When a product has no image, the grid card and the product-page gallery render a line-art "no image yet" placeholder — an isometric package on a faint grid (the product page adds a shield motif and a terminal-prompt accent) — instead of the previous single uppercase letter. The no-image grid card now uses the same media-card layout as image-bearing cards for a consistent grid. · *Every empty state has matching artwork* — The empty cart, no-search-results, empty wishlist, saved-for-later, addresses, returns, and orders states now show a bespoke line illustration in place of the previous emoji or unicode glyphs, so the storefront and account sections share one visual language. · *Inline, theme-adaptive SVG* — All of the new artwork is inline SVG with presentation attributes only — nothing is fetched from a CDN and no inline styles are used, consistent with the strict content-security and asset policies. The detail strokes use currentColor, so the set adapts to the background it sits on. The edge and container renderers emit identical markup.
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 };
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
 
@@ -2098,6 +2098,204 @@ function mount(router, deps) {
2098
2098
  ));
2099
2099
  }
2100
2100
 
2101
+ // ---- announcements --------------------------------------------------
2102
+ // Sitewide promo/notice strip. Content-negotiated like the other console
2103
+ // screens: bearer → the JSON contract; signed-in browser → the HTML
2104
+ // table + create form. The console exposes the all / guest / logged_in
2105
+ // audiences; the primitive's segment audience needs an isMember handle
2106
+ // that isn't wired (see server.js), so it isn't offered here.
2107
+ if (deps.announcementBar) {
2108
+ var announcements = deps.announcementBar;
2109
+
2110
+ router.get("/admin/announcements", _pageOrApi(true,
2111
+ R(async function (req, res) {
2112
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
2113
+ var activeS = url && url.searchParams.get("active");
2114
+ var rows = await announcements.listAnnouncements(activeS === "1" ? { active_only: true } : {});
2115
+ _json(res, 200, { rows: rows });
2116
+ }),
2117
+ async function (req, res) {
2118
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
2119
+ var activeS = url && url.searchParams.get("active");
2120
+ var rows = await announcements.listAnnouncements(activeS === "1" ? { active_only: true } : {});
2121
+ _sendHtml(res, 200, renderAdminAnnouncements({
2122
+ shop_name: deps.shop_name, nav_available: navAvailable, announcements: rows,
2123
+ active_filter: activeS,
2124
+ created: url && url.searchParams.get("created"),
2125
+ archived: url && url.searchParams.get("archived"),
2126
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed for the announcement." : null,
2127
+ }));
2128
+ },
2129
+ ));
2130
+
2131
+ router.post("/admin/announcements", _pageOrApi(false,
2132
+ W("announcement.define", async function (req, res) {
2133
+ var row = await announcements.defineAnnouncement(req.body || {});
2134
+ _json(res, 201, row);
2135
+ return { id: row.slug };
2136
+ }),
2137
+ async function (req, res) {
2138
+ try {
2139
+ await announcements.defineAnnouncement(_announcementFromForm(req.body || {}));
2140
+ } catch (e) {
2141
+ if (!(e instanceof TypeError)) throw e;
2142
+ var rows = await announcements.listAnnouncements({});
2143
+ return _sendHtml(res, 400, renderAdminAnnouncements({
2144
+ shop_name: deps.shop_name, nav_available: navAvailable, announcements: rows,
2145
+ notice: (e && e.message || "Couldn't save that announcement.").replace(/^announcementBar[.:]\s*/, ""),
2146
+ }));
2147
+ }
2148
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".announcement.define", outcome: "success" });
2149
+ _redirect(res, "/admin/announcements?created=1");
2150
+ },
2151
+ ));
2152
+
2153
+ router.post("/admin/announcements/:slug/archive", _pageOrApi(false,
2154
+ W("announcement.archive", async function (req, res) {
2155
+ var row;
2156
+ try { row = await announcements.archiveAnnouncement(req.params.slug); }
2157
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
2158
+ if (!row) return _problem(res, 404, "announcement-not-found");
2159
+ _json(res, 200, row);
2160
+ return { id: row.slug };
2161
+ }),
2162
+ async function (req, res) {
2163
+ var slug = req.params.slug;
2164
+ var row = null;
2165
+ try { row = await announcements.archiveAnnouncement(slug); }
2166
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
2167
+ if (!row) return _redirect(res, "/admin/announcements?err=1");
2168
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".announcement.archive", outcome: "success", metadata: { slug: slug } });
2169
+ _redirect(res, "/admin/announcements?archived=1");
2170
+ },
2171
+ ));
2172
+ }
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
+
2101
2299
  // ---- analytics ------------------------------------------------------
2102
2300
 
2103
2301
  var analytics = deps.analytics || null;
@@ -2772,6 +2970,8 @@ var ADMIN_NAV_ITEMS = [
2772
2970
  { key: "questions", href: "/admin/questions", label: "Q&A", requires: "productQa" },
2773
2971
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
2774
2972
  { key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
2973
+ { key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
2974
+ { key: "surveys", href: "/admin/surveys", label: "Surveys", requires: "customerSurveys" },
2775
2975
  { key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
2776
2976
  { key: "webhooks", href: "/admin/webhooks", label: "Webhooks", requires: "webhooks" },
2777
2977
  { key: "integrations", href: "/admin/integrations", label: "Integrations" },
@@ -3910,6 +4110,241 @@ function renderAdminCollections(opts) {
3910
4110
  return _renderAdminShell(opts.shop_name, "Collections", bodyHtml, "collections", opts.nav_available);
3911
4111
  }
3912
4112
 
4113
+ // A <datetime-local> form value → epoch ms (integer) for the lib's
4114
+ // starts_at/expires_at, or null when blank/unparseable (open-ended). The
4115
+ // announcement primitive validates the result; a blank schedule field just
4116
+ // omits the bound.
4117
+ function _epochFromForm(v) {
4118
+ if (typeof v !== "string" || !v.trim()) return null;
4119
+ var t = Date.parse(v.trim());
4120
+ return Number.isFinite(t) ? t : null;
4121
+ }
4122
+
4123
+ // Coerce the create form into the shape announcementBar.defineAnnouncement
4124
+ // expects: trimmed slug, strict boolean `dismissible`, integer epoch
4125
+ // schedule bounds, and link_url/link_label as a both-or-neither pair.
4126
+ function _announcementFromForm(body) {
4127
+ body = body || {};
4128
+ var out = {
4129
+ slug: typeof body.slug === "string" ? body.slug.trim() : body.slug,
4130
+ message: body.message,
4131
+ theme: body.theme,
4132
+ audience: body.audience,
4133
+ dismissible: body.dismissible === "on" || body.dismissible === "1" || body.dismissible === true,
4134
+ };
4135
+ var lu = typeof body.link_url === "string" ? body.link_url.trim() : "";
4136
+ var ll = typeof body.link_label === "string" ? body.link_label.trim() : "";
4137
+ if (lu || ll) { out.link_url = lu; out.link_label = ll; }
4138
+ var sa = _epochFromForm(body.starts_at); if (sa != null) out.starts_at = sa;
4139
+ var ea = _epochFromForm(body.expires_at); if (ea != null) out.expires_at = ea;
4140
+ return out;
4141
+ }
4142
+
4143
+ function renderAdminAnnouncements(opts) {
4144
+ opts = opts || {};
4145
+ var rows = opts.announcements || [];
4146
+ var created = opts.created ? "<div class=\"banner banner--ok\">Announcement saved.</div>" : "";
4147
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Announcement archived.</div>" : "";
4148
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
4149
+
4150
+ var af = opts.active_filter;
4151
+ var chips = "<div class=\"order-filters\">" +
4152
+ "<a class=\"chip" + (af == null ? " chip--on" : "") + "\" href=\"/admin/announcements\">All</a>" +
4153
+ "<a class=\"chip" + (af === "1" ? " chip--on" : "") + "\" href=\"/admin/announcements?active=1\">Active now</a>" +
4154
+ "</div>";
4155
+
4156
+ var bodyRows = rows.map(function (a) {
4157
+ var isArchived = a.archived_at != null;
4158
+ return "<tr>" +
4159
+ "<td><code class=\"order-id\">" + _htmlEscape(a.slug) + "</code></td>" +
4160
+ "<td>" + _htmlEscape(a.message) + "</td>" +
4161
+ "<td><span class=\"status-pill\">" + _htmlEscape(a.theme) + "</span></td>" +
4162
+ "<td>" + _htmlEscape(a.audience) + "</td>" +
4163
+ "<td><span class=\"status-pill " + (isArchived ? "cancelled" : "paid") + "\">" + (isArchived ? "archived" : "active") + "</span></td>" +
4164
+ "<td><div class=\"actions-row\">" +
4165
+ (isArchived ? "" :
4166
+ "<form method=\"post\" action=\"/admin/announcements/" + _htmlEscape(encodeURIComponent(a.slug)) + "/archive\" class=\"form-inline\">" +
4167
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form>") +
4168
+ "</div></td>" +
4169
+ "</tr>";
4170
+ }).join("");
4171
+
4172
+ var table = rows.length
4173
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Message</th><th scope=\"col\">Theme</th><th scope=\"col\">Audience</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + bodyRows + "</tbody></table></div>"
4174
+ : "<p class=\"empty\">No announcements" + (af === "1" ? " active right now" : " yet") + ".</p>";
4175
+
4176
+ var themeOpts = ["urgency", "promo", "info", "success"].map(function (t) {
4177
+ return "<option value=\"" + t + "\"" + (t === "info" ? " selected" : "") + ">" + t + "</option>";
4178
+ }).join("");
4179
+ var audienceOpts = ["all", "guest", "logged_in"].map(function (au) {
4180
+ return "<option value=\"" + au + "\">" + au + "</option>";
4181
+ }).join("");
4182
+
4183
+ var createForm =
4184
+ "<div class=\"panel mt mw-40\">" +
4185
+ "<h3 class=\"subhead\">Create an announcement</h3>" +
4186
+ "<p class=\"meta\">The highest-priority active announcement shows at the top of every page (urgency &gt; promo &gt; info &gt; success). Leave the schedule blank for an open-ended notice.</p>" +
4187
+ "<form method=\"post\" action=\"/admin/announcements\">" +
4188
+ _setupField("Slug", "slug", "", "text", "Lowercase, hyphenated — a stable id.", " maxlength=\"64\" required") +
4189
+ "<label class=\"form-field\"><span>Message</span><textarea name=\"message\" maxlength=\"500\" required></textarea></label>" +
4190
+ _setupField("Link URL (optional)", "link_url", "", "text", "https:// or a /-rooted path.", " maxlength=\"2048\"") +
4191
+ _setupField("Link label (optional)", "link_label", "", "text", "", " maxlength=\"120\"") +
4192
+ "<label class=\"form-field\"><span>Theme</span><select name=\"theme\">" + themeOpts + "</select></label>" +
4193
+ "<label class=\"form-field\"><span>Audience</span><select name=\"audience\">" + audienceOpts + "</select></label>" +
4194
+ "<label class=\"form-field\"><span>Starts at (optional)</span><input type=\"datetime-local\" name=\"starts_at\"></label>" +
4195
+ "<label class=\"form-field\"><span>Expires at (optional)</span><input type=\"datetime-local\" name=\"expires_at\"></label>" +
4196
+ "<label class=\"kv\"><input type=\"checkbox\" name=\"dismissible\" value=\"on\" checked> Visitors can dismiss this</label>" +
4197
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Create announcement</button></div>" +
4198
+ "</form>" +
4199
+ "</div>";
4200
+
4201
+ var bodyHtml = "<section><h2>Announcements</h2>" + created + archived + notice + chips + table + createForm + "</section>";
4202
+ return _renderAdminShell(opts.shop_name, "Announcements", bodyHtml, "announcements", opts.nav_available);
4203
+ }
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
+
3913
4348
  function renderAdminCollection(opts) {
3914
4349
  opts = opts || {};
3915
4350
  var col = opts.collection;
@@ -1,13 +1,17 @@
1
1
  {
2
- "version": "0.2.3",
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-iKbkn3AR4k733EgN+1Ju51qbKfz9yVmDk8lE2l99asnRGbSo4BJu4Lo+OTxcBME4",
10
- "fingerprinted": "css/main.5678b2596038b019.css"
9
+ "integrity": "sha384-G7yMuGZ4ojVoEpiRX70qcuR+YcQU4A1K/jRhRE/yPD7PXkqUH4LruBlu6MPCv+R8",
10
+ "fingerprinted": "css/main.b99ea24f442deef8.css"
11
+ },
12
+ "js/announcement.js": {
13
+ "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
14
+ "fingerprinted": "js/announcement.59eab4872d2f3b4c.js"
11
15
  },
12
16
  "js/consent.js": {
13
17
  "integrity": "sha384-KKMQ0og8HPOykRRPpUyxX7dMhTvKySfVtpGX/jGWzZwNaN/c4OykvRvXpqBHcQST",
@@ -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,