@blamejs/blamejs-shop 0.4.54 → 0.4.56

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/admin.js +385 -2
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/vendor/MANIFEST.json +41 -35
  5. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  6. package/lib/vendor/blamejs/SECURITY.md +1 -0
  7. package/lib/vendor/blamejs/api-snapshot.json +10 -2
  8. package/lib/vendor/blamejs/examples/wiki/lib/html-entities.js +24 -0
  9. package/lib/vendor/blamejs/examples/wiki/lib/symbol-index.js +7 -5
  10. package/lib/vendor/blamejs/examples/wiki/test/e2e.js +9 -1
  11. package/lib/vendor/blamejs/examples/wiki/test/validate-nav-coverage.js +2 -8
  12. package/lib/vendor/blamejs/lib/acme.js +7 -11
  13. package/lib/vendor/blamejs/lib/client-hints.js +3 -1
  14. package/lib/vendor/blamejs/lib/cluster.js +4 -2
  15. package/lib/vendor/blamejs/lib/guard-filename.js +6 -2
  16. package/lib/vendor/blamejs/lib/http-client-cache.js +3 -1
  17. package/lib/vendor/blamejs/lib/http-message-signature.js +25 -8
  18. package/lib/vendor/blamejs/lib/log-stream-otlp-grpc.js +12 -1
  19. package/lib/vendor/blamejs/lib/log-stream-syslog.js +6 -0
  20. package/lib/vendor/blamejs/lib/log.js +24 -2
  21. package/lib/vendor/blamejs/lib/mail.js +5 -0
  22. package/lib/vendor/blamejs/lib/middleware/body-parser.js +48 -6
  23. package/lib/vendor/blamejs/lib/network-dns.js +22 -26
  24. package/lib/vendor/blamejs/lib/network-heartbeat.js +3 -3
  25. package/lib/vendor/blamejs/lib/network-proxy.js +3 -7
  26. package/lib/vendor/blamejs/lib/network-tls.js +34 -13
  27. package/lib/vendor/blamejs/lib/network.js +2 -6
  28. package/lib/vendor/blamejs/lib/notify.js +7 -12
  29. package/lib/vendor/blamejs/lib/seeders.js +5 -10
  30. package/lib/vendor/blamejs/lib/structured-fields.js +38 -1
  31. package/lib/vendor/blamejs/package.json +1 -1
  32. package/lib/vendor/blamejs/release-notes/v0.15.12.json +47 -0
  33. package/lib/vendor/blamejs/test/00-primitives.js +24 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-error-redaction.test.js +74 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -8
  36. package/lib/vendor/blamejs/test/layer-0-primitives/guard-filename.test.js +11 -0
  37. package/lib/vendor/blamejs/test/layer-0-primitives/http-message-signature.test.js +33 -0
  38. package/lib/vendor/blamejs/test/layer-0-primitives/log-stream-otlp-grpc.test.js +27 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/network-tls.test.js +31 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/structured-fields.test.js +14 -0
  41. package/lib/winback-campaigns.js +202 -42
  42. 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.4.x
10
10
 
11
+ - v0.4.56 (2026-06-14) — **Win-back campaigns: re-engage lapsed customers with an escalating sequence of offers.** A new operator console defines win-back campaigns that re-engage customers who have not ordered in a while. A campaign sets a lapse window and an escalating sequence of steps, each with its own delay and an optional coupon. A scheduled pass enrols customers who cross the lapse window and sends each due step through the store's email transport, minting the step's coupon. The send is gated for consent and deliverability: a customer who has withdrawn marketing-email consent or sits on the marketing suppression list is never emailed, and an opted-out customer is removed from the sequence so no later step reaches them — and if the suppression check itself is unavailable the send is held rather than risked. Each step is sent at most once even if two scheduled passes overlap. Campaigns are inert until an operator creates and activates one and the store's email transport is configured. No migration to apply. **Added:** *Win-back campaign console + scheduled send* — Operators can create, list, activate, pause, and archive win-back campaigns from a new loyalty/marketing console screen: a lapse window plus an escalating sequence of steps, each with a delay and an optional coupon (a single-use, per-customer discount code minted when the step sends). A scheduled pass scans for lapsed customers, enrols them, advances each enrolment through the sequence, sends the due step via the store's email transport, and records a delivery log and per-campaign metrics. Every send is consent- and suppression-gated: a customer who withdrew marketing-email consent or is on the marketing suppression list is never emailed and is dropped from the sequence; a suppression-check outage holds the send rather than risking an unwanted message. The scheduled pass is idempotent — a step is sent at most once even if two passes overlap. The feature is inert until a campaign is created and activated and the email transport is configured.
12
+
13
+ - v0.4.55 (2026-06-14) — **Refresh the vendored blamejs framework to 0.15.12.** Refreshes the vendored blamejs framework from 0.15.11 to 0.15.12, a sweep of defense-in-depth hardening the shop picks up by composing the framework. The body parser no longer echoes a caught exception's internal detail (a filesystem errno and temp path, or a parse hook's thrown message) to the HTTP client — the client gets a generic status phrase while the full detail stays on the server-side audit chain. Filename guarding now strips every reserved character rather than only the first. The boot-time logger escapes bidirectional and control characters on every sink, closing a terminal log-forging and line-reordering vector. Structured-field string values (used by HTTP Message Signatures, Client Hints, and Cache-Control) are now decoded in a single conformant pass, the HTTP Message Signature content-digest check matches in constant time against the exact digest member, and any outbound TLS connection that runs with certificate validation disabled now emits an audit event so the degraded posture is visible. This refresh carries no shop-facing API change and applies no migration; it keeps the bundled framework current and the security posture aligned with the latest release. **Changed:** *Vendored blamejs refreshed to 0.15.12* — The bundled framework is updated to blamejs 0.15.12. It redacts internal error detail from body-parser error responses (the client gets a generic status phrase; full diagnostics stay on the audit chain), strips every reserved character in filename guarding instead of only the first, escapes bidirectional and control characters in the boot logger on every sink (Trojan-Source / log-forging defense), decodes RFC 8941 structured-field strings in a single conformant pass, verifies the HTTP Message Signature content-digest by exact constant-time member match, and emits an audit event whenever an outbound TLS connection is configured to skip certificate validation. No shop API change; the framework's PQC-first crypto, security middleware, and request lifecycle are carried forward as-is.
14
+
11
15
  - v0.4.54 (2026-06-14) — **The rewards page now shows what a customer's loyalty tier includes and their progress to the next tier.** A signed-in customer's rewards page now shows their current loyalty tier, how many points remain to reach the next tier (with a progress bar), and the perks their tier includes. Operators author the per-tier perks from a new console screen — free shipping over a threshold, a percent discount, early or exclusive access, priority support, or a birthday bonus. The perks are presented to customers as what their tier includes and that the shop honours them; they are not applied automatically at checkout, so the wording never promises an automatic discount the store doesn't yet apply. No migration to apply. **Added:** *Loyalty tier perks and next-tier progress on the rewards page* — The /account rewards page gains two sections: a tier-progress panel naming the customer's current tier and the points still needed to reach the next one (with a labelled progress bar, or a top-tier acknowledgement), and a list of the perks the customer's tier includes. The tier is resolved from the customer's own loyalty balance; the perks come from the operator-authored tier-benefit definitions. The perks are framed as tier inclusions the shop honours — the copy directs the customer to ask at checkout or contact support to have a perk applied — rather than implying an automatic discount. · *Tier-benefit authoring in the loyalty console* — A new admin screen under the loyalty console lets operators define the perks each tier includes: free shipping (optionally over a minimum order), a percent discount, early access (hours before general release), priority support (an SLA in minutes), exclusive access to a collection, or a birthday bonus. Benefits are created and archived from the screen, each change recorded to the audit trail under the loyalty permission. The screen states that these perks are shown to customers as tier inclusions the shop honours and are not applied automatically at checkout.
12
16
 
13
17
  - v0.4.53 (2026-06-14) — **A signed-in customer's cookie choices and newsletter unsubscribe now land in the durable consent record.** The durable, per-customer consent ledger — the GDPR Article 7(1) record a controller keeps to demonstrate consent — is now written from the real consent events for identified customers. When a signed-in customer saves their cookie preferences, each category (functional, analytics, marketing, preferences) is recorded as granted or withdrawn in the durable ledger, alongside the existing session-level cookie record. When a newsletter unsubscribe resolves to a customer account, a marketing-email withdrawal is recorded there too. Anonymous visitors and email-only subscribers with no account are unchanged — their cookie choice stays in the session-level store and their unsubscribe in the email-suppression list, neither of which can be customer-keyed. The ledger writes are best-effort and never block the banner save or the unsubscribe. No migration to apply. **Added:** *Cookie-banner choices recorded in the durable consent ledger for signed-in customers* — When an authenticated customer saves their cookie preferences, each of the four optional categories is now mirrored into the durable per-customer consent ledger as a granted or withdrawn decision (source: cookie banner), so a supervisory-authority audit shows the identified individual's choice and not only the session-keyed record. The durable record reflects the consent the storefront actually enforces: a browser-level opt-out signal (Global Privacy Control or Do Not Track) collapses the analytics and marketing categories to withdrawn even if their boxes were ticked, matching the runtime gate, so the record never claims consent the app refuses to honor. The customer is resolved from the existing signed-in session (a revoked session is treated as signed-out); anonymous visitors carry no account and are written only to the session-level cookie record as before. · *Newsletter unsubscribe records a marketing withdrawal for account holders* — A newsletter unsubscribe that resolves to a customer account now records a marketing-email withdrawal in the durable consent ledger. The unsubscribed address is matched to an account by its hashed form; an email-only subscriber with no account is handled by the existing email-suppression path only, since the durable ledger is keyed by customer. The existing unsubscribe behavior — the suppression entry and the one-click RFC 8058 flow — is unchanged.
package/lib/admin.js CHANGED
@@ -155,7 +155,7 @@ var _ACTION_PERMISSION = Object.freeze({
155
155
  gift: "catalog.write", preorder: "catalog.write", quantity_discount: "catalog.write",
156
156
  auto_discount: "catalog.write", coupon_policy: "catalog.write",
157
157
  promo_banner: "catalog.write", announcement: "catalog.write", blog: "catalog.write",
158
- email_campaign: "catalog.write", suggestion: "catalog.write", sidebar_widget: "catalog.write",
158
+ email_campaign: "catalog.write", winback_campaign: "catalog.write", suggestion: "catalog.write", sidebar_widget: "catalog.write",
159
159
  page: "catalog.write", help: "catalog.write", survey: "catalog.write",
160
160
  hours: "catalog.write", delivery_holiday: "catalog.write",
161
161
  delivery_transit: "catalog.write", tax_rate: "catalog.write",
@@ -889,6 +889,7 @@ function mount(router, deps) {
889
889
  var convertQuoteToOrder = deps.convertQuoteToOrder || null; // quote → pending-order converter (server.js composition); the console convert action disabled when absent
890
890
  var emailCampaigns = deps.emailCampaigns || null; // consent-gated broadcast/campaign console disabled when absent
891
891
  var mailingAudiences = deps.mailingAudiences || null; // audience picker for the campaign console (target-an-audience dropdown)
892
+ var winback = deps.winback || null; // win-back lapsed-customer campaign console (define/list/activate/archive + metrics + delivery log) disabled when absent
892
893
  // Read-only activity log at /admin/audit. Defaults ON — the framework
893
894
  // audit chain is always booted by createApp, so the screen always has a
894
895
  // data source (unlike the optional primitives above, which default off).
@@ -937,7 +938,7 @@ function mount(router, deps) {
937
938
  // `reports` is always present in the nav (read-only sales summary needs no
938
939
  // extra dep); its route mounts unconditionally and renders an unconfigured
939
940
  // notice when the salesReports primitive isn't wired.
940
- var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, suggestionBox: !!deps.suggestionBox, sidebarWidgets: !!deps.sidebarWidgets, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns, operators: !!operatorAccounts, inbox: !!operatorInbox };
941
+ var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, suggestionBox: !!deps.suggestionBox, sidebarWidgets: !!deps.sidebarWidgets, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns, winback: !!winback, operators: !!operatorAccounts, inbox: !!operatorInbox };
941
942
 
942
943
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
943
944
 
@@ -6868,6 +6869,220 @@ function mount(router, deps) {
6868
6869
  ));
6869
6870
  }
6870
6871
 
6872
+ // ---- win-back campaigns ---------------------------------------------
6873
+ //
6874
+ // Escalating re-engagement sequences for customers who've gone quiet —
6875
+ // the last paid order is N days in the past. The operator defines a
6876
+ // campaign (lapse window + ordered steps, each with an optional
6877
+ // per-step coupon), and the campaign-send-tick cron scans the order
6878
+ // history, enrolls the eligible, and sends the due step through the
6879
+ // SAME suppression + marketing-consent gate every marketing flow uses.
6880
+ // This console is the author/list/activate/archive + metrics/delivery-
6881
+ // log side; the send itself rides the cron (server.js + worker).
6882
+ //
6883
+ // Steps are authored one-per-line as `delay_days | template_slug |
6884
+ // coupon_kind | coupon_value` (the last two optional). defineCampaign
6885
+ // validates the whole shape and THROWS a TypeError on anything bad,
6886
+ // which the create route maps to a 400 / err redirect — no partial
6887
+ // write. Every operator-authored field rendered back is esc()'d in the
6888
+ // render functions below.
6889
+ if (winback) {
6890
+ // Parse the textarea step lines into the steps array the primitive
6891
+ // expects. A blank line is skipped; a malformed line surfaces as a
6892
+ // TypeError so the create route's catch redirects with `?err=steps`
6893
+ // rather than silently dropping a step. `delay_days` is a
6894
+ // non-negative int; `coupon_value` is parsed only when a kind is
6895
+ // present (free_shipping carries no value).
6896
+ function _winbackStepsFromBody(raw) {
6897
+ if (typeof raw !== "string") {
6898
+ throw new TypeError("winback: steps required");
6899
+ }
6900
+ var lines = raw.split(/\r?\n/);
6901
+ var steps = [];
6902
+ for (var i = 0; i < lines.length; i += 1) {
6903
+ var line = lines[i].trim();
6904
+ if (!line) continue;
6905
+ var parts = line.split("|").map(function (p) { return p.trim(); });
6906
+ var delayRaw = parts[0];
6907
+ var delay = parseInt(delayRaw, 10);
6908
+ if (String(delay) !== delayRaw || !Number.isInteger(delay) || delay < 0) {
6909
+ throw new TypeError("winback: step line " + (i + 1) + " — delay_days must be a non-negative integer");
6910
+ }
6911
+ var step = { delay_days: delay, template_slug: parts[1] };
6912
+ if (parts[2]) {
6913
+ step.coupon_kind = parts[2];
6914
+ if (parts[2] !== "free_shipping") {
6915
+ var val = parseInt(parts[3], 10);
6916
+ if (String(val) !== (parts[3] || "") || !Number.isInteger(val)) {
6917
+ throw new TypeError("winback: step line " + (i + 1) + " — coupon_value must be an integer for a " + parts[2] + " coupon");
6918
+ }
6919
+ step.coupon_value = val;
6920
+ }
6921
+ }
6922
+ steps.push(step);
6923
+ }
6924
+ return steps;
6925
+ }
6926
+
6927
+ function _winbackInput(body) {
6928
+ var input = {
6929
+ slug: body.slug,
6930
+ lapse_days_min: body.lapse_days_min != null && body.lapse_days_min !== "" ? parseInt(body.lapse_days_min, 10) : body.lapse_days_min,
6931
+ steps: _winbackStepsFromBody(body.steps),
6932
+ };
6933
+ if (body.lapse_days_max != null && body.lapse_days_max !== "") {
6934
+ input.lapse_days_max = parseInt(body.lapse_days_max, 10);
6935
+ }
6936
+ // Optional audience narrowing — a minimum lifetime order count
6937
+ // keeps a "we miss you" sequence off one-time buyers. Parsed only
6938
+ // when supplied; an empty field clears the predicate.
6939
+ var audience = {};
6940
+ if (body.lifetime_orders_min != null && body.lifetime_orders_min !== "") {
6941
+ audience.lifetime_orders_min = parseInt(body.lifetime_orders_min, 10);
6942
+ }
6943
+ if (Object.keys(audience).length) input.audience_filter = audience;
6944
+ return input;
6945
+ }
6946
+
6947
+ // List — every campaign (active + archived), newest-first.
6948
+ router.get("/admin/winback", _pageOrApi(true,
6949
+ R(async function (_req, res) {
6950
+ _json(res, 200, { campaigns: await winback.listCampaigns({}) });
6951
+ }),
6952
+ async function (req, res) {
6953
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6954
+ _sendHtml(res, 200, renderAdminWinbackList({
6955
+ shop_name: deps.shop_name, nav_available: navAvailable,
6956
+ rows: await winback.listCampaigns({}),
6957
+ can_send: !!winback._deps && !!winback._deps.email,
6958
+ saved: url && url.searchParams.get("saved"),
6959
+ notice: (url && url.searchParams.get("err")) ? _winbackErrNotice(url.searchParams.get("err")) : null,
6960
+ }));
6961
+ },
6962
+ ));
6963
+
6964
+ // New-campaign form — its own GET so a bad submit's err redirect
6965
+ // keeps the operator's context.
6966
+ router.get("/admin/winback/new", _pageOrApi(true,
6967
+ R(async function (_req, res) {
6968
+ _json(res, 200, { coupon_kinds: winback.COUPON_KINDS });
6969
+ }),
6970
+ async function (req, res) {
6971
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6972
+ _sendHtml(res, 200, renderAdminWinbackNew({
6973
+ shop_name: deps.shop_name, nav_available: navAvailable,
6974
+ notice: (url && url.searchParams.get("err")) ? _winbackErrNotice(url.searchParams.get("err")) : null,
6975
+ }));
6976
+ },
6977
+ ));
6978
+
6979
+ // Create / redefine — composes defineCampaign (validates slug,
6980
+ // lapse window, steps, coupons, audience filter; throws TypeError →
6981
+ // 400 / err redirect). Config-time tier: a bad shape never writes.
6982
+ router.post("/admin/winback", _pageOrApi(false,
6983
+ W("winback_campaign.create", async function (req, res) {
6984
+ var c;
6985
+ try { c = await winback.defineCampaign(_winbackInput(req.body || {})); }
6986
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6987
+ _json(res, 201, c);
6988
+ return { id: c.slug };
6989
+ }),
6990
+ async function (req, res) {
6991
+ var input;
6992
+ try { input = _winbackInput(req.body || {}); }
6993
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/winback/new?err=steps"); throw e; }
6994
+ try { await winback.defineCampaign(input); }
6995
+ catch (e) {
6996
+ var n = _safeNotice(e, "winback_campaign.create");
6997
+ if (n.status >= 500) throw e;
6998
+ return _redirect(res, "/admin/winback/new?err=bad");
6999
+ }
7000
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".winback_campaign.create", outcome: "success" });
7001
+ _redirect(res, "/admin/winback?saved=1");
7002
+ },
7003
+ ));
7004
+
7005
+ // Detail — the campaign config + recovery metrics over a trailing
7006
+ // window + the recent delivery log. Content-negotiated.
7007
+ function _winbackMetricsWindow() {
7008
+ var to = Date.now();
7009
+ // 90-day trailing window — wide enough to capture a full
7010
+ // escalation sequence plus the recovery tail.
7011
+ return { from: to - C.TIME.days(90), to: to };
7012
+ }
7013
+ router.get("/admin/winback/:slug", _pageOrApi(true,
7014
+ R(async function (req, res) {
7015
+ var c = await winback.getCampaign(req.params.slug);
7016
+ if (!c) return _problem(res, 404, "winback-campaign-not-found");
7017
+ var win = _winbackMetricsWindow();
7018
+ var metrics = null;
7019
+ try { metrics = await winback.metricsForCampaign({ slug: req.params.slug, from: win.from, to: win.to }); }
7020
+ catch (_e) { metrics = null; }
7021
+ var deliveries = await winback.recentDeliveriesForCampaign({ slug: req.params.slug, limit: 100 });
7022
+ _json(res, 200, Object.assign({}, c, { metrics: metrics, recent_deliveries: deliveries }));
7023
+ }),
7024
+ async function (req, res) {
7025
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7026
+ var c = await winback.getCampaign(req.params.slug);
7027
+ if (!c) return _sendHtml(res, 404, renderAdminWinbackList({
7028
+ shop_name: deps.shop_name, nav_available: navAvailable, rows: [],
7029
+ can_send: !!winback._deps && !!winback._deps.email, notice: "Campaign not found.",
7030
+ }));
7031
+ var win = _winbackMetricsWindow();
7032
+ var metrics = null;
7033
+ try { metrics = await winback.metricsForCampaign({ slug: req.params.slug, from: win.from, to: win.to }); }
7034
+ catch (_e) { metrics = null; }
7035
+ var deliveries = await winback.recentDeliveriesForCampaign({ slug: req.params.slug, limit: 100 });
7036
+ _sendHtml(res, 200, renderAdminWinback({
7037
+ shop_name: deps.shop_name, nav_available: navAvailable,
7038
+ campaign: c, metrics: metrics, deliveries: deliveries,
7039
+ can_send: !!winback._deps && !!winback._deps.email,
7040
+ saved: url && url.searchParams.get("saved"),
7041
+ notice: (url && url.searchParams.get("err")) ? _winbackErrNotice(url.searchParams.get("err")) : null,
7042
+ }));
7043
+ },
7044
+ ));
7045
+
7046
+ // Pause — archives the campaign out of the scan path. Active
7047
+ // enrollments still complete their sequence (the operator told
7048
+ // those customers they'd hear back); only NEW enrollments stop.
7049
+ router.post("/admin/winback/:slug/pause", _pageOrApi(false,
7050
+ W("winback_campaign.pause", async function (req, res) {
7051
+ var c;
7052
+ try { c = await winback.archiveCampaign(req.params.slug); }
7053
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7054
+ _json(res, 200, c);
7055
+ return { id: req.params.slug };
7056
+ }),
7057
+ async function (req, res) {
7058
+ var enc = encodeURIComponent(req.params.slug);
7059
+ try { await winback.archiveCampaign(req.params.slug); }
7060
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/winback/" + enc + "?err=pause"); }
7061
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".winback_campaign.pause", outcome: "success", metadata: { slug: req.params.slug } });
7062
+ _redirect(res, "/admin/winback/" + enc + "?saved=1");
7063
+ },
7064
+ ));
7065
+
7066
+ // Activate — clears the archive flag so the scan path picks the
7067
+ // campaign back up.
7068
+ router.post("/admin/winback/:slug/activate", _pageOrApi(false,
7069
+ W("winback_campaign.activate", async function (req, res) {
7070
+ var c;
7071
+ try { c = await winback.activateCampaign(req.params.slug); }
7072
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
7073
+ _json(res, 200, c);
7074
+ return { id: req.params.slug };
7075
+ }),
7076
+ async function (req, res) {
7077
+ var enc = encodeURIComponent(req.params.slug);
7078
+ try { await winback.activateCampaign(req.params.slug); }
7079
+ catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/winback/" + enc + "?err=activate"); }
7080
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".winback_campaign.activate", outcome: "success", metadata: { slug: req.params.slug } });
7081
+ _redirect(res, "/admin/winback/" + enc + "?saved=1");
7082
+ },
7083
+ ));
7084
+ }
7085
+
6871
7086
  // ---- gift wraps -----------------------------------------------------
6872
7087
  //
6873
7088
  // The operator-defined gift-wrap catalog: define / update / archive a wrap
@@ -14709,6 +14924,7 @@ var ADMIN_NAV_ITEMS = [
14709
14924
  { key: "suggestions", href: "/admin/suggestions", label: "Suggestion box", requires: "suggestionBox" },
14710
14925
  { key: "sidebar-widgets", href: "/admin/sidebar-widgets", label: "Sidebar widgets", requires: "sidebarWidgets" },
14711
14926
  { key: "campaigns", href: "/admin/campaigns", label: "Email campaigns", requires: "emailCampaigns" },
14927
+ { key: "winback", href: "/admin/winback", label: "Win-back", requires: "winback" },
14712
14928
  { key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
14713
14929
  { key: "help", href: "/admin/help", label: "Help center", requires: "knowledgeBase" },
14714
14930
  { key: "pages", href: "/admin/pages", label: "Pages", requires: "storefrontPages" },
@@ -18089,6 +18305,173 @@ function renderAdminCampaign(opts) {
18089
18305
  return _spliceRaw(html, "RAW_PREVIEW_BODY", previewBody);
18090
18306
  }
18091
18307
 
18308
+ // ---- win-back campaign console renders --------------------------------
18309
+
18310
+ function _winbackErrNotice(code) {
18311
+ if (code === "bad") return "Check the campaign — a slug, a lapse window (days), and at least one step are all required.";
18312
+ if (code === "steps") return "Check the steps — each line is `days | template | coupon_kind | coupon_value`; days must be a whole number and a percent/fixed coupon needs a value.";
18313
+ if (code === "pause") return "That campaign can't be paused from its current state.";
18314
+ if (code === "activate") return "That campaign can't be activated from its current state.";
18315
+ return "That action couldn't be completed.";
18316
+ }
18317
+
18318
+ function _winbackStatusPill(archivedAt) {
18319
+ return archivedAt == null
18320
+ ? "<span class=\"status-pill status-pill--ok\">active</span>"
18321
+ : "<span class=\"status-pill status-pill--muted\">paused</span>";
18322
+ }
18323
+
18324
+ // Render the steps array as a readable column — "0d · we-miss-you",
18325
+ // "7d · 10% off", "14d · free shipping". Every authored field escaped.
18326
+ function _winbackStepsSummary(steps) {
18327
+ if (!Array.isArray(steps) || !steps.length) return "<span class=\"meta\">—</span>";
18328
+ var parts = steps.map(function (s) {
18329
+ var label = _htmlEscape(String(s.delay_days)) + "d · " + _htmlEscape(String(s.template_slug));
18330
+ if (s.coupon_kind === "percent") label += " (" + _htmlEscape(String(s.coupon_value)) + "% off)";
18331
+ else if (s.coupon_kind === "fixed") label += " (" + _htmlEscape(String(s.coupon_value)) + " off)";
18332
+ else if (s.coupon_kind === "free_shipping") label += " (free shipping)";
18333
+ return label;
18334
+ });
18335
+ return parts.join("<br>");
18336
+ }
18337
+
18338
+ function renderAdminWinbackList(opts) {
18339
+ opts = opts || {};
18340
+ var rows = opts.rows || [];
18341
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Campaign saved.</div>" : "";
18342
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
18343
+
18344
+ // Honesty banner — win-back only emails when a deliverable-address
18345
+ // source is wired. Customer email is hash-only, so the default deploy
18346
+ // sends nothing: the sequence runs (enrolls, advances, logs) but no
18347
+ // mail leaves until the operator wires an address lookup + SMTP.
18348
+ var sendBanner = opts.can_send
18349
+ ? ""
18350
+ : "<div class=\"banner banner--warn\">Sending is dormant on this deployment: customer email is stored hash-only, so no win-back mail is sent until an SMTP transport and a customer-address lookup are wired. " +
18351
+ "Campaigns can still be authored, activated, and tracked — and the sequence enrolls + advances — but no email leaves until then.</div>";
18352
+
18353
+ var tableRows = rows.map(function (c) {
18354
+ var enc = encodeURIComponent(c.slug);
18355
+ var window = _htmlEscape(String(c.lapse_days_min)) + (c.lapse_days_max == null ? "+" : "–" + _htmlEscape(String(c.lapse_days_max))) + " days";
18356
+ return "<tr>" +
18357
+ "<td><a href=\"/admin/winback/" + enc + "\"><code>" + _htmlEscape(c.slug) + "</code></a></td>" +
18358
+ "<td>" + window + "</td>" +
18359
+ "<td>" + _htmlEscape(String((c.steps || []).length)) + "</td>" +
18360
+ "<td>" + _winbackStatusPill(c.archived_at) + "</td>" +
18361
+ "</tr>";
18362
+ }).join("");
18363
+
18364
+ var list = rows.length
18365
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Slug</th><th scope=\"col\">Lapse window</th><th scope=\"col\">Steps</th><th scope=\"col\">Status</th></tr></thead><tbody>" + tableRows + "</tbody></table>") + "</div>"
18366
+ : "<p class=\"empty\">No win-back campaigns yet. Create one to re-engage customers who've gone quiet.</p>";
18367
+
18368
+ var body =
18369
+ "<section><h2>Win-back campaigns</h2>" +
18370
+ "<p class=\"meta\">Re-engage customers whose last paid order is N days old with an escalating sequence of offers. " +
18371
+ "The cron scans the order history, enrolls the eligible, and sends each step to ONLY marketing-consented, non-suppressed addresses — consent is re-checked at send time.</p>" +
18372
+ saved + notice + sendBanner +
18373
+ "<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/winback/new\">New campaign</a></div>" +
18374
+ list +
18375
+ "</section>";
18376
+ return _renderAdminShell(opts.shop_name, "Win-back campaigns", body, "winback", opts.nav_available);
18377
+ }
18378
+
18379
+ function renderAdminWinbackNew(opts) {
18380
+ opts = opts || {};
18381
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
18382
+ var placeholder = "0 | we-miss-you\n7 | ten-pct-off | percent | 10\n14 | last-call | percent | 20";
18383
+
18384
+ var body =
18385
+ "<section class=\"mw-42\"><h2>New win-back campaign</h2>" + notice +
18386
+ "<form method=\"post\" action=\"/admin/winback\">" +
18387
+ _setupField("Campaign slug", "slug", "", "text", "Lowercase id, e.g. we-miss-you.", " maxlength=\"64\" required") +
18388
+ _setupField("Lapse window — minimum days", "lapse_days_min", "", "number", "Only customers whose last paid order is at least this many days old qualify.", " min=\"1\" required") +
18389
+ _setupField("Lapse window — maximum days", "lapse_days_max", "", "number", "Optional ceiling — skip customers gone longer than this. Leave blank for no ceiling.", " min=\"1\"") +
18390
+ _setupField("Minimum lifetime orders", "lifetime_orders_min", "", "number", "Optional — only target repeat buyers with at least this many paid orders.", " min=\"1\"") +
18391
+ "<label class=\"form-field\"><span>Steps</span>" +
18392
+ "<textarea name=\"steps\" rows=\"6\" maxlength=\"4000\" placeholder=\"" + _htmlEscape(placeholder) + "\" required></textarea>" +
18393
+ "<small>One step per line: <code>days | template_slug | coupon_kind | coupon_value</code>. " +
18394
+ "Coupon is optional; kinds are <code>percent</code>, <code>fixed</code>, <code>free_shipping</code> " +
18395
+ "(free_shipping carries no value). Days are the offset from enrollment and must not decrease.</small></label>" +
18396
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Create campaign</button>" +
18397
+ "<a class=\"btn btn--ghost\" href=\"/admin/winback\">Cancel</a></div>" +
18398
+ "</form>" +
18399
+ "</section>";
18400
+ return _renderAdminShell(opts.shop_name, "New win-back campaign", body, "winback", opts.nav_available);
18401
+ }
18402
+
18403
+ function renderAdminWinback(opts) {
18404
+ opts = opts || {};
18405
+ var c = opts.campaign;
18406
+ var enc = encodeURIComponent(c.slug);
18407
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Campaign updated.</div>" : "";
18408
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
18409
+
18410
+ var window = _htmlEscape(String(c.lapse_days_min)) + (c.lapse_days_max == null ? "+" : "–" + _htmlEscape(String(c.lapse_days_max))) + " days";
18411
+ var aud = c.audience_filter || {};
18412
+ var audLine = aud.lifetime_orders_min != null
18413
+ ? "<p class=\"meta\">Audience: at least " + _htmlEscape(String(aud.lifetime_orders_min)) + " lifetime paid order(s).</p>"
18414
+ : "";
18415
+
18416
+ var stepsPanel = "<div class=\"panel\"><h3 class=\"subhead\">Steps</h3>" +
18417
+ "<p>" + _winbackStepsSummary(c.steps) + "</p></div>";
18418
+
18419
+ // Recovery metrics over the trailing window. recovery_rate is a 0..1
18420
+ // fraction; render it as a one-decimal percentage.
18421
+ var m = opts.metrics;
18422
+ var ratePct = m ? (Number(m.recovery_rate) * 100).toFixed(1) : "0.0";
18423
+ var metricsPanel = m
18424
+ ? "<div class=\"panel\"><h3 class=\"subhead\">Recovery (last 90 days)</h3>" +
18425
+ "<ul class=\"kv-list\">" +
18426
+ "<li><span>Enrolled</span><strong>" + _htmlEscape(String(m.total)) + "</strong></li>" +
18427
+ "<li><span>Recovered</span><strong>" + _htmlEscape(String(m.counts.recovered)) + "</strong></li>" +
18428
+ "<li><span>Active</span><strong>" + _htmlEscape(String(m.counts.active)) + "</strong></li>" +
18429
+ "<li><span>Exhausted</span><strong>" + _htmlEscape(String(m.counts.exhausted)) + "</strong></li>" +
18430
+ "<li><span>Cancelled</span><strong>" + _htmlEscape(String(m.counts.cancelled)) + "</strong></li>" +
18431
+ "<li><span>Recovery rate</span><strong>" + _htmlEscape(ratePct) + "%</strong></li>" +
18432
+ "<li><span>Deliveries</span><strong>" + _htmlEscape(String(m.total_deliveries)) + "</strong></li>" +
18433
+ "</ul></div>"
18434
+ : "<div class=\"panel\"><p class=\"meta\">Metrics unavailable.</p></div>";
18435
+
18436
+ // Recent delivery log — which customer received which step + coupon
18437
+ // at which time. customer_id + coupon_code are escaped.
18438
+ var deliveries = opts.deliveries || [];
18439
+ var logRows = deliveries.map(function (d) {
18440
+ return "<tr>" +
18441
+ "<td>" + _htmlEscape(new Date(d.delivered_at).toISOString()) + "</td>" +
18442
+ "<td><code>" + _htmlEscape(String(d.customer_id)) + "</code></td>" +
18443
+ "<td>" + _htmlEscape(String(d.step_index)) + "</td>" +
18444
+ "<td>" + (d.coupon_code ? "<code>" + _htmlEscape(d.coupon_code) + "</code>" : "<span class=\"meta\">—</span>") + "</td>" +
18445
+ "</tr>";
18446
+ }).join("");
18447
+ var logPanel = deliveries.length
18448
+ ? "<div class=\"panel\"><h3 class=\"subhead\">Recent deliveries</h3>" +
18449
+ _tableWrap("<table><thead><tr><th scope=\"col\">Sent</th><th scope=\"col\">Customer</th><th scope=\"col\">Step</th><th scope=\"col\">Coupon</th></tr></thead><tbody>" + logRows + "</tbody></table>") + "</div>"
18450
+ : "<div class=\"panel\"><h3 class=\"subhead\">Recent deliveries</h3><p class=\"empty\">No deliveries yet.</p></div>";
18451
+
18452
+ // Pause / activate toggle on the campaign's archive state.
18453
+ var toggle = c.archived_at == null
18454
+ ? "<form method=\"post\" action=\"/admin/winback/" + enc + "/pause\" class=\"form-inline\">" +
18455
+ "<button class=\"btn btn--ghost\" type=\"submit\">Pause campaign</button></form>"
18456
+ : "<form method=\"post\" action=\"/admin/winback/" + enc + "/activate\" class=\"form-inline\">" +
18457
+ "<button class=\"btn\" type=\"submit\">Activate campaign</button></form>";
18458
+
18459
+ var sendNote = opts.can_send
18460
+ ? ""
18461
+ : "<p class=\"meta\">Sending is dormant: no deliverable-address source is wired, so the sequence enrolls and advances but no mail leaves.</p>";
18462
+
18463
+ var meta = "<p class=\"meta\">Lapse window " + window + " · " + _winbackStatusPill(c.archived_at) + "</p>";
18464
+
18465
+ var body =
18466
+ "<section><h2><code>" + _htmlEscape(c.slug) + "</code></h2>" + meta + audLine +
18467
+ saved + notice +
18468
+ stepsPanel + metricsPanel + logPanel +
18469
+ "<div class=\"panel\"><h3 class=\"subhead\">Status</h3>" + toggle + sendNote + "</div>" +
18470
+ "<div class=\"actions-row mt\"><a class=\"btn btn--ghost\" href=\"/admin/winback\">&larr; All campaigns</a></div>" +
18471
+ "</section>";
18472
+ return _renderAdminShell(opts.shop_name, c.slug, body, "winback", opts.nav_available);
18473
+ }
18474
+
18092
18475
  // Location→location transfer console: the open form + the open-transfer
18093
18476
  // queue with the FSM action legal from each row's status. Reasons,
18094
18477
  // carriers, and tracking numbers are operator free text — escaped.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.54",
2
+ "version": "0.4.56",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",