@blamejs/blamejs-shop 0.2.30 → 0.2.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/cart-recovery-pass.js +482 -0
- package/lib/index.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.2.x
|
|
10
10
|
|
|
11
|
+
- v0.2.31 (2026-05-29) — **Abandoned-cart recovery.** A scheduled pass now finds carts left idle with items and no order and emails the shopper a link back — composed from the cart-abandonment scanner and a multi-step recovery sequence, gated on marketing consent (anyone who has withdrawn marketing consent is skipped) and idempotent (one email per abandonment, no double-sends across ticks). It stays completely inert until an SMTP mailer and an email resolver are configured, so a store without them is unaffected; the existing once-a-minute cron drives it. **Added:** *Abandoned-cart recovery* — A scheduled pass detects carts that have items, no paid order, and have been idle past a configurable threshold (`shop.cart_recovery_after_hours`, default 4h), then emails a recovery link to shoppers whose address is known. Enrolment is gated on the marketing-consent ledger and the send path honors the suppression list, so a withdrawn or unsubscribed shopper is never contacted; sends are idempotent. The pass no-ops cleanly until an SMTP mailer (`SMTP_HOST` / `MAIL_FROM`) and an email resolver are wired, so deploying it changes nothing until you opt in.
|
|
12
|
+
|
|
11
13
|
- v0.2.30 (2026-05-29) — **Fulfillment operations in the admin console — pick lists, labels, split shipments.** The admin console gains a fulfillment workspace. Pick lists batch unfulfilled orders into a warehouse worksheet — generate from eligible orders or a pasted set of order ids, confirm or recount each line, then complete the list to fan out shipments, with a print-optimized worksheet. Shipping labels can be recorded per shipment on the order detail (carrier, service, parcel dimensions, tracking, and cost). Split shipments let you ship part of an order now and the rest later, planning per-line parcel assignments — without forcing the order's lifecycle forward until everything ships. Separately, an accessibility fix: the footer column headings move from h4 to h2, removing a heading-level skip on every page (the footer looks identical). **Added:** *Pick lists* — `/admin/pick-lists` batches unfulfilled orders into a warehouse worksheet — generate from auto-selected eligible orders or a pasted set of order ids, confirm/recount each line with a variance summary, cancel with a reason, and complete the list (which fans out one shipment per parent order). A self-contained `@media print` worksheet prints the picks. · *Shipping labels* — Record a carrier-minted shipping label per shipment from the order detail — carrier, service, parcel dimensions, tracking number, label URL, and cost — with a mark-used action and a per-shipment label list. Available where shipment tracking is wired. · *Split shipments* — Plan and execute partial shipments for an order: assign line items and quantities to parcels and ship them as separate shipments. The order stays in its fulfilling state until all parcels have shipped, so a partial shipment never marks the order delivered prematurely. **Fixed:** *Footer heading order* — The footer column headings were emitted as `h4` directly after the page's `h2`, skipping intermediate levels — a WCAG 1.3.1 heading-hierarchy violation. They are now `h2` (correct for a top-level region inside the footer landmark). The footer's appearance is unchanged.
|
|
12
14
|
|
|
13
15
|
- v0.2.29 (2026-05-29) — **Show the real total before checkout and truthful stock on every product.** Two storefront truthfulness fixes. The cart and checkout now show the real total before payment — subtotal plus estimated tax, shipping, and any discount, all totalled — computed by the same engine that runs the actual charge, so the figure shown matches what will be charged (labelled estimated until an address narrows the tax and shipping, then exact; a destination with no matching rule reads 'calculated at checkout' rather than a wrong number). Cart lines also surface real stock state. On product pages, the buy box now reflects real per-variant inventory: an out-of-stock variant disables Add to cart with an honest message, and a low-stock variant shows 'Only N left'. **Fixed:** *The cart and checkout show your real total before you pay* — Previously the cart total was just the subtotal and checkout showed no tax/shipping until the payment step. Both now render subtotal + estimated tax + estimated shipping + discount + total, computed from the same tax/shipping primitives as the Stripe charge — the displayed estimate equals the confirmed order total to the cent. Figures are labelled 'estimated' until a shipping address is entered (then exact); a destination with no matching tax or shipping rule reads 'calculated at checkout', and the subtotal stays honest. Cart lines now also show out-of-stock / low-stock state. · *Product availability reflects real inventory* — The product buy box now reads real per-variant stock: an out-of-stock variant disables 'Add to cart' and shows an out-of-stock message (in the badge and the JSON-LD), and a variant at or below the configured low-stock threshold shows 'Only N left'. The add-to-cart control is no longer enabled for items that can't be bought.
|
package/lib/asset-manifest.json
CHANGED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.cartRecoveryPass
|
|
4
|
+
* @title Cart-recovery orchestration — one bounded scan → enroll → dispatch tick
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* `cart-abandonment` detects idle carts (writes detection rows) and
|
|
8
|
+
* `cart-recovery` owns the multi-step nurture FSM (enroll, dispatch,
|
|
9
|
+
* recover). This primitive is the glue that drives both on a cron
|
|
10
|
+
* cadence: one `runPass()` call scans for freshly abandoned carts,
|
|
11
|
+
* enrolls the eligible ones into a recovery sequence, and dispatches
|
|
12
|
+
* every nurture step that has come due — all inside a single bounded,
|
|
13
|
+
* drop-silent tick that is safe to fire every minute.
|
|
14
|
+
*
|
|
15
|
+
* Composition:
|
|
16
|
+
*
|
|
17
|
+
* var pass = bShop.cartRecoveryPass.create({
|
|
18
|
+
* cartAbandonment: bShop.cartAbandonment.create({ query: q, cart: cart }),
|
|
19
|
+
* cartRecovery: bShop.cartRecovery.create({
|
|
20
|
+
* query: q,
|
|
21
|
+
* email: bShop.email.create({ mailer: m }),
|
|
22
|
+
* emailSuppressions: bShop.emailSuppressions.create({ query: q }),
|
|
23
|
+
* }),
|
|
24
|
+
* config: bShop.config.create({ query: q }),
|
|
25
|
+
* consentLedger: bShop.consentLedger.create({ query: q }),
|
|
26
|
+
* sequenceSlug: "default-recovery",
|
|
27
|
+
* cartUrlBase: "https://shop.example/cart",
|
|
28
|
+
* resolveEmail: async function (candidate) {
|
|
29
|
+
* // Operator-owned: map an abandoned cart's customer_id to a
|
|
30
|
+
* // deliverable plaintext address. Return null when none.
|
|
31
|
+
* return await myEmailLookup(candidate.customer_id);
|
|
32
|
+
* },
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* // Cron-driven; drop-silent — never throws out of the cron.
|
|
36
|
+
* var summary = await pass.runPass();
|
|
37
|
+
*
|
|
38
|
+
* Eligibility (who gets enrolled):
|
|
39
|
+
*
|
|
40
|
+
* 1. The cart has a `customer_id` (a known shopper). Guest carts
|
|
41
|
+
* carry no email anywhere in the schema — the `carts` row has
|
|
42
|
+
* only `session_id` + `customer_id`, and the customer record
|
|
43
|
+
* stores an `email_hash`, never the plaintext — so there is no
|
|
44
|
+
* deliverable address to recover for an anonymous cart. Guest-
|
|
45
|
+
* cart recovery re-opens the day the operator persists a guest
|
|
46
|
+
* email on the cart (a checkout-email-capture field) and feeds
|
|
47
|
+
* it through `resolveEmail`; until then it's an inert branch,
|
|
48
|
+
* not a silent drop — the pass counts the skip.
|
|
49
|
+
*
|
|
50
|
+
* 2. `resolveEmail(candidate)` returns a non-empty plaintext
|
|
51
|
+
* address. The operator owns the customer_id → address route;
|
|
52
|
+
* absent the hook (or a null return) the candidate is skipped
|
|
53
|
+
* `no-email` and never enrolled.
|
|
54
|
+
*
|
|
55
|
+
* 3. The customer has not WITHDRAWN marketing-email consent. The
|
|
56
|
+
* consent ledger's latest `marketing_email` decision must not be
|
|
57
|
+
* `withdrawn`. A missing decision reads as "no objection on
|
|
58
|
+
* file" — the operator's checkout opt-in flow is the place that
|
|
59
|
+
* records the positive grant; this gate only refuses a recorded
|
|
60
|
+
* withdrawal so a one-click unsubscribe (which writes a
|
|
61
|
+
* `withdrawn` row) stops the next nurture before it enrolls.
|
|
62
|
+
* When no `consentLedger` dep is wired the gate is skipped
|
|
63
|
+
* (the operator runs without the ledger) and the suppression
|
|
64
|
+
* list inside the dispatcher remains the backstop.
|
|
65
|
+
*
|
|
66
|
+
* The suppression-list check (hard bounces / spam complaints /
|
|
67
|
+
* one-click unsubscribe rows) lives inside `cartRecovery.dispatchTick`
|
|
68
|
+
* — it runs per send, AFTER enrollment, and cancels the whole
|
|
69
|
+
* enrollment on a hit. The consent gate here is the BEFORE-enrollment
|
|
70
|
+
* complement: it keeps an opted-out customer out of the sequence in
|
|
71
|
+
* the first place rather than enrolling them and skipping the send.
|
|
72
|
+
*
|
|
73
|
+
* Idempotency + cadence:
|
|
74
|
+
*
|
|
75
|
+
* * `cartAbandonment.scan` is idempotent at the idle-window level —
|
|
76
|
+
* a cart already detected inside the current window is skipped,
|
|
77
|
+
* so re-running the pass every minute does not re-detect the same
|
|
78
|
+
* cart.
|
|
79
|
+
* * Each detection enrolls AT MOST once: the pass records the
|
|
80
|
+
* detection_id on the enrollment and, before enrolling, checks
|
|
81
|
+
* the abandonment detection's `reminder_status`. A detection
|
|
82
|
+
* already marked `sent` / `skipped-*` is never re-enrolled.
|
|
83
|
+
* * `cartRecovery.dispatchTick` sends one email per step and walks
|
|
84
|
+
* the FSM forward, so a cart is never emailed twice for the same
|
|
85
|
+
* step.
|
|
86
|
+
*
|
|
87
|
+
* Safe-to-run-every-minute:
|
|
88
|
+
*
|
|
89
|
+
* The scan + dispatch batches are bounded (`maxCarts`,
|
|
90
|
+
* `maxDispatch`, both capped). The pass wraps the whole body in a
|
|
91
|
+
* try/catch and returns a summary object on success OR a
|
|
92
|
+
* `{ ok: false, error }` shape on failure — it NEVER throws, so a
|
|
93
|
+
* slow/failing send can't escape the cron handler. (Drop-silent —
|
|
94
|
+
* by design: this is a hot-path scheduled sink, not a config
|
|
95
|
+
* entry point.)
|
|
96
|
+
*
|
|
97
|
+
* Config threshold:
|
|
98
|
+
*
|
|
99
|
+
* `shop.cart_recovery_after_hours` (default 4) sets how long a cart
|
|
100
|
+
* sits idle before it's eligible. Read through the `config`
|
|
101
|
+
* primitive when wired; an out-of-range stored value throws at the
|
|
102
|
+
* config-read boundary (entry-point tier) so an operator catches a
|
|
103
|
+
* fat-fingered value rather than silently shipping a 0-hour or
|
|
104
|
+
* 1000-hour threshold.
|
|
105
|
+
*
|
|
106
|
+
* Gating (no-op when unconfigured):
|
|
107
|
+
*
|
|
108
|
+
* The pass is INERT unless `cartRecovery` is wired with a live
|
|
109
|
+
* `email` primitive AND a `resolveEmail` hook is supplied — the two
|
|
110
|
+
* things needed to actually deliver mail. Absent either, `runPass`
|
|
111
|
+
* returns `{ ok: true, enabled: false, reason }` without scanning,
|
|
112
|
+
* so an operator who hasn't configured a mailer pays nothing and
|
|
113
|
+
* the cron stays quiet. This is the common case today: no mailer is
|
|
114
|
+
* wired into the default deploy, so the cron's recovery branch
|
|
115
|
+
* no-ops cleanly until the operator opts in.
|
|
116
|
+
*
|
|
117
|
+
* Composes ONLY blamejs + sibling shop primitives:
|
|
118
|
+
* - shop.cartAbandonment — scan + detection bookkeeping.
|
|
119
|
+
* - shop.cartRecovery — enroll + dispatch FSM.
|
|
120
|
+
* - shop.config — threshold read (optional).
|
|
121
|
+
* - shop.consentLedger — marketing opt-out gate (optional).
|
|
122
|
+
* - b.constants.TIME — hours → ms conversion.
|
|
123
|
+
*
|
|
124
|
+
* @primitive cartRecoveryPass
|
|
125
|
+
* @related shop.cartAbandonment, shop.cartRecovery, shop.config,
|
|
126
|
+
* shop.consentLedger, shop.email, shop.emailSuppressions
|
|
127
|
+
*/
|
|
128
|
+
|
|
129
|
+
var b = require("./vendor/blamejs");
|
|
130
|
+
var C = b.constants;
|
|
131
|
+
|
|
132
|
+
// ---- constants ----------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
var CONFIG_KEY_AFTER_HOURS = "shop.cart_recovery_after_hours";
|
|
135
|
+
var DEFAULT_AFTER_HOURS = 4;
|
|
136
|
+
var MIN_AFTER_HOURS = 1;
|
|
137
|
+
var MAX_AFTER_HOURS = 720; // 30 days — the abandonment max-age ceiling
|
|
138
|
+
|
|
139
|
+
var DEFAULT_MAX_CARTS = 200;
|
|
140
|
+
var MAX_MAX_CARTS = 2000;
|
|
141
|
+
var DEFAULT_MAX_DISPATCH = 200;
|
|
142
|
+
var MAX_MAX_DISPATCH = 2000;
|
|
143
|
+
|
|
144
|
+
var DEFAULT_SEQUENCE_SLUG = "default-recovery";
|
|
145
|
+
|
|
146
|
+
// The consent ledger records `marketing_email` decisions as `granted`
|
|
147
|
+
// / `withdrawn`. We refuse enrollment only on a recorded withdrawal —
|
|
148
|
+
// a missing decision is "no objection on file" (the positive grant is
|
|
149
|
+
// recorded by the operator's checkout opt-in, not invented here).
|
|
150
|
+
var CONSENT_KIND_MARKETING = "marketing_email";
|
|
151
|
+
var CONSENT_WITHDRAWN = "withdrawn";
|
|
152
|
+
|
|
153
|
+
// ---- validators ---------------------------------------------------------
|
|
154
|
+
//
|
|
155
|
+
// Config-time / entry-point tier — THROW on bad operator input so a
|
|
156
|
+
// typo is caught at boot or at the config-read boundary rather than
|
|
157
|
+
// silently shipping a nonsense threshold.
|
|
158
|
+
|
|
159
|
+
function _positiveIntInRange(n, label, min, max) {
|
|
160
|
+
if (typeof n !== "number" || !isFinite(n) || !Number.isInteger(n) || n < min || n > max) {
|
|
161
|
+
throw new TypeError(
|
|
162
|
+
"cartRecoveryPass: " + label + " must be an integer in [" + min + ", " + max + "]"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return n;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- factory ------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
function create(opts) {
|
|
171
|
+
opts = opts || {};
|
|
172
|
+
|
|
173
|
+
// Required dep: the abandonment scanner. The pass can't find idle
|
|
174
|
+
// carts without it, so refuse at factory time (boot) rather than at
|
|
175
|
+
// first tick.
|
|
176
|
+
if (!opts.cartAbandonment || typeof opts.cartAbandonment.scan !== "function") {
|
|
177
|
+
throw new TypeError(
|
|
178
|
+
"cartRecoveryPass.create: opts.cartAbandonment (with .scan()) is required"
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
var cartAbandonment = opts.cartAbandonment;
|
|
182
|
+
|
|
183
|
+
// Required dep: the recovery FSM. The pass enrolls detections + ticks
|
|
184
|
+
// the dispatcher through it.
|
|
185
|
+
if (
|
|
186
|
+
!opts.cartRecovery ||
|
|
187
|
+
typeof opts.cartRecovery.enrollDetection !== "function" ||
|
|
188
|
+
typeof opts.cartRecovery.dispatchTick !== "function" ||
|
|
189
|
+
typeof opts.cartRecovery.defineSequence !== "function"
|
|
190
|
+
) {
|
|
191
|
+
throw new TypeError(
|
|
192
|
+
"cartRecoveryPass.create: opts.cartRecovery (with .enrollDetection/.dispatchTick/.defineSequence) is required"
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
var cartRecovery = opts.cartRecovery;
|
|
196
|
+
|
|
197
|
+
// Optional dep: the config primitive. When wired, the idle threshold
|
|
198
|
+
// is read from `shop.cart_recovery_after_hours`; absent it, the
|
|
199
|
+
// default (4h) is used.
|
|
200
|
+
var config = opts.config || null;
|
|
201
|
+
if (config && typeof config.get !== "function") {
|
|
202
|
+
throw new TypeError(
|
|
203
|
+
"cartRecoveryPass.create: opts.config must expose a get(key, default) method"
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Optional dep: the consent ledger. When wired, a recorded
|
|
208
|
+
// marketing-email withdrawal keeps the customer out of the sequence
|
|
209
|
+
// before enrollment. Absent it, the suppression list inside the
|
|
210
|
+
// dispatcher is the backstop.
|
|
211
|
+
var consentLedger = opts.consentLedger || null;
|
|
212
|
+
if (consentLedger && typeof consentLedger.currentStateForCustomer !== "function") {
|
|
213
|
+
throw new TypeError(
|
|
214
|
+
"cartRecoveryPass.create: opts.consentLedger must expose currentStateForCustomer(customerId)"
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Operator-owned resolver: an abandoned cart's customer_id → a
|
|
219
|
+
// deliverable plaintext address. The customer record stores only an
|
|
220
|
+
// email_hash, so the framework cannot recover the address on its own
|
|
221
|
+
// — the operator wires the route (typically through their own
|
|
222
|
+
// customer/address store). Absent the hook the pass is inert (no
|
|
223
|
+
// deliverable address means nothing to send).
|
|
224
|
+
var resolveEmail = opts.resolveEmail || null;
|
|
225
|
+
if (resolveEmail != null && typeof resolveEmail !== "function") {
|
|
226
|
+
throw new TypeError(
|
|
227
|
+
"cartRecoveryPass.create: opts.resolveEmail must be a function (candidate) => Promise<string|null>"
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The recovery sequence detections enroll into. Validated lazily —
|
|
232
|
+
// `defineSequence` / `enrollDetection` enforce the slug grammar.
|
|
233
|
+
var sequenceSlug = typeof opts.sequenceSlug === "string" && opts.sequenceSlug.length
|
|
234
|
+
? opts.sequenceSlug
|
|
235
|
+
: DEFAULT_SEQUENCE_SLUG;
|
|
236
|
+
|
|
237
|
+
// Base URL the recovery email's cart link is built from. Passed
|
|
238
|
+
// straight through to the dispatcher.
|
|
239
|
+
var cartUrlBase = typeof opts.cartUrlBase === "string" && opts.cartUrlBase.length
|
|
240
|
+
? opts.cartUrlBase
|
|
241
|
+
: null;
|
|
242
|
+
|
|
243
|
+
var maxCarts = opts.maxCarts == null
|
|
244
|
+
? DEFAULT_MAX_CARTS
|
|
245
|
+
: _positiveIntInRange(opts.maxCarts, "maxCarts", 1, MAX_MAX_CARTS);
|
|
246
|
+
var maxDispatch = opts.maxDispatch == null
|
|
247
|
+
? DEFAULT_MAX_DISPATCH
|
|
248
|
+
: _positiveIntInRange(opts.maxDispatch, "maxDispatch", 1, MAX_MAX_DISPATCH);
|
|
249
|
+
|
|
250
|
+
// The dispatcher can actually deliver mail only when the recovery
|
|
251
|
+
// primitive was wired with a live `email` dep AND we have a resolver
|
|
252
|
+
// to turn a customer_id into a plaintext address. Inspect the
|
|
253
|
+
// recovery primitive's exposed deps so the gate reflects the real
|
|
254
|
+
// wiring rather than a separate config flag.
|
|
255
|
+
function _deliveryConfigured() {
|
|
256
|
+
var deps = cartRecovery._deps || {};
|
|
257
|
+
var hasEmail = !!(deps.email && typeof deps.email.sendAbandonedCartReminder === "function");
|
|
258
|
+
return hasEmail && typeof resolveEmail === "function";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- internals --------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
// Read + validate the idle threshold. Config-time / entry-point tier
|
|
264
|
+
// — a stored value outside [MIN, MAX] hours THROWS so the operator
|
|
265
|
+
// catches a bad config row rather than silently shipping a nonsense
|
|
266
|
+
// cadence. Absent the config dep (or the key), the default applies.
|
|
267
|
+
async function _resolveAfterHours() {
|
|
268
|
+
if (!config) return DEFAULT_AFTER_HOURS;
|
|
269
|
+
var raw = await config.get(CONFIG_KEY_AFTER_HOURS, DEFAULT_AFTER_HOURS);
|
|
270
|
+
if (raw == null) return DEFAULT_AFTER_HOURS;
|
|
271
|
+
var n = typeof raw === "string" ? Number(raw) : raw;
|
|
272
|
+
return _positiveIntInRange(
|
|
273
|
+
n, CONFIG_KEY_AFTER_HOURS, MIN_AFTER_HOURS, MAX_AFTER_HOURS
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// True when the customer has a RECORDED marketing-email withdrawal.
|
|
278
|
+
// A missing decision (no row) returns false — "no objection on file".
|
|
279
|
+
// Reads through the consent ledger when wired; absent it, the gate is
|
|
280
|
+
// open (the suppression list is the backstop inside the dispatcher).
|
|
281
|
+
async function _hasWithdrawnMarketing(customerId) {
|
|
282
|
+
if (!consentLedger || !customerId) return false;
|
|
283
|
+
var state = await consentLedger.currentStateForCustomer(customerId);
|
|
284
|
+
var decision = state && state[CONSENT_KIND_MARKETING];
|
|
285
|
+
return !!(decision && decision.state === CONSENT_WITHDRAWN);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Ensure the recovery sequence the pass enrolls into exists. The
|
|
289
|
+
// operator can pre-define a richer sequence with the same slug; this
|
|
290
|
+
// only seeds a sane 3-step default (1h reminder → 24h discount → 72h
|
|
291
|
+
// last-chance) the first time so a fresh deploy isn't inert for lack
|
|
292
|
+
// of a sequence row. `defineSequence` is an upsert keyed on slug, but
|
|
293
|
+
// we only seed when absent so we never stomp an operator's edits.
|
|
294
|
+
async function _ensureSequence(now) {
|
|
295
|
+
var existing = await cartRecovery.getSequence(sequenceSlug);
|
|
296
|
+
if (existing) return existing;
|
|
297
|
+
return await cartRecovery.defineSequence({
|
|
298
|
+
slug: sequenceSlug,
|
|
299
|
+
title: "Cart recovery",
|
|
300
|
+
steps: [
|
|
301
|
+
{ step_index: 0, offset_ms: C.TIME.hours(1), kind: "reminder" },
|
|
302
|
+
{ step_index: 1, offset_ms: C.TIME.hours(24), kind: "discount" },
|
|
303
|
+
{ step_index: 2, offset_ms: C.TIME.hours(72), kind: "last_chance" },
|
|
304
|
+
],
|
|
305
|
+
now: now,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---- surface ----------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
CONFIG_KEY_AFTER_HOURS: CONFIG_KEY_AFTER_HOURS,
|
|
313
|
+
DEFAULT_AFTER_HOURS: DEFAULT_AFTER_HOURS,
|
|
314
|
+
|
|
315
|
+
// One full orchestration tick: scan → consent-gate → enroll →
|
|
316
|
+
// dispatch. Drop-silent (by design) — wraps the whole body in a
|
|
317
|
+
// try/catch and NEVER throws, so a slow/failing send can't escape
|
|
318
|
+
// the cron handler. Returns a summary object describing what
|
|
319
|
+
// happened (or `{ ok: false, error }` on an internal failure).
|
|
320
|
+
//
|
|
321
|
+
// Safe to call every minute: the scan + dispatch batches are
|
|
322
|
+
// bounded, the abandonment scan is window-idempotent, and each
|
|
323
|
+
// detection enrolls at most once.
|
|
324
|
+
runPass: async function (passOpts) {
|
|
325
|
+
passOpts = passOpts || {};
|
|
326
|
+
|
|
327
|
+
// Gate: inert unless mail can actually be delivered. No scan, no
|
|
328
|
+
// DB churn — return a cheap "disabled" summary.
|
|
329
|
+
if (!_deliveryConfigured()) {
|
|
330
|
+
return {
|
|
331
|
+
ok: true,
|
|
332
|
+
enabled: false,
|
|
333
|
+
reason: "cart-recovery email delivery not configured (needs cartRecovery.email + resolveEmail)",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
var now = passOpts.now == null ? Date.now() : passOpts.now;
|
|
339
|
+
|
|
340
|
+
var afterHours = await _resolveAfterHours();
|
|
341
|
+
var idleMs = C.TIME.hours(afterHours);
|
|
342
|
+
|
|
343
|
+
await _ensureSequence(now);
|
|
344
|
+
|
|
345
|
+
// 1. Scan for freshly abandoned carts. The scanner writes a
|
|
346
|
+
// detection row per new candidate + returns the list.
|
|
347
|
+
var scan = await cartAbandonment.scan({
|
|
348
|
+
idle_threshold_ms: idleMs,
|
|
349
|
+
max_carts: maxCarts,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
var enrolled = 0;
|
|
353
|
+
var skippedNoCustomer = 0;
|
|
354
|
+
var skippedNoEmail = 0;
|
|
355
|
+
var skippedOptOut = 0;
|
|
356
|
+
|
|
357
|
+
var candidates = scan.candidates || [];
|
|
358
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
359
|
+
var cand = candidates[i];
|
|
360
|
+
|
|
361
|
+
// Guest cart — no customer record, so no address anywhere in
|
|
362
|
+
// the schema. Mark the detection skipped so the abandonment
|
|
363
|
+
// dashboard reflects the outcome, then move on. (Re-opens
|
|
364
|
+
// when the operator persists a guest email on the cart.)
|
|
365
|
+
if (!cand.customer_id) {
|
|
366
|
+
skippedNoCustomer += 1;
|
|
367
|
+
await cartAbandonment.markReminderSkipped(cand.detection_id, {
|
|
368
|
+
reason: "anonymous",
|
|
369
|
+
});
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Marketing opt-out gate — refuse enrollment for a customer
|
|
374
|
+
// who has recorded a marketing-email withdrawal. The
|
|
375
|
+
// suppression list inside the dispatcher catches bounces /
|
|
376
|
+
// complaints; this catches the explicit consent withdrawal
|
|
377
|
+
// BEFORE we enroll them.
|
|
378
|
+
var optedOut = await _hasWithdrawnMarketing(cand.customer_id);
|
|
379
|
+
if (optedOut) {
|
|
380
|
+
skippedOptOut += 1;
|
|
381
|
+
await cartAbandonment.markReminderSkipped(cand.detection_id, {
|
|
382
|
+
reason: "opted-out",
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Resolve a deliverable address. Absent one, mark the
|
|
388
|
+
// detection skipped + don't enroll (nothing to send).
|
|
389
|
+
var address = null;
|
|
390
|
+
try { address = await resolveEmail(cand); }
|
|
391
|
+
catch (_e) { address = null; }
|
|
392
|
+
if (typeof address !== "string" || !address.length) {
|
|
393
|
+
skippedNoEmail += 1;
|
|
394
|
+
await cartAbandonment.markReminderSkipped(cand.detection_id, {
|
|
395
|
+
reason: "no-email",
|
|
396
|
+
});
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Enroll into the recovery sequence. The dispatcher (below /
|
|
401
|
+
// next tick) owns the actual send + the per-send suppression
|
|
402
|
+
// check. We mark the abandonment detection `sent` to record
|
|
403
|
+
// that fan-out has begun — the nurture FSM is now the source
|
|
404
|
+
// of truth for this cart.
|
|
405
|
+
await cartRecovery.enrollDetection({
|
|
406
|
+
sequence_slug: sequenceSlug,
|
|
407
|
+
detection_id: cand.detection_id,
|
|
408
|
+
cart_id: cand.cart_id,
|
|
409
|
+
customer_id: cand.customer_id,
|
|
410
|
+
customer_email: address,
|
|
411
|
+
now: now,
|
|
412
|
+
});
|
|
413
|
+
await cartAbandonment.markReminderSent(cand.detection_id, {
|
|
414
|
+
sent_at: now,
|
|
415
|
+
});
|
|
416
|
+
enrolled += 1;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 2. Dispatch every nurture step that has come due. The
|
|
420
|
+
// dispatcher walks `status='enrolled' AND next_step_at <=
|
|
421
|
+
// now`, sends through the email primitive (subject to the
|
|
422
|
+
// per-send suppression gate), and advances the FSM. The
|
|
423
|
+
// resolver re-supplies the plaintext address (the
|
|
424
|
+
// enrollment row stores only the hash).
|
|
425
|
+
var dispatch = await cartRecovery.dispatchTick({
|
|
426
|
+
now: now,
|
|
427
|
+
max_batch: maxDispatch,
|
|
428
|
+
cart_url_base: cartUrlBase,
|
|
429
|
+
resolveEmail: function (enrollment) {
|
|
430
|
+
// The dispatcher hands the enrollment row; route through
|
|
431
|
+
// the same operator resolver, shaping the input to the
|
|
432
|
+
// candidate-like fields the resolver reads.
|
|
433
|
+
return resolveEmail({
|
|
434
|
+
customer_id: enrollment.customer_id,
|
|
435
|
+
cart_id: enrollment.cart_id,
|
|
436
|
+
detection_id: enrollment.detection_id,
|
|
437
|
+
});
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
ok: true,
|
|
443
|
+
enabled: true,
|
|
444
|
+
after_hours: afterHours,
|
|
445
|
+
scanned: scan.carts_scanned,
|
|
446
|
+
detected: scan.carts_detected,
|
|
447
|
+
enrolled: enrolled,
|
|
448
|
+
skipped_no_customer: skippedNoCustomer,
|
|
449
|
+
skipped_no_email: skippedNoEmail,
|
|
450
|
+
skipped_opt_out: skippedOptOut,
|
|
451
|
+
dispatched: dispatch.dispatched,
|
|
452
|
+
};
|
|
453
|
+
} catch (e) {
|
|
454
|
+
// Drop-silent — by design. A scheduled hot-path sink must never
|
|
455
|
+
// throw out of the cron handler (that would crash-loop the
|
|
456
|
+
// tick). Return the failure as data so the caller's
|
|
457
|
+
// observability sink can surface it.
|
|
458
|
+
var msg = e && e.message ? String(e.message) : String(e || "unknown");
|
|
459
|
+
if (msg.length > 1024) msg = msg.slice(0, 1024);
|
|
460
|
+
return { ok: false, enabled: true, error: msg };
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
// Expose deps + resolved gate so a wiring sanity check (and the
|
|
465
|
+
// tests) can assert the pass reached the factory correctly.
|
|
466
|
+
_deps: {
|
|
467
|
+
cartAbandonment: cartAbandonment,
|
|
468
|
+
cartRecovery: cartRecovery,
|
|
469
|
+
config: config,
|
|
470
|
+
consentLedger: consentLedger,
|
|
471
|
+
resolveEmail: resolveEmail,
|
|
472
|
+
sequenceSlug: sequenceSlug,
|
|
473
|
+
},
|
|
474
|
+
_deliveryConfigured: _deliveryConfigured,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
module.exports = {
|
|
479
|
+
create: create,
|
|
480
|
+
CONFIG_KEY_AFTER_HOURS: CONFIG_KEY_AFTER_HOURS,
|
|
481
|
+
DEFAULT_AFTER_HOURS: DEFAULT_AFTER_HOURS,
|
|
482
|
+
};
|
package/lib/index.js
CHANGED
|
@@ -223,6 +223,7 @@ Object.assign(module.exports, {
|
|
|
223
223
|
stockReceipts: require("./stock-receipts"),
|
|
224
224
|
bannerABTests: require("./banner-ab-tests"),
|
|
225
225
|
cartRecovery: require("./cart-recovery"),
|
|
226
|
+
cartRecoveryPass: require("./cart-recovery-pass"),
|
|
226
227
|
sidebarWidgets: require("./sidebar-widgets"),
|
|
227
228
|
consentLedger: require("./consent-ledger"),
|
|
228
229
|
announcementBar: require("./announcement-bar"),
|
package/package.json
CHANGED