@blamejs/blamejs-shop 0.0.57 → 0.0.59

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.
@@ -0,0 +1,726 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.promoBanners
4
+ * @title Promo banners — operator-controlled marketing across the storefront
5
+ *
6
+ * @intro
7
+ * Operator-authored marketing banners that render at six fixed
8
+ * placements across the storefront:
9
+ *
10
+ * top_strip — site-wide announcement strip above the nav.
11
+ * homepage_hero — large hero block on the homepage.
12
+ * pdp_side — narrow sidebar on the product-detail page.
13
+ * cart_side — adjacent to the cart-summary panel.
14
+ * search_empty — rendered in place of "no results" copy.
15
+ * footer — slim band above the footer links.
16
+ *
17
+ * Each banner carries a schedule window (starts_at / expires_at),
18
+ * an audience filter (everyone / logged-in / guest / a named
19
+ * customer segment), a priority (higher wins when multiple banners
20
+ * overlap), a theme token, and a CTA URL. `activeForPlacement`
21
+ * resolves the single banner that wins for a given placement +
22
+ * viewer + customer at a given instant.
23
+ *
24
+ * Composes:
25
+ * - `b.safeUrl.parse` — `cta_url` is HTTPS-only at the app
26
+ * layer (or a /-rooted absolute path so the operator can link
27
+ * to a storefront-internal route without ceremony). javascript:
28
+ * and data: URLs are refused before the banner is persisted.
29
+ * - `b.template.escapeHtml` — `renderHtml` escapes every
30
+ * operator-input field so a hostile headline / body / label
31
+ * lands as inert text in the storefront HTML.
32
+ *
33
+ * Surface:
34
+ * - `defineBanner({ slug, placement, headline, body?, cta_label,
35
+ * cta_url, image_url?, audience, segment_slug?,
36
+ * priority, starts_at, expires_at, theme? })`
37
+ * - `activeForPlacement({ placement, viewer_kind, customer_id?,
38
+ * now })` — highest-priority active
39
+ * banner for the placement, filtered by audience + segment.
40
+ * - `listAll({ active_only? })` / `getBanner(slug)` /
41
+ * `updateBanner(slug, patch)` / `archive(slug)` /
42
+ * `unarchive(slug)`.
43
+ * - `renderHtml({ banner, locale? })` — sanitized HTML string
44
+ * ready for inline insertion into a storefront template.
45
+ * - `impressionCount(slug)` / `clickCount(slug)` — read counters.
46
+ * - `recordImpression(slug)` / `recordClick(slug)` — increment.
47
+ * Drop-silent on unknown slug (these run on the hot request
48
+ * path; throwing here would crash the storefront response that
49
+ * triggered the counter).
50
+ *
51
+ * Storage:
52
+ * - `promo_banners` (migration `0053_promo_banners.sql`).
53
+ *
54
+ * @primitive promoBanners
55
+ * @related b.safeUrl, b.template.escapeHtml
56
+ */
57
+
58
+ var MAX_SLUG_LEN = 80;
59
+ var MAX_HEADLINE_LEN = 200;
60
+ var MAX_BODY_LEN = 1000;
61
+ var MAX_CTA_LABEL_LEN = 80;
62
+ var MAX_CTA_URL_LEN = 2048;
63
+ var MAX_IMAGE_URL_LEN = 2048;
64
+ var MAX_SEGMENT_LEN = 80;
65
+
66
+ var ALLOWED_PLACEMENTS = Object.freeze([
67
+ "top_strip",
68
+ "homepage_hero",
69
+ "pdp_side",
70
+ "cart_side",
71
+ "search_empty",
72
+ "footer",
73
+ ]);
74
+
75
+ var ALLOWED_AUDIENCES = Object.freeze([
76
+ "all",
77
+ "logged_in",
78
+ "guest",
79
+ "segment",
80
+ ]);
81
+
82
+ var ALLOWED_THEMES = Object.freeze([
83
+ "info",
84
+ "promo",
85
+ "urgency",
86
+ "success",
87
+ ]);
88
+
89
+ var ALLOWED_VIEWER_KINDS = Object.freeze([
90
+ "logged_in",
91
+ "guest",
92
+ ]);
93
+
94
+ // Slug shape mirrors the catalog primitive's convention: alnum +
95
+ // hyphen + underscore, leading char alnum, capped length. The slug
96
+ // reaches operator-facing logs + URLs in the admin dashboard, so the
97
+ // shape stays narrow.
98
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
99
+
100
+ // Refuse C0 control bytes + DEL in operator-authored strings — these
101
+ // render onto an HTML storefront response and (transitively) into
102
+ // operator dashboards. Newlines are allowed in `body` (multi-line
103
+ // marketing copy is common); `headline` / `cta_label` refuse LF / CR
104
+ // so a single-line presentation slot can't be smuggled into.
105
+ var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
106
+ var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
107
+
108
+ // Zero-width / direction-override family — mirrors the gift-options
109
+ // + order-notes catalogues. Spelled with \u-escapes so ESLint's
110
+ // no-irregular-whitespace stays happy.
111
+ var ZERO_WIDTH_RE = new RegExp(
112
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
113
+ );
114
+
115
+ var ALLOWED_PATCH_COLUMNS = Object.freeze([
116
+ "placement",
117
+ "headline",
118
+ "body",
119
+ "cta_label",
120
+ "cta_url",
121
+ "image_url",
122
+ "audience",
123
+ "segment_slug",
124
+ "priority",
125
+ "theme",
126
+ "starts_at",
127
+ "expires_at",
128
+ ]);
129
+
130
+ var bShop;
131
+ function _b() {
132
+ if (!bShop) bShop = require("./index");
133
+ return bShop.framework;
134
+ }
135
+
136
+ // ---- validators ---------------------------------------------------------
137
+
138
+ function _slug(s) {
139
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
140
+ throw new TypeError("promoBanners: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_SLUG_LEN + " chars)");
141
+ }
142
+ return s;
143
+ }
144
+
145
+ function _placement(s) {
146
+ if (typeof s !== "string" || ALLOWED_PLACEMENTS.indexOf(s) === -1) {
147
+ throw new TypeError("promoBanners: placement must be one of " + JSON.stringify(ALLOWED_PLACEMENTS));
148
+ }
149
+ return s;
150
+ }
151
+
152
+ function _audience(s) {
153
+ if (typeof s !== "string" || ALLOWED_AUDIENCES.indexOf(s) === -1) {
154
+ throw new TypeError("promoBanners: audience must be one of " + JSON.stringify(ALLOWED_AUDIENCES));
155
+ }
156
+ return s;
157
+ }
158
+
159
+ function _theme(s) {
160
+ if (s == null) return "info";
161
+ if (typeof s !== "string" || ALLOWED_THEMES.indexOf(s) === -1) {
162
+ throw new TypeError("promoBanners: theme must be one of " + JSON.stringify(ALLOWED_THEMES));
163
+ }
164
+ return s;
165
+ }
166
+
167
+ function _line(s, label, maxLen) {
168
+ if (typeof s !== "string" || !s.length || s.length > maxLen) {
169
+ throw new TypeError("promoBanners: " + label + " must be a non-empty string ≤ " + maxLen + " chars");
170
+ }
171
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
172
+ throw new TypeError("promoBanners: " + label + " contains control bytes (incl. CR/LF)");
173
+ }
174
+ if (ZERO_WIDTH_RE.test(s)) {
175
+ throw new TypeError("promoBanners: " + label + " contains zero-width / direction-override characters");
176
+ }
177
+ return s;
178
+ }
179
+
180
+ function _block(s, label, maxLen) {
181
+ if (s == null) return null;
182
+ if (typeof s !== "string") {
183
+ throw new TypeError("promoBanners: " + label + " must be a string");
184
+ }
185
+ if (s.length > maxLen) {
186
+ throw new TypeError("promoBanners: " + label + " must be ≤ " + maxLen + " chars");
187
+ }
188
+ if (CONTROL_BYTE_BLOCK_RE.test(s)) {
189
+ throw new TypeError("promoBanners: " + label + " contains control bytes");
190
+ }
191
+ if (ZERO_WIDTH_RE.test(s)) {
192
+ throw new TypeError("promoBanners: " + label + " contains zero-width / direction-override characters");
193
+ }
194
+ return s;
195
+ }
196
+
197
+ // Validate a CTA URL through `b.safeUrl.parse` (https-only) OR
198
+ // accept a /-rooted absolute path. The CTA URL lands into an <a
199
+ // href="..."> on the storefront; javascript: / data: / vbscript: are
200
+ // all refused by safeUrl's default protocol allowlist. Protocol-
201
+ // relative `//host/...` URLs are refused — the operator must commit
202
+ // to https explicitly so a CDN mis-config can't downgrade the link.
203
+ function _ctaUrl(s) {
204
+ if (typeof s !== "string" || !s.length || s.length > MAX_CTA_URL_LEN) {
205
+ throw new TypeError("promoBanners: cta_url must be a non-empty string ≤ " + MAX_CTA_URL_LEN + " chars");
206
+ }
207
+ if (CONTROL_BYTE_LINE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
208
+ throw new TypeError("promoBanners: cta_url contains control / zero-width bytes");
209
+ }
210
+ if (s.charCodeAt(0) === 47 /* "/" */) {
211
+ // /-rooted absolute path — refuse protocol-relative `//host`
212
+ // (the second char being "/" turns the link into an off-site
213
+ // URL on the same scheme, which sidesteps the https-only gate).
214
+ if (s.length > 1 && s.charCodeAt(1) === 47) {
215
+ throw new TypeError("promoBanners: cta_url protocol-relative `//host/...` refused — use absolute https://");
216
+ }
217
+ if (s.indexOf("..") !== -1) {
218
+ throw new TypeError("promoBanners: cta_url path must not contain '..'");
219
+ }
220
+ return s;
221
+ }
222
+ try {
223
+ _b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
224
+ } catch (e) {
225
+ throw new TypeError("promoBanners: cta_url — " + (e && e.message || "must be https:// or a /-rooted absolute path"));
226
+ }
227
+ return s;
228
+ }
229
+
230
+ // Image URL discipline mirrors `cta_url` — https-only or /-rooted.
231
+ // Optional (banners may carry text only).
232
+ function _imageUrl(s) {
233
+ if (s == null) return null;
234
+ if (typeof s !== "string" || !s.length || s.length > MAX_IMAGE_URL_LEN) {
235
+ throw new TypeError("promoBanners: image_url must be a non-empty string ≤ " + MAX_IMAGE_URL_LEN + " chars");
236
+ }
237
+ if (CONTROL_BYTE_LINE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
238
+ throw new TypeError("promoBanners: image_url contains control / zero-width bytes");
239
+ }
240
+ if (s.charCodeAt(0) === 47 /* "/" */) {
241
+ if (s.length > 1 && s.charCodeAt(1) === 47) {
242
+ throw new TypeError("promoBanners: image_url protocol-relative `//host/...` refused — use absolute https://");
243
+ }
244
+ if (s.indexOf("..") !== -1) {
245
+ throw new TypeError("promoBanners: image_url path must not contain '..'");
246
+ }
247
+ return s;
248
+ }
249
+ try {
250
+ _b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
251
+ } catch (e) {
252
+ throw new TypeError("promoBanners: image_url — " + (e && e.message || "must be https:// or a /-rooted absolute path"));
253
+ }
254
+ return s;
255
+ }
256
+
257
+ function _priority(n) {
258
+ if (!Number.isInteger(n) || n < 0 || n > 1000000) {
259
+ throw new TypeError("promoBanners: priority must be an integer in [0, 1000000]");
260
+ }
261
+ return n;
262
+ }
263
+
264
+ function _epochMs(n, label) {
265
+ if (!Number.isInteger(n) || n < 0) {
266
+ throw new TypeError("promoBanners: " + label + " must be a non-negative integer (epoch ms)");
267
+ }
268
+ return n;
269
+ }
270
+
271
+ function _segmentSlug(s) {
272
+ if (s == null) return null;
273
+ if (typeof s !== "string" || !s.length || s.length > MAX_SEGMENT_LEN) {
274
+ throw new TypeError("promoBanners: segment_slug must be a non-empty string ≤ " + MAX_SEGMENT_LEN + " chars");
275
+ }
276
+ if (!SLUG_RE.test(s)) {
277
+ throw new TypeError("promoBanners: segment_slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/");
278
+ }
279
+ return s;
280
+ }
281
+
282
+ function _viewerKind(s) {
283
+ if (typeof s !== "string" || ALLOWED_VIEWER_KINDS.indexOf(s) === -1) {
284
+ throw new TypeError("promoBanners: viewer_kind must be one of " + JSON.stringify(ALLOWED_VIEWER_KINDS));
285
+ }
286
+ return s;
287
+ }
288
+
289
+ function _now() { return Date.now(); }
290
+
291
+ // ---- row hydration ------------------------------------------------------
292
+
293
+ function _hydrateRow(r) {
294
+ if (!r) return null;
295
+ return {
296
+ slug: r.slug,
297
+ placement: r.placement,
298
+ headline: r.headline,
299
+ body: r.body,
300
+ cta_label: r.cta_label,
301
+ cta_url: r.cta_url,
302
+ image_url: r.image_url,
303
+ audience: r.audience,
304
+ segment_slug: r.segment_slug,
305
+ priority: Number(r.priority),
306
+ theme: r.theme,
307
+ starts_at: Number(r.starts_at),
308
+ expires_at: Number(r.expires_at),
309
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
310
+ impression_count: Number(r.impression_count),
311
+ click_count: Number(r.click_count),
312
+ created_at: Number(r.created_at),
313
+ updated_at: Number(r.updated_at),
314
+ };
315
+ }
316
+
317
+ // ---- factory ------------------------------------------------------------
318
+
319
+ function create(opts) {
320
+ opts = opts || {};
321
+ var query = opts.query;
322
+ if (!query) {
323
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
324
+ }
325
+ // customerSegments is optional — banners with audience = "segment"
326
+ // require it, but a deployment without segment-targeted banners can
327
+ // run without one. The factory captures the handle and the
328
+ // `activeForPlacement` path enforces the requirement lazily, with
329
+ // a clear error message when a segment-audience banner is reached
330
+ // without a segments handle wired up.
331
+ var customerSegments = opts.customerSegments || null;
332
+
333
+ // -- defineBanner ------------------------------------------------------
334
+
335
+ async function defineBanner(input) {
336
+ if (!input || typeof input !== "object") {
337
+ throw new TypeError("promoBanners.defineBanner: input object required");
338
+ }
339
+ var slug = _slug(input.slug);
340
+ var placement = _placement(input.placement);
341
+ var headline = _line(input.headline, "headline", MAX_HEADLINE_LEN);
342
+ var body = _block(input.body, "body", MAX_BODY_LEN);
343
+ var ctaLabel = _line(input.cta_label, "cta_label", MAX_CTA_LABEL_LEN);
344
+ var ctaUrl = _ctaUrl(input.cta_url);
345
+ var imageUrl = _imageUrl(input.image_url);
346
+ var audience = _audience(input.audience);
347
+ var segmentSlug;
348
+ if (audience === "segment") {
349
+ if (input.segment_slug == null) {
350
+ throw new TypeError("promoBanners.defineBanner: audience=\"segment\" requires segment_slug");
351
+ }
352
+ segmentSlug = _segmentSlug(input.segment_slug);
353
+ } else {
354
+ if (input.segment_slug != null) {
355
+ throw new TypeError("promoBanners.defineBanner: segment_slug only valid when audience=\"segment\"");
356
+ }
357
+ segmentSlug = null;
358
+ }
359
+ var priority = _priority(input.priority);
360
+ var theme = _theme(input.theme);
361
+ var startsAt = _epochMs(input.starts_at, "starts_at");
362
+ var expiresAt = _epochMs(input.expires_at, "expires_at");
363
+ if (expiresAt <= startsAt) {
364
+ throw new TypeError("promoBanners.defineBanner: expires_at must be strictly greater than starts_at");
365
+ }
366
+
367
+ var ts = _now();
368
+ await query(
369
+ "INSERT INTO promo_banners (slug, placement, headline, body, cta_label, cta_url, image_url, " +
370
+ "audience, segment_slug, priority, theme, starts_at, expires_at, archived_at, " +
371
+ "impression_count, click_count, created_at, updated_at) " +
372
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, NULL, 0, 0, ?14, ?14)",
373
+ [slug, placement, headline, body, ctaLabel, ctaUrl, imageUrl,
374
+ audience, segmentSlug, priority, theme, startsAt, expiresAt, ts],
375
+ );
376
+ return await getBanner(slug);
377
+ }
378
+
379
+ // -- listAll / getBanner ----------------------------------------------
380
+
381
+ async function listAll(listOpts) {
382
+ listOpts = listOpts || {};
383
+ var activeOnly = false;
384
+ if (listOpts.active_only != null) {
385
+ if (typeof listOpts.active_only !== "boolean") {
386
+ throw new TypeError("promoBanners.listAll: active_only must be a boolean");
387
+ }
388
+ activeOnly = listOpts.active_only;
389
+ }
390
+ var sql, params;
391
+ if (activeOnly) {
392
+ var nowTs = _now();
393
+ sql = "SELECT * FROM promo_banners WHERE archived_at IS NULL AND starts_at <= ?1 AND expires_at > ?1 " +
394
+ "ORDER BY priority DESC, created_at ASC, slug ASC";
395
+ params = [nowTs];
396
+ } else {
397
+ sql = "SELECT * FROM promo_banners ORDER BY created_at ASC, slug ASC";
398
+ params = [];
399
+ }
400
+ var rows = (await query(sql, params)).rows;
401
+ return rows.map(_hydrateRow);
402
+ }
403
+
404
+ async function getBanner(slug) {
405
+ _slug(slug);
406
+ var r = (await query(
407
+ "SELECT * FROM promo_banners WHERE slug = ?1 LIMIT 1",
408
+ [slug],
409
+ )).rows[0];
410
+ return _hydrateRow(r);
411
+ }
412
+
413
+ // -- updateBanner ------------------------------------------------------
414
+
415
+ async function updateBanner(slug, patch) {
416
+ _slug(slug);
417
+ if (!patch || typeof patch !== "object") {
418
+ throw new TypeError("promoBanners.updateBanner: patch object required");
419
+ }
420
+ var keys = Object.keys(patch);
421
+ if (!keys.length) {
422
+ throw new TypeError("promoBanners.updateBanner: patch must include at least one column");
423
+ }
424
+ // Resolve the existing row first so cross-column invariants
425
+ // (audience ↔ segment_slug, starts_at < expires_at) can be
426
+ // checked against the post-patch state without re-reading mid-
427
+ // UPDATE.
428
+ var current = await getBanner(slug);
429
+ if (!current) {
430
+ throw new TypeError("promoBanners.updateBanner: slug " + JSON.stringify(slug) + " not found");
431
+ }
432
+
433
+ var sets = [];
434
+ var params = [];
435
+ var idx = 1;
436
+ var postAudience = current.audience;
437
+ var postSegmentSlug = current.segment_slug;
438
+ var postStartsAt = current.starts_at;
439
+ var postExpiresAt = current.expires_at;
440
+
441
+ for (var i = 0; i < keys.length; i += 1) {
442
+ var col = keys[i];
443
+ if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
444
+ throw new TypeError("promoBanners.updateBanner: unsupported column " + JSON.stringify(col));
445
+ }
446
+ var v;
447
+ if (col === "placement") { v = _placement(patch[col]); }
448
+ else if (col === "headline") { v = _line(patch[col], "headline", MAX_HEADLINE_LEN); }
449
+ else if (col === "body") { v = _block(patch[col], "body", MAX_BODY_LEN); }
450
+ else if (col === "cta_label") { v = _line(patch[col], "cta_label", MAX_CTA_LABEL_LEN); }
451
+ else if (col === "cta_url") { v = _ctaUrl(patch[col]); }
452
+ else if (col === "image_url") { v = _imageUrl(patch[col]); }
453
+ else if (col === "audience") { v = _audience(patch[col]); postAudience = v; }
454
+ else if (col === "segment_slug") { v = patch[col] == null ? null : _segmentSlug(patch[col]); postSegmentSlug = v; }
455
+ else if (col === "priority") { v = _priority(patch[col]); }
456
+ else if (col === "theme") { v = _theme(patch[col]); }
457
+ else if (col === "starts_at") { v = _epochMs(patch[col], "starts_at"); postStartsAt = v; }
458
+ else /* expires_at */ { v = _epochMs(patch[col], "expires_at"); postExpiresAt = v; }
459
+ sets.push(col + " = ?" + idx);
460
+ params.push(v);
461
+ idx += 1;
462
+ }
463
+
464
+ if (postAudience === "segment" && postSegmentSlug == null) {
465
+ throw new TypeError("promoBanners.updateBanner: audience=\"segment\" requires segment_slug");
466
+ }
467
+ if (postAudience !== "segment" && postSegmentSlug != null) {
468
+ throw new TypeError("promoBanners.updateBanner: segment_slug only valid when audience=\"segment\"");
469
+ }
470
+ if (postExpiresAt <= postStartsAt) {
471
+ throw new TypeError("promoBanners.updateBanner: expires_at must be strictly greater than starts_at");
472
+ }
473
+
474
+ sets.push("updated_at = ?" + idx);
475
+ params.push(_now());
476
+ idx += 1;
477
+ params.push(slug);
478
+
479
+ var r = await query(
480
+ "UPDATE promo_banners SET " + sets.join(", ") + " WHERE slug = ?" + idx,
481
+ params,
482
+ );
483
+ if (Number(r.rowCount || 0) === 0) {
484
+ throw new TypeError("promoBanners.updateBanner: slug " + JSON.stringify(slug) + " not found");
485
+ }
486
+ return await getBanner(slug);
487
+ }
488
+
489
+ // -- archive / unarchive ----------------------------------------------
490
+
491
+ async function archive(slug) {
492
+ _slug(slug);
493
+ var ts = _now();
494
+ var r = await query(
495
+ "UPDATE promo_banners SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2 AND archived_at IS NULL",
496
+ [ts, slug],
497
+ );
498
+ if (Number(r.rowCount || 0) === 0) {
499
+ var existing = await getBanner(slug);
500
+ if (!existing) {
501
+ throw new TypeError("promoBanners.archive: slug " + JSON.stringify(slug) + " not found");
502
+ }
503
+ // Already archived — return the existing row idempotently so
504
+ // operators running an "archive everything" sweep don't have
505
+ // to special-case banners that a coworker archived first.
506
+ return existing;
507
+ }
508
+ return await getBanner(slug);
509
+ }
510
+
511
+ async function unarchive(slug) {
512
+ _slug(slug);
513
+ var ts = _now();
514
+ var r = await query(
515
+ "UPDATE promo_banners SET archived_at = NULL, updated_at = ?1 WHERE slug = ?2 AND archived_at IS NOT NULL",
516
+ [ts, slug],
517
+ );
518
+ if (Number(r.rowCount || 0) === 0) {
519
+ var existing = await getBanner(slug);
520
+ if (!existing) {
521
+ throw new TypeError("promoBanners.unarchive: slug " + JSON.stringify(slug) + " not found");
522
+ }
523
+ return existing;
524
+ }
525
+ return await getBanner(slug);
526
+ }
527
+
528
+ // -- activeForPlacement ------------------------------------------------
529
+
530
+ async function activeForPlacement(input) {
531
+ if (!input || typeof input !== "object") {
532
+ throw new TypeError("promoBanners.activeForPlacement: input object required");
533
+ }
534
+ var placement = _placement(input.placement);
535
+ var viewerKind = _viewerKind(input.viewer_kind);
536
+ var nowTs = _epochMs(input.now, "now");
537
+ var customerId = null;
538
+ if (input.customer_id != null) {
539
+ if (typeof input.customer_id !== "string" || !input.customer_id.length) {
540
+ throw new TypeError("promoBanners.activeForPlacement: customer_id must be a non-empty string when provided");
541
+ }
542
+ customerId = input.customer_id;
543
+ }
544
+ if (viewerKind === "logged_in" && customerId == null) {
545
+ throw new TypeError("promoBanners.activeForPlacement: viewer_kind=\"logged_in\" requires customer_id");
546
+ }
547
+ if (viewerKind === "guest" && customerId != null) {
548
+ throw new TypeError("promoBanners.activeForPlacement: viewer_kind=\"guest\" must not carry customer_id");
549
+ }
550
+
551
+ // Read every candidate row for the placement (active window +
552
+ // not archived), sorted by priority DESC. The audience +
553
+ // segment filter is JS-side because the segment-membership
554
+ // check is an external handle call, not a SQL join.
555
+ var rows = (await query(
556
+ "SELECT * FROM promo_banners " +
557
+ "WHERE placement = ?1 AND archived_at IS NULL AND starts_at <= ?2 AND expires_at > ?2 " +
558
+ "ORDER BY priority DESC, created_at ASC, slug ASC",
559
+ [placement, nowTs],
560
+ )).rows;
561
+
562
+ for (var i = 0; i < rows.length; i += 1) {
563
+ var b = _hydrateRow(rows[i]);
564
+ if (b.audience === "all") return b;
565
+ if (b.audience === "logged_in" && viewerKind === "logged_in") return b;
566
+ if (b.audience === "guest" && viewerKind === "guest") return b;
567
+ if (b.audience === "segment") {
568
+ if (viewerKind !== "logged_in") continue;
569
+ if (!customerSegments) {
570
+ throw new TypeError("promoBanners.activeForPlacement: banner " + JSON.stringify(b.slug) +
571
+ " has audience=\"segment\" but no customerSegments handle was wired into create()");
572
+ }
573
+ if (typeof customerSegments.isMember !== "function") {
574
+ throw new TypeError("promoBanners.activeForPlacement: customerSegments handle must expose isMember(customer_id, segment_slug)");
575
+ }
576
+ var member = await customerSegments.isMember(customerId, b.segment_slug);
577
+ if (member) return b;
578
+ continue;
579
+ }
580
+ }
581
+ return null;
582
+ }
583
+
584
+ // -- renderHtml --------------------------------------------------------
585
+
586
+ function renderHtml(input) {
587
+ if (!input || typeof input !== "object") {
588
+ throw new TypeError("promoBanners.renderHtml: input object required");
589
+ }
590
+ var banner = input.banner;
591
+ if (!banner || typeof banner !== "object") {
592
+ throw new TypeError("promoBanners.renderHtml: banner object required");
593
+ }
594
+ var locale = input.locale;
595
+ if (locale != null) {
596
+ if (typeof locale !== "string" || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(locale)) {
597
+ throw new TypeError("promoBanners.renderHtml: locale must be a BCP-47-shape string (e.g. 'en-US')");
598
+ }
599
+ }
600
+ var escapeHtml = _b().template.escapeHtml;
601
+
602
+ // Theme + placement come from a closed enum; pass them through
603
+ // escapeHtml anyway so any future enum expansion that lands a
604
+ // hyphenated value still renders safely.
605
+ var theme = escapeHtml(banner.theme || "info");
606
+ var placement = escapeHtml(banner.placement);
607
+ var slug = escapeHtml(banner.slug);
608
+ var headline = escapeHtml(banner.headline);
609
+ var ctaLabel = escapeHtml(banner.cta_label);
610
+ var ctaUrl = escapeHtml(banner.cta_url);
611
+ var localeAttr = locale ? ' lang="' + escapeHtml(locale) + '"' : "";
612
+
613
+ var parts = [];
614
+ parts.push('<div class="promo-banner promo-banner--' + theme + ' promo-banner--' + placement +
615
+ '" data-banner-slug="' + slug + '"' + localeAttr + '>');
616
+ if (banner.image_url) {
617
+ parts.push('<img class="promo-banner__image" src="' + escapeHtml(banner.image_url) + '" alt="" />');
618
+ }
619
+ parts.push('<div class="promo-banner__body">');
620
+ parts.push('<h2 class="promo-banner__headline">' + headline + '</h2>');
621
+ if (banner.body) {
622
+ // Split body on LF so multi-line marketing copy renders as
623
+ // one <p> per line. CRLF is normalized to LF; trailing empty
624
+ // lines drop so the output stays tidy.
625
+ var raw = String(banner.body).replace(/\r\n/g, "\n").split("\n");
626
+ while (raw.length && raw[raw.length - 1] === "") raw.pop();
627
+ for (var i = 0; i < raw.length; i += 1) {
628
+ parts.push('<p class="promo-banner__line">' + escapeHtml(raw[i]) + '</p>');
629
+ }
630
+ }
631
+ parts.push('<a class="promo-banner__cta" href="' + ctaUrl + '" data-banner-slug="' + slug + '">' +
632
+ ctaLabel + '</a>');
633
+ parts.push('</div>');
634
+ parts.push('</div>');
635
+ return parts.join("");
636
+ }
637
+
638
+ // -- counters ---------------------------------------------------------
639
+
640
+ async function impressionCount(slug) {
641
+ _slug(slug);
642
+ var r = (await query(
643
+ "SELECT impression_count FROM promo_banners WHERE slug = ?1 LIMIT 1",
644
+ [slug],
645
+ )).rows[0];
646
+ return r ? Number(r.impression_count) : 0;
647
+ }
648
+
649
+ async function clickCount(slug) {
650
+ _slug(slug);
651
+ var r = (await query(
652
+ "SELECT click_count FROM promo_banners WHERE slug = ?1 LIMIT 1",
653
+ [slug],
654
+ )).rows[0];
655
+ return r ? Number(r.click_count) : 0;
656
+ }
657
+
658
+ // Drop-silent on bad slug / missing row — these run on the hot
659
+ // request path (impression on every storefront page render, click
660
+ // on the redirect handler that fires before the customer reaches
661
+ // the CTA destination). Throwing here would crash the response
662
+ // the counter is observing. The validation layer at defineBanner
663
+ // / updateBanner has already verified that legitimate slugs exist;
664
+ // a request that arrives with a stale slug after the banner was
665
+ // archived simply doesn't increment, which is the correct
666
+ // observability behavior.
667
+ async function recordImpression(slug) {
668
+ if (typeof slug !== "string" || !SLUG_RE.test(slug)) return { recorded: false };
669
+ try {
670
+ var r = await query(
671
+ "UPDATE promo_banners SET impression_count = impression_count + 1, updated_at = ?1 WHERE slug = ?2",
672
+ [_now(), slug],
673
+ );
674
+ return { recorded: Number(r.rowCount || 0) > 0 };
675
+ } catch (_e) {
676
+ return { recorded: false };
677
+ }
678
+ }
679
+
680
+ async function recordClick(slug) {
681
+ if (typeof slug !== "string" || !SLUG_RE.test(slug)) return { recorded: false };
682
+ try {
683
+ var r = await query(
684
+ "UPDATE promo_banners SET click_count = click_count + 1, updated_at = ?1 WHERE slug = ?2",
685
+ [_now(), slug],
686
+ );
687
+ return { recorded: Number(r.rowCount || 0) > 0 };
688
+ } catch (_e) {
689
+ return { recorded: false };
690
+ }
691
+ }
692
+
693
+ return {
694
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
695
+ MAX_HEADLINE_LEN: MAX_HEADLINE_LEN,
696
+ MAX_BODY_LEN: MAX_BODY_LEN,
697
+ MAX_CTA_LABEL_LEN: MAX_CTA_LABEL_LEN,
698
+ ALLOWED_PLACEMENTS: ALLOWED_PLACEMENTS,
699
+ ALLOWED_AUDIENCES: ALLOWED_AUDIENCES,
700
+ ALLOWED_THEMES: ALLOWED_THEMES,
701
+
702
+ defineBanner: defineBanner,
703
+ activeForPlacement: activeForPlacement,
704
+ listAll: listAll,
705
+ getBanner: getBanner,
706
+ updateBanner: updateBanner,
707
+ archive: archive,
708
+ unarchive: unarchive,
709
+ renderHtml: renderHtml,
710
+ impressionCount: impressionCount,
711
+ clickCount: clickCount,
712
+ recordImpression: recordImpression,
713
+ recordClick: recordClick,
714
+ };
715
+ }
716
+
717
+ module.exports = {
718
+ create: create,
719
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
720
+ MAX_HEADLINE_LEN: MAX_HEADLINE_LEN,
721
+ MAX_BODY_LEN: MAX_BODY_LEN,
722
+ MAX_CTA_LABEL_LEN: MAX_CTA_LABEL_LEN,
723
+ ALLOWED_PLACEMENTS: ALLOWED_PLACEMENTS,
724
+ ALLOWED_AUDIENCES: ALLOWED_AUDIENCES,
725
+ ALLOWED_THEMES: ALLOWED_THEMES,
726
+ };