@blamejs/blamejs-shop 0.0.72 → 0.0.75

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 (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,952 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.sidebarWidgets
4
+ * @title Sidebar widgets — operator-curated storefront sidebar content
5
+ *
6
+ * @intro
7
+ * Operator-authored content blocks rendered in the storefront
8
+ * sidebar across the product / collection / cart / search / account
9
+ * pages. Where `promoBanners` owns horizontal strips at fixed
10
+ * placements, `sidebarWidgets` owns the vertically-stacked sidebar
11
+ * on pages that have one — each page declares an ordered list of
12
+ * widgets via `setPagePlacement(page_key, [slugs...])`, and the
13
+ * storefront's render path resolves the ordered list for the
14
+ * current page + viewer through `widgetsForPage`.
15
+ *
16
+ * Nine widget kinds, each with a kind-specific payload shape:
17
+ *
18
+ * - newsletter_signup — { list_id, headline, cta_label }
19
+ * - recently_viewed — { limit }
20
+ * - trust_badges — { badges: [slug, ...] }
21
+ * - featured_collection — { collection_slug, limit }
22
+ * - social_proof — { headline, message_template }
23
+ * - size_chart — { chart_slug }
24
+ * - live_visitors — { window_minutes, min_threshold }
25
+ * - countdown_timer — { target_at, completed_label }
26
+ * - sticky_addtocart — { variant_slug }
27
+ *
28
+ * Audience filtering mirrors `promoBanners` — every widget targets
29
+ * `all` / `logged_in` / `guest` / a named `segment` — and the
30
+ * factory accepts an optional `customerSegments` handle for
31
+ * segment-membership resolution.
32
+ *
33
+ * Schedule windows (`starts_at` / `expires_at`) gate visibility in
34
+ * time; `archived_at` soft-retires a widget so its placement rows
35
+ * stay queryable while the widget itself stops rendering.
36
+ *
37
+ * Surface:
38
+ * - `defineWidget({ slug, title, kind, payload, audience,
39
+ * segment_slug?, priority?, starts_at,
40
+ * expires_at })`
41
+ * - `setPagePlacement(page_key, [slug, ...])` — replaces the
42
+ * page's ordered widget list atomically.
43
+ * - `widgetsForPage({ page_key, viewer_kind, customer_id?, now })`
44
+ * — ordered widgets for the page, filtered by audience +
45
+ * schedule window. Returns the placement order verbatim;
46
+ * widgets that fail the audience / schedule / archived check
47
+ * drop out without renumbering.
48
+ * - `recordImpression({ widget_slug, page_key })` —
49
+ * drop-silent ledger insert (hot path).
50
+ * - `recordClick({ widget_slug, page_key })` — drop-silent
51
+ * ledger insert (hot path).
52
+ * - `metricsForWidget({ slug, from?, to? })` — impressions /
53
+ * clicks / CTR + per-page-key breakdown.
54
+ * - `listWidgets({ kind?, audience?, include_archived?, limit? })`
55
+ * - `updateWidget(slug, patch)` — title / payload / audience /
56
+ * segment_slug / priority / starts_at / expires_at.
57
+ * - `archiveWidget(slug)` — idempotent soft-retire.
58
+ *
59
+ * Composes:
60
+ * - `b.uuid.v7` — ledger row PK (lexicographic-monotonic so
61
+ * per-widget event reads sort cleanly).
62
+ * - `b.safeUrl.parse` — used inside payload validation where the
63
+ * operator-authored config carries URLs (no
64
+ * kind in v1 actually carries a free-form
65
+ * URL; the validation discipline is staged
66
+ * for future kinds without changing the
67
+ * surface).
68
+ *
69
+ * Storage: `migrations-d1/0176_sidebar_widgets.sql` —
70
+ * `sidebar_widgets` + `sidebar_widget_placements` (FK CASCADE) +
71
+ * `sidebar_widget_events` (FK CASCADE).
72
+ *
73
+ * @primitive sidebarWidgets
74
+ * @related b.uuid, promoBanners, customerSegments
75
+ */
76
+
77
+ var bShop;
78
+ function _b() {
79
+ if (!bShop) bShop = require("./index");
80
+ return bShop.framework;
81
+ }
82
+
83
+ // ---- constants ----------------------------------------------------------
84
+
85
+ var KINDS = Object.freeze([
86
+ "newsletter_signup",
87
+ "recently_viewed",
88
+ "trust_badges",
89
+ "featured_collection",
90
+ "social_proof",
91
+ "size_chart",
92
+ "live_visitors",
93
+ "countdown_timer",
94
+ "sticky_addtocart",
95
+ ]);
96
+
97
+ var AUDIENCES = Object.freeze(["all", "logged_in", "guest", "segment"]);
98
+ var VIEWER_KINDS = Object.freeze(["logged_in", "guest"]);
99
+ var EVENT_KINDS = Object.freeze(["impression", "click"]);
100
+
101
+ var MAX_SLUG_LEN = 80;
102
+ var MAX_TITLE_LEN = 200;
103
+ var MAX_PAGE_KEY_LEN = 120;
104
+ var MAX_HEADLINE_LEN = 200;
105
+ var MAX_CTA_LABEL_LEN = 80;
106
+ var MAX_LABEL_LEN = 200;
107
+ var MAX_MESSAGE_LEN = 500;
108
+ var MAX_LIST_ID_LEN = 120;
109
+ var MAX_COLLECTION_LEN = 120;
110
+ var MAX_VARIANT_LEN = 120;
111
+ var MAX_CHART_LEN = 120;
112
+ var MAX_BADGES = 12;
113
+ var MAX_BADGE_LEN = 80;
114
+ var MAX_WIDGETS_PER_PAGE = 24;
115
+ var MAX_PRIORITY = 1000000;
116
+ var MAX_LIMIT = 500;
117
+ var DEFAULT_LIMIT = 50;
118
+ var MAX_RECENTLY_VIEWED = 24;
119
+ var MAX_FEATURED_LIMIT = 24;
120
+ var MAX_LIVE_VISITORS_WIN = 240; // 4 hours
121
+ var MIN_LIVE_VISITORS_WIN = 1;
122
+ var MAX_MIN_THRESHOLD = 100000;
123
+
124
+ // Slug shape mirrors the storefront-wide convention: leading alnum,
125
+ // alnum / dot / dash / underscore, capped length. The slug reaches
126
+ // operator-facing admin URLs and HTML data-attributes.
127
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
128
+ var PAGE_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9:._\/-]{0,119}$/;
129
+ var BADGE_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
130
+ var SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
131
+
132
+ var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
133
+ var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
134
+ var ZERO_WIDTH_RE = new RegExp(
135
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
136
+ );
137
+
138
+ var ALLOWED_PATCH_COLUMNS = Object.freeze([
139
+ "title",
140
+ "payload",
141
+ "audience",
142
+ "segment_slug",
143
+ "priority",
144
+ "starts_at",
145
+ "expires_at",
146
+ ]);
147
+
148
+ // ---- monotonic clock ----------------------------------------------------
149
+ //
150
+ // Widget definition rows + ledger event rows persist epoch-ms
151
+ // timestamps. Two same-millisecond `_now()` calls inside a single
152
+ // `setPagePlacement` (DELETE + INSERT batch) or inside a tight test
153
+ // loop would otherwise produce identical timestamps, leaving the row
154
+ // ordering ambiguous on equal-time reads. The strict-monotonic shim
155
+ // guarantees every subsequent call observes a timestamp at least 1ms
156
+ // greater than the previous one so `created_at` / `updated_at` /
157
+ // `occurred_at` define a total order without an extra tiebreaker
158
+ // column. Sibling primitives (pixelEvents, customerSurveys, etc.) use
159
+ // the same shape.
160
+
161
+ var _lastTs = 0;
162
+ function _now() {
163
+ var t = Date.now();
164
+ if (t <= _lastTs) { t = _lastTs + 1; }
165
+ _lastTs = t;
166
+ return t;
167
+ }
168
+
169
+ // ---- validators ---------------------------------------------------------
170
+
171
+ function _slug(s, label) {
172
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
173
+ throw new TypeError("sidebarWidgets: " + (label || "slug") +
174
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (1.." + MAX_SLUG_LEN + " chars)");
175
+ }
176
+ return s;
177
+ }
178
+
179
+ function _title(s) {
180
+ if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
181
+ throw new TypeError("sidebarWidgets: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
182
+ }
183
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
184
+ throw new TypeError("sidebarWidgets: title must not contain control bytes (incl. CR/LF)");
185
+ }
186
+ if (ZERO_WIDTH_RE.test(s)) {
187
+ throw new TypeError("sidebarWidgets: title must not contain zero-width / direction-override characters");
188
+ }
189
+ return s;
190
+ }
191
+
192
+ function _kind(s) {
193
+ if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
194
+ throw new TypeError("sidebarWidgets: kind must be one of " + KINDS.join(", "));
195
+ }
196
+ return s;
197
+ }
198
+
199
+ function _audience(s) {
200
+ if (typeof s !== "string" || AUDIENCES.indexOf(s) === -1) {
201
+ throw new TypeError("sidebarWidgets: audience must be one of " + AUDIENCES.join(", "));
202
+ }
203
+ return s;
204
+ }
205
+
206
+ function _segmentSlug(s) {
207
+ if (s == null) return null;
208
+ if (typeof s !== "string" || !SEGMENT_RE.test(s)) {
209
+ throw new TypeError("sidebarWidgets: segment_slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/");
210
+ }
211
+ return s;
212
+ }
213
+
214
+ function _priority(n) {
215
+ if (n == null) return 0;
216
+ if (!Number.isInteger(n) || n < 0 || n > MAX_PRIORITY) {
217
+ throw new TypeError("sidebarWidgets: priority must be an integer in [0, " + MAX_PRIORITY + "]");
218
+ }
219
+ return n;
220
+ }
221
+
222
+ function _epochMs(n, label) {
223
+ if (!Number.isInteger(n) || n < 0) {
224
+ throw new TypeError("sidebarWidgets: " + label + " must be a non-negative integer (epoch ms)");
225
+ }
226
+ return n;
227
+ }
228
+
229
+ function _epochMsOpt(n, label) {
230
+ if (n == null) return null;
231
+ return _epochMs(n, label);
232
+ }
233
+
234
+ function _pageKey(s) {
235
+ if (typeof s !== "string" || !s.length || s.length > MAX_PAGE_KEY_LEN || !PAGE_KEY_RE.test(s)) {
236
+ throw new TypeError("sidebarWidgets: page_key must match /^[A-Za-z0-9][A-Za-z0-9:._\\/-]*$/ (1.." + MAX_PAGE_KEY_LEN + " chars)");
237
+ }
238
+ return s;
239
+ }
240
+
241
+ function _viewerKind(s) {
242
+ if (typeof s !== "string" || VIEWER_KINDS.indexOf(s) === -1) {
243
+ throw new TypeError("sidebarWidgets: viewer_kind must be one of " + VIEWER_KINDS.join(", "));
244
+ }
245
+ return s;
246
+ }
247
+
248
+ function _eventKind(s) {
249
+ if (typeof s !== "string" || EVENT_KINDS.indexOf(s) === -1) {
250
+ throw new TypeError("sidebarWidgets: event_kind must be one of " + EVENT_KINDS.join(", "));
251
+ }
252
+ return s;
253
+ }
254
+
255
+ function _limit(n, label) {
256
+ if (n == null) return DEFAULT_LIMIT;
257
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
258
+ throw new TypeError("sidebarWidgets: " + (label || "limit") + " must be an integer in [1, " + MAX_LIMIT + "]");
259
+ }
260
+ return n;
261
+ }
262
+
263
+ function _line(s, label, maxLen) {
264
+ if (typeof s !== "string" || !s.length || s.length > maxLen) {
265
+ throw new TypeError("sidebarWidgets: " + label + " must be a non-empty string <= " + maxLen + " chars");
266
+ }
267
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
268
+ throw new TypeError("sidebarWidgets: " + label + " must not contain control bytes (incl. CR/LF)");
269
+ }
270
+ if (ZERO_WIDTH_RE.test(s)) {
271
+ throw new TypeError("sidebarWidgets: " + label + " must not contain zero-width / direction-override characters");
272
+ }
273
+ return s;
274
+ }
275
+
276
+ function _block(s, label, maxLen) {
277
+ if (typeof s !== "string" || !s.length || s.length > maxLen) {
278
+ throw new TypeError("sidebarWidgets: " + label + " must be a non-empty string <= " + maxLen + " chars");
279
+ }
280
+ if (CONTROL_BYTE_BLOCK_RE.test(s)) {
281
+ throw new TypeError("sidebarWidgets: " + label + " must not contain control bytes");
282
+ }
283
+ if (ZERO_WIDTH_RE.test(s)) {
284
+ throw new TypeError("sidebarWidgets: " + label + " must not contain zero-width / direction-override characters");
285
+ }
286
+ return s;
287
+ }
288
+
289
+ function _ident(s, label, re, maxLen) {
290
+ if (typeof s !== "string" || !s.length || s.length > maxLen || !re.test(s)) {
291
+ throw new TypeError("sidebarWidgets: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (1.." + maxLen + " chars)");
292
+ }
293
+ return s;
294
+ }
295
+
296
+ // ---- payload validation -------------------------------------------------
297
+ //
298
+ // Each kind owns a payload shape. The validator returns a canonical
299
+ // (key-ordered) object so the stored JSON is independent of caller
300
+ // insertion order. Unknown keys are refused so a stale client can't
301
+ // smuggle extra fields into the persisted JSON.
302
+
303
+ function _payloadFor(kind, payload) {
304
+ if (payload == null || typeof payload !== "object" || Array.isArray(payload)) {
305
+ throw new TypeError("sidebarWidgets: payload must be an object");
306
+ }
307
+
308
+ function _onlyKeys(allowed) {
309
+ var keys = Object.keys(payload);
310
+ for (var i = 0; i < keys.length; i += 1) {
311
+ if (allowed.indexOf(keys[i]) === -1) {
312
+ throw new TypeError("sidebarWidgets: payload key " + JSON.stringify(keys[i]) +
313
+ " is not valid for kind " + JSON.stringify(kind));
314
+ }
315
+ }
316
+ }
317
+
318
+ if (kind === "newsletter_signup") {
319
+ _onlyKeys(["list_id", "headline", "cta_label"]);
320
+ return {
321
+ list_id: _ident(payload.list_id, "payload.list_id", /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/, MAX_LIST_ID_LEN),
322
+ headline: _line(payload.headline, "payload.headline", MAX_HEADLINE_LEN),
323
+ cta_label: _line(payload.cta_label, "payload.cta_label", MAX_CTA_LABEL_LEN),
324
+ };
325
+ }
326
+ if (kind === "recently_viewed") {
327
+ _onlyKeys(["limit"]);
328
+ var lim = payload.limit;
329
+ if (!Number.isInteger(lim) || lim < 1 || lim > MAX_RECENTLY_VIEWED) {
330
+ throw new TypeError("sidebarWidgets: payload.limit must be an integer in [1, " + MAX_RECENTLY_VIEWED + "]");
331
+ }
332
+ return { limit: lim };
333
+ }
334
+ if (kind === "trust_badges") {
335
+ _onlyKeys(["badges"]);
336
+ if (!Array.isArray(payload.badges) || payload.badges.length === 0) {
337
+ throw new TypeError("sidebarWidgets: payload.badges must be a non-empty array");
338
+ }
339
+ if (payload.badges.length > MAX_BADGES) {
340
+ throw new TypeError("sidebarWidgets: payload.badges must contain <= " + MAX_BADGES + " entries");
341
+ }
342
+ var seen = Object.create(null);
343
+ var out = [];
344
+ for (var i = 0; i < payload.badges.length; i += 1) {
345
+ var b = payload.badges[i];
346
+ if (typeof b !== "string" || !b.length || b.length > MAX_BADGE_LEN || !BADGE_SLUG_RE.test(b)) {
347
+ throw new TypeError("sidebarWidgets: payload.badges[" + i +
348
+ "] must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (1.." + MAX_BADGE_LEN + " chars)");
349
+ }
350
+ if (seen[b]) {
351
+ throw new TypeError("sidebarWidgets: payload.badges[" + i + "] duplicates a previous entry");
352
+ }
353
+ seen[b] = true;
354
+ out.push(b);
355
+ }
356
+ return { badges: out };
357
+ }
358
+ if (kind === "featured_collection") {
359
+ _onlyKeys(["collection_slug", "limit"]);
360
+ var col = _ident(payload.collection_slug, "payload.collection_slug",
361
+ /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/, MAX_COLLECTION_LEN);
362
+ var flim = payload.limit;
363
+ if (!Number.isInteger(flim) || flim < 1 || flim > MAX_FEATURED_LIMIT) {
364
+ throw new TypeError("sidebarWidgets: payload.limit must be an integer in [1, " + MAX_FEATURED_LIMIT + "]");
365
+ }
366
+ return { collection_slug: col, limit: flim };
367
+ }
368
+ if (kind === "social_proof") {
369
+ _onlyKeys(["headline", "message_template"]);
370
+ return {
371
+ headline: _line(payload.headline, "payload.headline", MAX_HEADLINE_LEN),
372
+ message_template: _block(payload.message_template, "payload.message_template", MAX_MESSAGE_LEN),
373
+ };
374
+ }
375
+ if (kind === "size_chart") {
376
+ _onlyKeys(["chart_slug"]);
377
+ return {
378
+ chart_slug: _ident(payload.chart_slug, "payload.chart_slug",
379
+ /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/, MAX_CHART_LEN),
380
+ };
381
+ }
382
+ if (kind === "live_visitors") {
383
+ _onlyKeys(["window_minutes", "min_threshold"]);
384
+ var win = payload.window_minutes;
385
+ if (!Number.isInteger(win) || win < MIN_LIVE_VISITORS_WIN || win > MAX_LIVE_VISITORS_WIN) {
386
+ throw new TypeError("sidebarWidgets: payload.window_minutes must be an integer in [" +
387
+ MIN_LIVE_VISITORS_WIN + ", " + MAX_LIVE_VISITORS_WIN + "]");
388
+ }
389
+ var thr = payload.min_threshold;
390
+ if (!Number.isInteger(thr) || thr < 0 || thr > MAX_MIN_THRESHOLD) {
391
+ throw new TypeError("sidebarWidgets: payload.min_threshold must be an integer in [0, " + MAX_MIN_THRESHOLD + "]");
392
+ }
393
+ return { window_minutes: win, min_threshold: thr };
394
+ }
395
+ if (kind === "countdown_timer") {
396
+ _onlyKeys(["target_at", "completed_label"]);
397
+ var target = payload.target_at;
398
+ if (!Number.isInteger(target) || target <= 0) {
399
+ throw new TypeError("sidebarWidgets: payload.target_at must be a positive integer (epoch ms)");
400
+ }
401
+ return {
402
+ target_at: target,
403
+ completed_label: _line(payload.completed_label, "payload.completed_label", MAX_LABEL_LEN),
404
+ };
405
+ }
406
+ // sticky_addtocart
407
+ _onlyKeys(["variant_slug"]);
408
+ return {
409
+ variant_slug: _ident(payload.variant_slug, "payload.variant_slug",
410
+ /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/, MAX_VARIANT_LEN),
411
+ };
412
+ }
413
+
414
+ // ---- row hydration ------------------------------------------------------
415
+
416
+ function _hydrateWidget(r) {
417
+ if (!r) return null;
418
+ var payload;
419
+ try { payload = JSON.parse(r.payload_json); }
420
+ catch (_e) { payload = {}; }
421
+ return {
422
+ slug: r.slug,
423
+ title: r.title,
424
+ kind: r.kind,
425
+ payload: payload,
426
+ audience: r.audience,
427
+ segment_slug: r.segment_slug,
428
+ priority: Number(r.priority),
429
+ starts_at: Number(r.starts_at),
430
+ expires_at: Number(r.expires_at),
431
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
432
+ created_at: Number(r.created_at),
433
+ updated_at: Number(r.updated_at),
434
+ };
435
+ }
436
+
437
+ // ---- factory ------------------------------------------------------------
438
+
439
+ function create(opts) {
440
+ opts = opts || {};
441
+ var query = opts.query;
442
+ if (!query) {
443
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
444
+ }
445
+ // customerSegments is optional — widgets with audience = "segment"
446
+ // require it, but a deployment without segment-targeted widgets can
447
+ // run without one. The factory captures the handle and the
448
+ // `widgetsForPage` path enforces the requirement lazily so the
449
+ // error surfaces with a clear message when a segment-audience
450
+ // widget is reached without a segments handle wired up.
451
+ var customerSegments = opts.customerSegments || null;
452
+
453
+ // -- internal helpers --------------------------------------------------
454
+
455
+ async function _getWidgetRow(slug) {
456
+ var r = await query("SELECT * FROM sidebar_widgets WHERE slug = ?1", [slug]);
457
+ return r.rows[0] || null;
458
+ }
459
+
460
+ async function getWidget(slug) {
461
+ _slug(slug);
462
+ return _hydrateWidget(await _getWidgetRow(slug));
463
+ }
464
+
465
+ // -- defineWidget ------------------------------------------------------
466
+
467
+ async function defineWidget(input) {
468
+ if (!input || typeof input !== "object") {
469
+ throw new TypeError("sidebarWidgets.defineWidget: input object required");
470
+ }
471
+ var slug = _slug(input.slug, "slug");
472
+ var title = _title(input.title);
473
+ var kind = _kind(input.kind);
474
+ var payload = _payloadFor(kind, input.payload);
475
+ var audience = _audience(input.audience);
476
+
477
+ var segmentSlug;
478
+ if (audience === "segment") {
479
+ if (input.segment_slug == null) {
480
+ throw new TypeError("sidebarWidgets.defineWidget: audience=\"segment\" requires segment_slug");
481
+ }
482
+ segmentSlug = _segmentSlug(input.segment_slug);
483
+ } else {
484
+ if (input.segment_slug != null) {
485
+ throw new TypeError("sidebarWidgets.defineWidget: segment_slug only valid when audience=\"segment\"");
486
+ }
487
+ segmentSlug = null;
488
+ }
489
+
490
+ var priority = _priority(input.priority);
491
+ var startsAt = _epochMs(input.starts_at, "starts_at");
492
+ var expiresAt = _epochMs(input.expires_at, "expires_at");
493
+ if (expiresAt <= startsAt) {
494
+ throw new TypeError("sidebarWidgets.defineWidget: expires_at must be strictly greater than starts_at");
495
+ }
496
+
497
+ var existing = await _getWidgetRow(slug);
498
+ var ts = _now();
499
+ if (existing) {
500
+ // Update path: same-slug re-define replaces the row atomically.
501
+ // Refuses to resurrect an archived widget — operators have to
502
+ // explicitly call defineWidget on a fresh slug or update +
503
+ // unarchive (the latter isn't a defined surface in v1 — archive
504
+ // is a one-way soft-retire that preserves the placement history
505
+ // for audit but stops the widget from ever rendering again).
506
+ if (existing.archived_at != null) {
507
+ throw new TypeError("sidebarWidgets.defineWidget: widget " + JSON.stringify(slug) + " is archived");
508
+ }
509
+ // Kind is immutable after first define — the payload shape is
510
+ // kind-specific, and historical impression / click events
511
+ // reference the slug. Operators that want a different kind
512
+ // archive the old slug and define a new one.
513
+ if (existing.kind !== kind) {
514
+ throw new TypeError(
515
+ "sidebarWidgets.defineWidget: cannot change kind from " + JSON.stringify(existing.kind) +
516
+ " to " + JSON.stringify(kind) + " for slug " + JSON.stringify(slug) +
517
+ " — archive the existing widget and pick a new slug"
518
+ );
519
+ }
520
+ await query(
521
+ "UPDATE sidebar_widgets SET title = ?1, payload_json = ?2, audience = ?3, " +
522
+ "segment_slug = ?4, priority = ?5, starts_at = ?6, expires_at = ?7, updated_at = ?8 " +
523
+ "WHERE slug = ?9",
524
+ [title, JSON.stringify(payload), audience, segmentSlug, priority,
525
+ startsAt, expiresAt, ts, slug],
526
+ );
527
+ } else {
528
+ await query(
529
+ "INSERT INTO sidebar_widgets " +
530
+ "(slug, title, kind, payload_json, audience, segment_slug, priority, " +
531
+ " starts_at, expires_at, archived_at, created_at, updated_at) " +
532
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10, ?10)",
533
+ [slug, title, kind, JSON.stringify(payload), audience, segmentSlug, priority,
534
+ startsAt, expiresAt, ts],
535
+ );
536
+ }
537
+ return _hydrateWidget(await _getWidgetRow(slug));
538
+ }
539
+
540
+ // -- setPagePlacement --------------------------------------------------
541
+ //
542
+ // Atomic replace: every previous (page_key, *) placement row is
543
+ // deleted, then the new ordered list is inserted in one pass. The
544
+ // primitive verifies every slug exists + isn't archived before
545
+ // touching the placement table so a typo doesn't half-replace the
546
+ // page's sidebar.
547
+
548
+ async function setPagePlacement(pageKey, slugs) {
549
+ var key = _pageKey(pageKey);
550
+ if (!Array.isArray(slugs)) {
551
+ throw new TypeError("sidebarWidgets.setPagePlacement: slugs must be an array");
552
+ }
553
+ if (slugs.length > MAX_WIDGETS_PER_PAGE) {
554
+ throw new TypeError("sidebarWidgets.setPagePlacement: slugs must contain <= " + MAX_WIDGETS_PER_PAGE + " entries");
555
+ }
556
+ var seen = Object.create(null);
557
+ var canonical = [];
558
+ for (var i = 0; i < slugs.length; i += 1) {
559
+ var s = slugs[i];
560
+ _slug(s, "slugs[" + i + "]");
561
+ if (seen[s]) {
562
+ throw new TypeError("sidebarWidgets.setPagePlacement: slugs[" + i + "] duplicates a previous entry");
563
+ }
564
+ seen[s] = true;
565
+ canonical.push(s);
566
+ }
567
+
568
+ // Verify each referenced widget exists + isn't archived. A
569
+ // placement row pointing at an archived widget is meaningless —
570
+ // the widget is permanently retired, so the page can't render it.
571
+ for (var j = 0; j < canonical.length; j += 1) {
572
+ var row = await _getWidgetRow(canonical[j]);
573
+ if (!row) {
574
+ throw new TypeError("sidebarWidgets.setPagePlacement: widget " + JSON.stringify(canonical[j]) + " not found");
575
+ }
576
+ if (row.archived_at != null) {
577
+ throw new TypeError("sidebarWidgets.setPagePlacement: widget " + JSON.stringify(canonical[j]) + " is archived");
578
+ }
579
+ }
580
+
581
+ await query("DELETE FROM sidebar_widget_placements WHERE page_key = ?1", [key]);
582
+ var ts = _now();
583
+ for (var k = 0; k < canonical.length; k += 1) {
584
+ await query(
585
+ "INSERT INTO sidebar_widget_placements (page_key, widget_slug, position, created_at) " +
586
+ "VALUES (?1, ?2, ?3, ?4)",
587
+ [key, canonical[k], k, ts],
588
+ );
589
+ }
590
+
591
+ return { page_key: key, slugs: canonical, updated_at: ts };
592
+ }
593
+
594
+ // -- widgetsForPage ----------------------------------------------------
595
+
596
+ async function widgetsForPage(input) {
597
+ if (!input || typeof input !== "object") {
598
+ throw new TypeError("sidebarWidgets.widgetsForPage: input object required");
599
+ }
600
+ var key = _pageKey(input.page_key);
601
+ var viewerKind = _viewerKind(input.viewer_kind);
602
+ var nowTs = _epochMs(input.now, "now");
603
+ var customerId = null;
604
+ if (input.customer_id != null) {
605
+ if (typeof input.customer_id !== "string" || !input.customer_id.length) {
606
+ throw new TypeError("sidebarWidgets.widgetsForPage: customer_id must be a non-empty string when provided");
607
+ }
608
+ customerId = input.customer_id;
609
+ }
610
+ if (viewerKind === "logged_in" && customerId == null) {
611
+ throw new TypeError("sidebarWidgets.widgetsForPage: viewer_kind=\"logged_in\" requires customer_id");
612
+ }
613
+ if (viewerKind === "guest" && customerId != null) {
614
+ throw new TypeError("sidebarWidgets.widgetsForPage: viewer_kind=\"guest\" must not carry customer_id");
615
+ }
616
+
617
+ // Join placement -> widget so we read ordered placement rows
618
+ // with the corresponding widget definition in one pass. Filter
619
+ // by schedule window + archived_at at the SQL layer; the
620
+ // audience / segment-membership check happens JS-side (segment
621
+ // membership is an external handle call, not a SQL join).
622
+ var rows = (await query(
623
+ "SELECT w.*, p.position AS _position " +
624
+ "FROM sidebar_widget_placements p " +
625
+ "JOIN sidebar_widgets w ON w.slug = p.widget_slug " +
626
+ "WHERE p.page_key = ?1 AND w.archived_at IS NULL " +
627
+ "AND w.starts_at <= ?2 AND w.expires_at > ?2 " +
628
+ "ORDER BY p.position ASC",
629
+ [key, nowTs],
630
+ )).rows;
631
+
632
+ var out = [];
633
+ for (var i = 0; i < rows.length; i += 1) {
634
+ var widget = _hydrateWidget(rows[i]);
635
+ if (widget.audience === "all") {
636
+ out.push(widget); continue;
637
+ }
638
+ if (widget.audience === "logged_in" && viewerKind === "logged_in") {
639
+ out.push(widget); continue;
640
+ }
641
+ if (widget.audience === "guest" && viewerKind === "guest") {
642
+ out.push(widget); continue;
643
+ }
644
+ if (widget.audience === "segment") {
645
+ if (viewerKind !== "logged_in") continue;
646
+ if (!customerSegments) {
647
+ throw new TypeError("sidebarWidgets.widgetsForPage: widget " + JSON.stringify(widget.slug) +
648
+ " has audience=\"segment\" but no customerSegments handle was wired into create()");
649
+ }
650
+ if (typeof customerSegments.isMember !== "function") {
651
+ throw new TypeError("sidebarWidgets.widgetsForPage: customerSegments handle must expose isMember(customer_id, segment_slug)");
652
+ }
653
+ var member = await customerSegments.isMember(customerId, widget.segment_slug);
654
+ if (member) out.push(widget);
655
+ continue;
656
+ }
657
+ }
658
+ return out;
659
+ }
660
+
661
+ // -- recordImpression / recordClick ------------------------------------
662
+ //
663
+ // Drop-silent on bad input / missing widget. These run on the hot
664
+ // request path (impression on every storefront page render that
665
+ // surfaces the widget, click on the redirect / form-submit handler
666
+ // that fires before the customer leaves the page). Throwing here
667
+ // would crash the storefront response that triggered the counter.
668
+ // The defineWidget / updateWidget validators already gate legitimate
669
+ // slugs; a request that arrives with a stale slug after archive
670
+ // simply doesn't increment, which is the correct observability
671
+ // behavior — the operator's metricsForWidget rollup naturally
672
+ // reflects the post-archive zero.
673
+
674
+ async function _recordEvent(kind, input) {
675
+ if (!input || typeof input !== "object") return { recorded: false };
676
+ if (typeof input.widget_slug !== "string" || !SLUG_RE.test(input.widget_slug)) {
677
+ return { recorded: false };
678
+ }
679
+ if (typeof input.page_key !== "string" || !PAGE_KEY_RE.test(input.page_key) ||
680
+ input.page_key.length > MAX_PAGE_KEY_LEN) {
681
+ return { recorded: false };
682
+ }
683
+ try {
684
+ // Verify the widget exists + isn't archived. The FK on the
685
+ // events table would catch a non-existent slug, but doing the
686
+ // check here lets the drop-silent path return cleanly without
687
+ // surfacing a SQL constraint error to the storefront response.
688
+ var w = await _getWidgetRow(input.widget_slug);
689
+ if (!w || w.archived_at != null) return { recorded: false };
690
+
691
+ var id = _b().uuid.v7();
692
+ await query(
693
+ "INSERT INTO sidebar_widget_events (id, widget_slug, page_key, event_kind, occurred_at) " +
694
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
695
+ [id, input.widget_slug, input.page_key, kind, _now()],
696
+ );
697
+ return { recorded: true, event_id: id };
698
+ } catch (_e) {
699
+ // Drop-silent — by design. The storefront response must not
700
+ // observe an exception from a hot-path observability sink.
701
+ return { recorded: false };
702
+ }
703
+ }
704
+
705
+ async function recordImpression(input) { return _recordEvent("impression", input); }
706
+ async function recordClick(input) { return _recordEvent("click", input); }
707
+
708
+ // -- metricsForWidget --------------------------------------------------
709
+
710
+ async function metricsForWidget(input) {
711
+ if (!input || typeof input !== "object") {
712
+ throw new TypeError("sidebarWidgets.metricsForWidget: input object required");
713
+ }
714
+ var slug = _slug(input.slug, "slug");
715
+ var from = _epochMsOpt(input.from, "from");
716
+ var to = _epochMsOpt(input.to, "to");
717
+ if (from != null && to != null && from > to) {
718
+ throw new TypeError("sidebarWidgets.metricsForWidget: from must be <= to");
719
+ }
720
+
721
+ var widget = await _getWidgetRow(slug);
722
+ if (!widget) return null;
723
+
724
+ var sql = "SELECT event_kind, page_key, COUNT(*) AS n FROM sidebar_widget_events " +
725
+ "WHERE widget_slug = ?1";
726
+ var params = [slug];
727
+ var idx = 2;
728
+ if (from != null) { sql += " AND occurred_at >= ?" + idx; params.push(from); idx += 1; }
729
+ if (to != null) { sql += " AND occurred_at <= ?" + idx; params.push(to); idx += 1; }
730
+ sql += " GROUP BY event_kind, page_key";
731
+ var rows = (await query(sql, params)).rows;
732
+
733
+ var impressions = 0;
734
+ var clicks = 0;
735
+ var byPage = Object.create(null);
736
+ for (var i = 0; i < rows.length; i += 1) {
737
+ var row = rows[i];
738
+ var n = Number(row.n);
739
+ if (!byPage[row.page_key]) byPage[row.page_key] = { impressions: 0, clicks: 0 };
740
+ if (row.event_kind === "impression") {
741
+ impressions += n;
742
+ byPage[row.page_key].impressions += n;
743
+ } else if (row.event_kind === "click") {
744
+ clicks += n;
745
+ byPage[row.page_key].clicks += n;
746
+ }
747
+ }
748
+
749
+ // CTR is reported as a 4-decimal-place ratio (e.g. 0.0312 = 3.12%).
750
+ // Empty-impression case returns 0 so the operator can distinguish
751
+ // "no impressions yet" from "no clicks despite impressions" via
752
+ // the impressions field; same convention as customerSurveys'
753
+ // empty-response rollup.
754
+ var ctr = impressions === 0 ? 0 : Math.round((clicks / impressions) * 10000) / 10000;
755
+
756
+ // Per-page CTR computed the same way, attached to each page entry
757
+ // so the operator dashboard can sort by per-page conversion.
758
+ var byPageList = Object.keys(byPage).sort().map(function (p) {
759
+ var entry = byPage[p];
760
+ var pCtr = entry.impressions === 0 ? 0
761
+ : Math.round((entry.clicks / entry.impressions) * 10000) / 10000;
762
+ return { page_key: p, impressions: entry.impressions, clicks: entry.clicks, ctr: pCtr };
763
+ });
764
+
765
+ return {
766
+ slug: slug,
767
+ kind: widget.kind,
768
+ from: from,
769
+ to: to,
770
+ impressions: impressions,
771
+ clicks: clicks,
772
+ ctr: ctr,
773
+ by_page: byPageList,
774
+ };
775
+ }
776
+
777
+ // -- listWidgets -------------------------------------------------------
778
+
779
+ async function listWidgets(listOpts) {
780
+ listOpts = listOpts || {};
781
+ var kindFilter = null;
782
+ if (listOpts.kind != null) {
783
+ kindFilter = _kind(listOpts.kind);
784
+ }
785
+ var audienceFilter = null;
786
+ if (listOpts.audience != null) {
787
+ audienceFilter = _audience(listOpts.audience);
788
+ }
789
+ var includeArchived = false;
790
+ if (listOpts.include_archived != null) {
791
+ if (typeof listOpts.include_archived !== "boolean") {
792
+ throw new TypeError("sidebarWidgets.listWidgets: include_archived must be a boolean");
793
+ }
794
+ includeArchived = listOpts.include_archived;
795
+ }
796
+ var limit = _limit(listOpts.limit, "limit");
797
+
798
+ var clauses = [];
799
+ var params = [];
800
+ var idx = 1;
801
+ if (kindFilter != null) { clauses.push("kind = ?" + idx); params.push(kindFilter); idx += 1; }
802
+ if (audienceFilter != null) { clauses.push("audience = ?" + idx); params.push(audienceFilter); idx += 1; }
803
+ if (!includeArchived) { clauses.push("archived_at IS NULL"); }
804
+
805
+ var sql = "SELECT * FROM sidebar_widgets";
806
+ if (clauses.length) sql += " WHERE " + clauses.join(" AND ");
807
+ sql += " ORDER BY priority DESC, created_at ASC, slug ASC LIMIT ?" + idx;
808
+ params.push(limit);
809
+
810
+ var rows = (await query(sql, params)).rows;
811
+ var out = [];
812
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateWidget(rows[i]));
813
+ return out;
814
+ }
815
+
816
+ // -- updateWidget ------------------------------------------------------
817
+
818
+ async function updateWidget(slug, patch) {
819
+ _slug(slug);
820
+ if (!patch || typeof patch !== "object") {
821
+ throw new TypeError("sidebarWidgets.updateWidget: patch object required");
822
+ }
823
+ var keys = Object.keys(patch);
824
+ if (!keys.length) {
825
+ throw new TypeError("sidebarWidgets.updateWidget: patch must include at least one column");
826
+ }
827
+
828
+ var current = await _getWidgetRow(slug);
829
+ if (!current) {
830
+ throw new TypeError("sidebarWidgets.updateWidget: slug " + JSON.stringify(slug) + " not found");
831
+ }
832
+ if (current.archived_at != null) {
833
+ throw new TypeError("sidebarWidgets.updateWidget: widget " + JSON.stringify(slug) + " is archived");
834
+ }
835
+
836
+ var sets = [];
837
+ var params = [];
838
+ var idx = 1;
839
+ var postAudience = current.audience;
840
+ var postSegmentSlug = current.segment_slug;
841
+ var postStartsAt = Number(current.starts_at);
842
+ var postExpiresAt = Number(current.expires_at);
843
+
844
+ for (var i = 0; i < keys.length; i += 1) {
845
+ var col = keys[i];
846
+ if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
847
+ throw new TypeError("sidebarWidgets.updateWidget: unsupported column " + JSON.stringify(col));
848
+ }
849
+ var v;
850
+ var dbCol = col;
851
+ if (col === "title") {
852
+ v = _title(patch[col]);
853
+ } else if (col === "payload") {
854
+ v = JSON.stringify(_payloadFor(current.kind, patch[col]));
855
+ dbCol = "payload_json";
856
+ } else if (col === "audience") {
857
+ v = _audience(patch[col]); postAudience = v;
858
+ } else if (col === "segment_slug") {
859
+ v = patch[col] == null ? null : _segmentSlug(patch[col]); postSegmentSlug = v;
860
+ } else if (col === "priority") {
861
+ v = _priority(patch[col]);
862
+ } else if (col === "starts_at") {
863
+ v = _epochMs(patch[col], "starts_at"); postStartsAt = v;
864
+ } else /* expires_at */ {
865
+ v = _epochMs(patch[col], "expires_at"); postExpiresAt = v;
866
+ }
867
+ sets.push(dbCol + " = ?" + idx);
868
+ params.push(v);
869
+ idx += 1;
870
+ }
871
+
872
+ if (postAudience === "segment" && postSegmentSlug == null) {
873
+ throw new TypeError("sidebarWidgets.updateWidget: audience=\"segment\" requires segment_slug");
874
+ }
875
+ if (postAudience !== "segment" && postSegmentSlug != null) {
876
+ throw new TypeError("sidebarWidgets.updateWidget: segment_slug only valid when audience=\"segment\"");
877
+ }
878
+ if (postExpiresAt <= postStartsAt) {
879
+ throw new TypeError("sidebarWidgets.updateWidget: expires_at must be strictly greater than starts_at");
880
+ }
881
+
882
+ sets.push("updated_at = ?" + idx);
883
+ params.push(_now());
884
+ idx += 1;
885
+ params.push(slug);
886
+
887
+ await query(
888
+ "UPDATE sidebar_widgets SET " + sets.join(", ") + " WHERE slug = ?" + idx,
889
+ params,
890
+ );
891
+ return _hydrateWidget(await _getWidgetRow(slug));
892
+ }
893
+
894
+ // -- archiveWidget -----------------------------------------------------
895
+
896
+ async function archiveWidget(slug) {
897
+ _slug(slug);
898
+ var ts = _now();
899
+ var r = await query(
900
+ "UPDATE sidebar_widgets SET archived_at = ?1, updated_at = ?1 " +
901
+ "WHERE slug = ?2 AND archived_at IS NULL",
902
+ [ts, slug],
903
+ );
904
+ if (Number(r.rowCount || 0) === 0) {
905
+ var existing = await _getWidgetRow(slug);
906
+ if (!existing) {
907
+ throw new TypeError("sidebarWidgets.archiveWidget: slug " + JSON.stringify(slug) + " not found");
908
+ }
909
+ // Already archived — idempotent return so an operator sweep
910
+ // doesn't have to special-case widgets a coworker archived
911
+ // first. Sibling pattern with promoBanners.archive.
912
+ return _hydrateWidget(existing);
913
+ }
914
+ return _hydrateWidget(await _getWidgetRow(slug));
915
+ }
916
+
917
+ return {
918
+ KINDS: KINDS.slice(),
919
+ AUDIENCES: AUDIENCES.slice(),
920
+ VIEWER_KINDS: VIEWER_KINDS.slice(),
921
+ EVENT_KINDS: EVENT_KINDS.slice(),
922
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
923
+ MAX_PAGE_KEY_LEN: MAX_PAGE_KEY_LEN,
924
+ MAX_WIDGETS_PER_PAGE: MAX_WIDGETS_PER_PAGE,
925
+ MAX_LIMIT: MAX_LIMIT,
926
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
927
+
928
+ defineWidget: defineWidget,
929
+ getWidget: getWidget,
930
+ setPagePlacement: setPagePlacement,
931
+ widgetsForPage: widgetsForPage,
932
+ recordImpression: recordImpression,
933
+ recordClick: recordClick,
934
+ metricsForWidget: metricsForWidget,
935
+ listWidgets: listWidgets,
936
+ updateWidget: updateWidget,
937
+ archiveWidget: archiveWidget,
938
+ };
939
+ }
940
+
941
+ module.exports = {
942
+ create: create,
943
+ KINDS: KINDS,
944
+ AUDIENCES: AUDIENCES,
945
+ VIEWER_KINDS: VIEWER_KINDS,
946
+ EVENT_KINDS: EVENT_KINDS,
947
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
948
+ MAX_PAGE_KEY_LEN: MAX_PAGE_KEY_LEN,
949
+ MAX_WIDGETS_PER_PAGE: MAX_WIDGETS_PER_PAGE,
950
+ MAX_LIMIT: MAX_LIMIT,
951
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
952
+ };