@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.
- package/CHANGELOG.md +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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
|
+
};
|