@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,753 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.announcementBar
|
|
4
|
+
* @title Announcement bar — operator-controlled storefront top strip
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* The single thin row of text rendered above the storefront
|
|
8
|
+
* navigation: "Free shipping on orders over $50", "Holiday sale 30%
|
|
9
|
+
* off", "Order by Dec 18 for Christmas delivery". One announcement
|
|
10
|
+
* shows at a time. Operators define announcements with a theme, an
|
|
11
|
+
* optional CTA link, an optional schedule window, and an audience
|
|
12
|
+
* filter; the storefront calls `activeAnnouncement({ now,
|
|
13
|
+
* viewer_kind, customer_id })` once per request to resolve which
|
|
14
|
+
* announcement (if any) wins for the current viewer.
|
|
15
|
+
*
|
|
16
|
+
* This is intentionally distinct from `promoBanners` — that
|
|
17
|
+
* primitive carries placement targeting, images, multi-line body
|
|
18
|
+
* copy, and impression/click counters. The announcement bar is the
|
|
19
|
+
* text-only strip with one job: a short, time-bounded operator
|
|
20
|
+
* message at the very top of the page.
|
|
21
|
+
*
|
|
22
|
+
* Theme ordering (high-priority first):
|
|
23
|
+
*
|
|
24
|
+
* urgency — "Last day for free shipping", "Site maintenance at 9pm"
|
|
25
|
+
* promo — "30% off everything this weekend"
|
|
26
|
+
* info — "We're hiring", "New collection just landed"
|
|
27
|
+
* success — "Thanks for shopping — order confirmation sent"
|
|
28
|
+
*
|
|
29
|
+
* When multiple announcements are active in the same window,
|
|
30
|
+
* `activeAnnouncement` picks the highest-priority theme; ties break
|
|
31
|
+
* on `updated_at DESC` then `slug ASC` (deterministic, no random
|
|
32
|
+
* tie-break — operators expect the announcement they most recently
|
|
33
|
+
* edited to win).
|
|
34
|
+
*
|
|
35
|
+
* Audience filter:
|
|
36
|
+
*
|
|
37
|
+
* all — every viewer sees it
|
|
38
|
+
* logged_in — only signed-in customers (customer_id required)
|
|
39
|
+
* guest — only guests (customer_id must be absent)
|
|
40
|
+
* segment — only members of the named segment (requires the
|
|
41
|
+
* customerSegments handle wired into create())
|
|
42
|
+
*
|
|
43
|
+
* Dismissal:
|
|
44
|
+
*
|
|
45
|
+
* A `dismissible: true` announcement renders with a close button;
|
|
46
|
+
* the storefront calls `recordDismissal({ slug, session_id })` and
|
|
47
|
+
* the announcement is filtered out of subsequent
|
|
48
|
+
* `activeAnnouncement` calls for that session. The raw session id
|
|
49
|
+
* is hashed at the boundary via `b.crypto.namespaceHash` —
|
|
50
|
+
* storage carries only the digest, so an operator scanning the
|
|
51
|
+
* dismissal log never sees the session token.
|
|
52
|
+
*
|
|
53
|
+
* Composes:
|
|
54
|
+
* - `b.safeUrl.parse` — `link_url` is https-only (or a
|
|
55
|
+
* /-rooted absolute path); javascript: / data: refused before
|
|
56
|
+
* persistence.
|
|
57
|
+
* - `b.crypto.namespaceHash`— SHA3-512 hash under the
|
|
58
|
+
* `announcement-bar-session-id` namespace; the only form the
|
|
59
|
+
* session id is persisted in.
|
|
60
|
+
* - `b.template.escapeHtml` — `renderHtml` escapes every operator-
|
|
61
|
+
* authored field so a hostile message / link_label lands as
|
|
62
|
+
* inert text in the storefront HTML.
|
|
63
|
+
*
|
|
64
|
+
* Surface:
|
|
65
|
+
* - `defineAnnouncement({ slug, message, link_url?, link_label?,
|
|
66
|
+
* theme, starts_at?, expires_at?, audience,
|
|
67
|
+
* segment_slug?, dismissible })`
|
|
68
|
+
* - `activeAnnouncement({ now?, viewer_kind, customer_id?,
|
|
69
|
+
* session_id? })`
|
|
70
|
+
* - `recordDismissal({ slug, session_id, occurred_at? })`
|
|
71
|
+
* - `getAnnouncement(slug)` / `listAnnouncements({ active_only? })`
|
|
72
|
+
* - `updateAnnouncement(slug, patch)` / `archiveAnnouncement(slug)`
|
|
73
|
+
* - `renderHtml({ announcement, locale? })` — sanitized HTML
|
|
74
|
+
* string ready for inline insertion into a storefront template.
|
|
75
|
+
*
|
|
76
|
+
* Storage:
|
|
77
|
+
* - `migrations-d1/0180_announcement_bar.sql` — `announcements`
|
|
78
|
+
* (slug PK) + `announcement_dismissals` (FK CASCADE).
|
|
79
|
+
*
|
|
80
|
+
* @primitive announcementBar
|
|
81
|
+
* @related promoBanners, b.safeUrl, b.template.escapeHtml, b.crypto.namespaceHash
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
// ---- constants ----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
var SESSION_HASH_NAMESPACE = "announcement-bar-session-id";
|
|
87
|
+
|
|
88
|
+
var MAX_SLUG_LEN = 80;
|
|
89
|
+
var MAX_MESSAGE_LEN = 240;
|
|
90
|
+
var MAX_LINK_URL_LEN = 2048;
|
|
91
|
+
var MAX_LINK_LABEL_LEN = 80;
|
|
92
|
+
var MAX_SEGMENT_LEN = 80;
|
|
93
|
+
var MAX_SESSION_ID_LEN = 256;
|
|
94
|
+
|
|
95
|
+
var ALLOWED_THEMES = Object.freeze(["info", "promo", "urgency", "success"]);
|
|
96
|
+
|
|
97
|
+
// Theme priority — higher number wins in activeAnnouncement. Urgency
|
|
98
|
+
// outranks promo (operator wants a "last day" warning to override the
|
|
99
|
+
// running sale strip); info and success are routine and yield to both.
|
|
100
|
+
var THEME_RANK = Object.freeze({
|
|
101
|
+
urgency: 3,
|
|
102
|
+
promo: 2,
|
|
103
|
+
info: 1,
|
|
104
|
+
success: 0,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
var ALLOWED_AUDIENCES = Object.freeze(["all", "logged_in", "guest", "segment"]);
|
|
108
|
+
|
|
109
|
+
var ALLOWED_VIEWER_KINDS = Object.freeze(["logged_in", "guest"]);
|
|
110
|
+
|
|
111
|
+
// Slug shape mirrors promoBanners: leading alnum, then alnum / dot /
|
|
112
|
+
// underscore / hyphen, capped at 80 chars. The slug reaches operator-
|
|
113
|
+
// facing URLs in the admin dashboard so the shape stays narrow.
|
|
114
|
+
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
115
|
+
|
|
116
|
+
// Refuse C0 control bytes + DEL. Newlines are NOT allowed in
|
|
117
|
+
// `message` — the announcement is a single-line strip, never a
|
|
118
|
+
// paragraph; an embedded \n would either break the layout or be
|
|
119
|
+
// silently swallowed by CSS, both of which are surprising.
|
|
120
|
+
var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
|
|
121
|
+
|
|
122
|
+
// Zero-width / direction-override family — spelled with \u-escapes
|
|
123
|
+
// so ESLint's no-irregular-whitespace stays happy.
|
|
124
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
125
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
129
|
+
"message",
|
|
130
|
+
"link_url",
|
|
131
|
+
"link_label",
|
|
132
|
+
"theme",
|
|
133
|
+
"audience",
|
|
134
|
+
"segment_slug",
|
|
135
|
+
"starts_at",
|
|
136
|
+
"expires_at",
|
|
137
|
+
"dismissible",
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
var bShop;
|
|
141
|
+
function _b() {
|
|
142
|
+
if (!bShop) bShop = require("./index");
|
|
143
|
+
return bShop.framework;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- monotonic clock ----------------------------------------------------
|
|
147
|
+
//
|
|
148
|
+
// `defineAnnouncement` / `updateAnnouncement` / `recordDismissal` all
|
|
149
|
+
// persist epoch-ms timestamps. Operators occasionally edit the same
|
|
150
|
+
// announcement twice in the same millisecond (a typo fix immediately
|
|
151
|
+
// followed by an audience change); the activeAnnouncement tie-break
|
|
152
|
+
// is `updated_at DESC` so two same-ms updates would resolve
|
|
153
|
+
// non-deterministically. The strict-monotonic clock guarantees two
|
|
154
|
+
// `_now()` calls in the same millisecond produce distinct integers.
|
|
155
|
+
var _lastTs = 0;
|
|
156
|
+
function _now() {
|
|
157
|
+
var t = Date.now();
|
|
158
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
159
|
+
_lastTs = t;
|
|
160
|
+
return t;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- validators ---------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
function _slug(s, label) {
|
|
166
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
167
|
+
throw new TypeError("announcementBar: " + (label || "slug") +
|
|
168
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (1.." + MAX_SLUG_LEN + " chars)");
|
|
169
|
+
}
|
|
170
|
+
return s;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function _message(s) {
|
|
174
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_MESSAGE_LEN) {
|
|
175
|
+
throw new TypeError("announcementBar: message must be a non-empty string <= " + MAX_MESSAGE_LEN + " chars");
|
|
176
|
+
}
|
|
177
|
+
if (CONTROL_BYTE_LINE_RE.test(s)) {
|
|
178
|
+
throw new TypeError("announcementBar: message must not contain control bytes (incl. CR/LF)");
|
|
179
|
+
}
|
|
180
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
181
|
+
throw new TypeError("announcementBar: message must not contain zero-width / direction-override characters");
|
|
182
|
+
}
|
|
183
|
+
return s;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _linkLabel(s) {
|
|
187
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_LINK_LABEL_LEN) {
|
|
188
|
+
throw new TypeError("announcementBar: link_label must be a non-empty string <= " + MAX_LINK_LABEL_LEN + " chars");
|
|
189
|
+
}
|
|
190
|
+
if (CONTROL_BYTE_LINE_RE.test(s)) {
|
|
191
|
+
throw new TypeError("announcementBar: link_label must not contain control bytes");
|
|
192
|
+
}
|
|
193
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
194
|
+
throw new TypeError("announcementBar: link_label must not contain zero-width / direction-override characters");
|
|
195
|
+
}
|
|
196
|
+
return s;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _theme(s) {
|
|
200
|
+
if (typeof s !== "string" || ALLOWED_THEMES.indexOf(s) === -1) {
|
|
201
|
+
throw new TypeError("announcementBar: theme must be one of " + JSON.stringify(ALLOWED_THEMES));
|
|
202
|
+
}
|
|
203
|
+
return s;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function _audience(s) {
|
|
207
|
+
if (typeof s !== "string" || ALLOWED_AUDIENCES.indexOf(s) === -1) {
|
|
208
|
+
throw new TypeError("announcementBar: audience must be one of " + JSON.stringify(ALLOWED_AUDIENCES));
|
|
209
|
+
}
|
|
210
|
+
return s;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function _segmentSlug(s) {
|
|
214
|
+
if (s == null) return null;
|
|
215
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_SEGMENT_LEN) {
|
|
216
|
+
throw new TypeError("announcementBar: segment_slug must be a non-empty string <= " + MAX_SEGMENT_LEN + " chars");
|
|
217
|
+
}
|
|
218
|
+
if (!SLUG_RE.test(s)) {
|
|
219
|
+
throw new TypeError("announcementBar: segment_slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/");
|
|
220
|
+
}
|
|
221
|
+
return s;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _viewerKind(s) {
|
|
225
|
+
if (typeof s !== "string" || ALLOWED_VIEWER_KINDS.indexOf(s) === -1) {
|
|
226
|
+
throw new TypeError("announcementBar: viewer_kind must be one of " + JSON.stringify(ALLOWED_VIEWER_KINDS));
|
|
227
|
+
}
|
|
228
|
+
return s;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _epochOpt(n, label) {
|
|
232
|
+
if (n == null) return null;
|
|
233
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
234
|
+
throw new TypeError("announcementBar: " + label + " must be a non-negative integer (epoch ms) or null");
|
|
235
|
+
}
|
|
236
|
+
return n;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function _dismissible(v) {
|
|
240
|
+
if (typeof v !== "boolean") {
|
|
241
|
+
throw new TypeError("announcementBar: dismissible must be a boolean");
|
|
242
|
+
}
|
|
243
|
+
return v;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function _sessionId(s) {
|
|
247
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_SESSION_ID_LEN) {
|
|
248
|
+
throw new TypeError("announcementBar: session_id must be a non-empty string <= " + MAX_SESSION_ID_LEN + " chars");
|
|
249
|
+
}
|
|
250
|
+
if (CONTROL_BYTE_LINE_RE.test(s)) {
|
|
251
|
+
throw new TypeError("announcementBar: session_id must not contain control bytes");
|
|
252
|
+
}
|
|
253
|
+
return s;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Validate a link URL through `b.safeUrl.parse` (https-only) OR
|
|
257
|
+
// accept a /-rooted absolute path. Lands into an <a href="..."> on
|
|
258
|
+
// the storefront; javascript: / data: / vbscript: are refused by
|
|
259
|
+
// safeUrl's default protocol allowlist. Protocol-relative `//host/...`
|
|
260
|
+
// URLs are refused so a CDN mis-config can't downgrade the link.
|
|
261
|
+
function _linkUrl(s) {
|
|
262
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_LINK_URL_LEN) {
|
|
263
|
+
throw new TypeError("announcementBar: link_url must be a non-empty string <= " + MAX_LINK_URL_LEN + " chars");
|
|
264
|
+
}
|
|
265
|
+
if (CONTROL_BYTE_LINE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
266
|
+
throw new TypeError("announcementBar: link_url contains control / zero-width bytes");
|
|
267
|
+
}
|
|
268
|
+
if (s.charCodeAt(0) === 47 /* "/" */) {
|
|
269
|
+
if (s.length > 1 && s.charCodeAt(1) === 47) {
|
|
270
|
+
throw new TypeError("announcementBar: link_url protocol-relative `//host/...` refused — use absolute https://");
|
|
271
|
+
}
|
|
272
|
+
if (s.indexOf("..") !== -1) {
|
|
273
|
+
throw new TypeError("announcementBar: link_url path must not contain '..'");
|
|
274
|
+
}
|
|
275
|
+
return s;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
_b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
|
|
279
|
+
} catch (e) {
|
|
280
|
+
throw new TypeError("announcementBar: link_url — " + (e && e.message || "must be https:// or a /-rooted absolute path"));
|
|
281
|
+
}
|
|
282
|
+
return s;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// link_url + link_label are coupled — both present or both NULL. The
|
|
286
|
+
// SQL CHECK constraint enforces the same invariant; the JS validator
|
|
287
|
+
// catches it earlier so the operator gets a clear error before the
|
|
288
|
+
// DB round-trip.
|
|
289
|
+
function _linkPair(linkUrl, linkLabel) {
|
|
290
|
+
var urlPresent = linkUrl != null;
|
|
291
|
+
var labelPresent = linkLabel != null;
|
|
292
|
+
if (urlPresent !== labelPresent) {
|
|
293
|
+
throw new TypeError("announcementBar: link_url and link_label must both be set or both be omitted");
|
|
294
|
+
}
|
|
295
|
+
if (!urlPresent) return { url: null, label: null };
|
|
296
|
+
return { url: _linkUrl(linkUrl), label: _linkLabel(linkLabel) };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---- row hydration ------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
function _hydrateRow(r) {
|
|
302
|
+
if (!r) return null;
|
|
303
|
+
return {
|
|
304
|
+
slug: r.slug,
|
|
305
|
+
message: r.message,
|
|
306
|
+
link_url: r.link_url,
|
|
307
|
+
link_label: r.link_label,
|
|
308
|
+
theme: r.theme,
|
|
309
|
+
audience: r.audience,
|
|
310
|
+
segment_slug: r.segment_slug,
|
|
311
|
+
starts_at: r.starts_at == null ? null : Number(r.starts_at),
|
|
312
|
+
expires_at: r.expires_at == null ? null : Number(r.expires_at),
|
|
313
|
+
dismissible: Number(r.dismissible) === 1,
|
|
314
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
315
|
+
created_at: Number(r.created_at),
|
|
316
|
+
updated_at: Number(r.updated_at),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---- factory ------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
function create(opts) {
|
|
323
|
+
opts = opts || {};
|
|
324
|
+
var query = opts.query;
|
|
325
|
+
if (!query) {
|
|
326
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
327
|
+
}
|
|
328
|
+
// customerSegments is optional — announcements with audience =
|
|
329
|
+
// "segment" require it, but a deployment without segment-targeted
|
|
330
|
+
// announcements can run without one. The factory captures the handle
|
|
331
|
+
// and the `activeAnnouncement` path enforces the requirement lazily
|
|
332
|
+
// with a clear error message when a segment-audience row is reached
|
|
333
|
+
// without a segments handle wired up.
|
|
334
|
+
var customerSegments = opts.customerSegments || null;
|
|
335
|
+
|
|
336
|
+
// -- defineAnnouncement ----------------------------------------------
|
|
337
|
+
|
|
338
|
+
async function defineAnnouncement(input) {
|
|
339
|
+
if (!input || typeof input !== "object") {
|
|
340
|
+
throw new TypeError("announcementBar.defineAnnouncement: input object required");
|
|
341
|
+
}
|
|
342
|
+
var slug = _slug(input.slug);
|
|
343
|
+
var message = _message(input.message);
|
|
344
|
+
var pair = _linkPair(input.link_url, input.link_label);
|
|
345
|
+
var theme = _theme(input.theme);
|
|
346
|
+
var audience = _audience(input.audience);
|
|
347
|
+
var segmentSlug;
|
|
348
|
+
if (audience === "segment") {
|
|
349
|
+
if (input.segment_slug == null) {
|
|
350
|
+
throw new TypeError("announcementBar.defineAnnouncement: audience=\"segment\" requires segment_slug");
|
|
351
|
+
}
|
|
352
|
+
segmentSlug = _segmentSlug(input.segment_slug);
|
|
353
|
+
} else {
|
|
354
|
+
if (input.segment_slug != null) {
|
|
355
|
+
throw new TypeError("announcementBar.defineAnnouncement: segment_slug only valid when audience=\"segment\"");
|
|
356
|
+
}
|
|
357
|
+
segmentSlug = null;
|
|
358
|
+
}
|
|
359
|
+
var startsAt = _epochOpt(input.starts_at, "starts_at");
|
|
360
|
+
var expiresAt = _epochOpt(input.expires_at, "expires_at");
|
|
361
|
+
if (startsAt != null && expiresAt != null && expiresAt <= startsAt) {
|
|
362
|
+
throw new TypeError("announcementBar.defineAnnouncement: expires_at must be strictly greater than starts_at");
|
|
363
|
+
}
|
|
364
|
+
var dismissible = _dismissible(input.dismissible);
|
|
365
|
+
|
|
366
|
+
var existing = await _row(slug);
|
|
367
|
+
var ts = _now();
|
|
368
|
+
if (existing) {
|
|
369
|
+
if (existing.archived_at != null) {
|
|
370
|
+
throw new TypeError("announcementBar.defineAnnouncement: slug " + JSON.stringify(slug) + " is archived");
|
|
371
|
+
}
|
|
372
|
+
await query(
|
|
373
|
+
"UPDATE announcements " +
|
|
374
|
+
"SET message = ?1, link_url = ?2, link_label = ?3, theme = ?4, audience = ?5, " +
|
|
375
|
+
" segment_slug = ?6, starts_at = ?7, expires_at = ?8, dismissible = ?9, updated_at = ?10 " +
|
|
376
|
+
"WHERE slug = ?11",
|
|
377
|
+
[message, pair.url, pair.label, theme, audience, segmentSlug,
|
|
378
|
+
startsAt, expiresAt, dismissible ? 1 : 0, ts, slug],
|
|
379
|
+
);
|
|
380
|
+
} else {
|
|
381
|
+
await query(
|
|
382
|
+
"INSERT INTO announcements " +
|
|
383
|
+
"(slug, message, link_url, link_label, theme, audience, segment_slug, " +
|
|
384
|
+
" starts_at, expires_at, dismissible, archived_at, created_at, updated_at) " +
|
|
385
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, NULL, ?11, ?11)",
|
|
386
|
+
[slug, message, pair.url, pair.label, theme, audience, segmentSlug,
|
|
387
|
+
startsAt, expiresAt, dismissible ? 1 : 0, ts],
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return _hydrateRow(await _row(slug));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function _row(slug) {
|
|
394
|
+
var r = await query("SELECT * FROM announcements WHERE slug = ?1 LIMIT 1", [slug]);
|
|
395
|
+
return r.rows[0] || null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// -- getAnnouncement / listAnnouncements -----------------------------
|
|
399
|
+
|
|
400
|
+
async function getAnnouncement(slug) {
|
|
401
|
+
_slug(slug);
|
|
402
|
+
return _hydrateRow(await _row(slug));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function listAnnouncements(listOpts) {
|
|
406
|
+
listOpts = listOpts || {};
|
|
407
|
+
var activeOnly = false;
|
|
408
|
+
if (listOpts.active_only != null) {
|
|
409
|
+
if (typeof listOpts.active_only !== "boolean") {
|
|
410
|
+
throw new TypeError("announcementBar.listAnnouncements: active_only must be a boolean");
|
|
411
|
+
}
|
|
412
|
+
activeOnly = listOpts.active_only;
|
|
413
|
+
}
|
|
414
|
+
var sql, params;
|
|
415
|
+
if (activeOnly) {
|
|
416
|
+
var nowTs = _now();
|
|
417
|
+
sql = "SELECT * FROM announcements " +
|
|
418
|
+
"WHERE archived_at IS NULL " +
|
|
419
|
+
" AND (starts_at IS NULL OR starts_at <= ?1) " +
|
|
420
|
+
" AND (expires_at IS NULL OR expires_at > ?1) " +
|
|
421
|
+
"ORDER BY updated_at DESC, slug ASC";
|
|
422
|
+
params = [nowTs];
|
|
423
|
+
} else {
|
|
424
|
+
sql = "SELECT * FROM announcements ORDER BY updated_at DESC, slug ASC";
|
|
425
|
+
params = [];
|
|
426
|
+
}
|
|
427
|
+
var rows = (await query(sql, params)).rows;
|
|
428
|
+
var out = [];
|
|
429
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRow(rows[i]));
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// -- updateAnnouncement ----------------------------------------------
|
|
434
|
+
|
|
435
|
+
async function updateAnnouncement(slug, patch) {
|
|
436
|
+
_slug(slug);
|
|
437
|
+
if (!patch || typeof patch !== "object") {
|
|
438
|
+
throw new TypeError("announcementBar.updateAnnouncement: patch object required");
|
|
439
|
+
}
|
|
440
|
+
var keys = Object.keys(patch);
|
|
441
|
+
if (!keys.length) {
|
|
442
|
+
throw new TypeError("announcementBar.updateAnnouncement: patch must include at least one column");
|
|
443
|
+
}
|
|
444
|
+
// Resolve the existing row first so cross-column invariants
|
|
445
|
+
// (audience <-> segment_slug, link_url <-> link_label, starts_at
|
|
446
|
+
// < expires_at) can be checked against the post-patch state
|
|
447
|
+
// without a mid-UPDATE re-read.
|
|
448
|
+
var current = await getAnnouncement(slug);
|
|
449
|
+
if (!current) {
|
|
450
|
+
throw new TypeError("announcementBar.updateAnnouncement: slug " + JSON.stringify(slug) + " not found");
|
|
451
|
+
}
|
|
452
|
+
if (current.archived_at != null) {
|
|
453
|
+
throw new TypeError("announcementBar.updateAnnouncement: slug " + JSON.stringify(slug) + " is archived");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
var sets = [];
|
|
457
|
+
var params = [];
|
|
458
|
+
var idx = 1;
|
|
459
|
+
var postAudience = current.audience;
|
|
460
|
+
var postSegmentSlug = current.segment_slug;
|
|
461
|
+
var postStartsAt = current.starts_at;
|
|
462
|
+
var postExpiresAt = current.expires_at;
|
|
463
|
+
var postLinkUrl = current.link_url;
|
|
464
|
+
var postLinkLabel = current.link_label;
|
|
465
|
+
|
|
466
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
467
|
+
var col = keys[i];
|
|
468
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
|
|
469
|
+
throw new TypeError("announcementBar.updateAnnouncement: unsupported column " + JSON.stringify(col));
|
|
470
|
+
}
|
|
471
|
+
var v;
|
|
472
|
+
if (col === "message") { v = _message(patch[col]); }
|
|
473
|
+
else if (col === "link_url") { v = patch[col] == null ? null : _linkUrl(patch[col]); postLinkUrl = v; }
|
|
474
|
+
else if (col === "link_label") { v = patch[col] == null ? null : _linkLabel(patch[col]); postLinkLabel = v; }
|
|
475
|
+
else if (col === "theme") { v = _theme(patch[col]); }
|
|
476
|
+
else if (col === "audience") { v = _audience(patch[col]); postAudience = v; }
|
|
477
|
+
else if (col === "segment_slug") { v = patch[col] == null ? null : _segmentSlug(patch[col]); postSegmentSlug = v; }
|
|
478
|
+
else if (col === "starts_at") { v = _epochOpt(patch[col], "starts_at"); postStartsAt = v; }
|
|
479
|
+
else if (col === "expires_at") { v = _epochOpt(patch[col], "expires_at"); postExpiresAt = v; }
|
|
480
|
+
else /* dismissible */ { v = _dismissible(patch[col]) ? 1 : 0; }
|
|
481
|
+
sets.push(col + " = ?" + idx);
|
|
482
|
+
params.push(v);
|
|
483
|
+
idx += 1;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (postAudience === "segment" && postSegmentSlug == null) {
|
|
487
|
+
throw new TypeError("announcementBar.updateAnnouncement: audience=\"segment\" requires segment_slug");
|
|
488
|
+
}
|
|
489
|
+
if (postAudience !== "segment" && postSegmentSlug != null) {
|
|
490
|
+
throw new TypeError("announcementBar.updateAnnouncement: segment_slug only valid when audience=\"segment\"");
|
|
491
|
+
}
|
|
492
|
+
if ((postLinkUrl == null) !== (postLinkLabel == null)) {
|
|
493
|
+
throw new TypeError("announcementBar.updateAnnouncement: link_url and link_label must both be set or both be omitted");
|
|
494
|
+
}
|
|
495
|
+
if (postStartsAt != null && postExpiresAt != null && postExpiresAt <= postStartsAt) {
|
|
496
|
+
throw new TypeError("announcementBar.updateAnnouncement: expires_at must be strictly greater than starts_at");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
sets.push("updated_at = ?" + idx);
|
|
500
|
+
params.push(_now());
|
|
501
|
+
idx += 1;
|
|
502
|
+
params.push(slug);
|
|
503
|
+
|
|
504
|
+
var r = await query(
|
|
505
|
+
"UPDATE announcements SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
506
|
+
params,
|
|
507
|
+
);
|
|
508
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
509
|
+
throw new TypeError("announcementBar.updateAnnouncement: slug " + JSON.stringify(slug) + " not found");
|
|
510
|
+
}
|
|
511
|
+
return await getAnnouncement(slug);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// -- archiveAnnouncement ---------------------------------------------
|
|
515
|
+
|
|
516
|
+
async function archiveAnnouncement(slug) {
|
|
517
|
+
_slug(slug);
|
|
518
|
+
var ts = _now();
|
|
519
|
+
var r = await query(
|
|
520
|
+
"UPDATE announcements SET archived_at = ?1, updated_at = ?1 " +
|
|
521
|
+
"WHERE slug = ?2 AND archived_at IS NULL",
|
|
522
|
+
[ts, slug],
|
|
523
|
+
);
|
|
524
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
525
|
+
var existing = await getAnnouncement(slug);
|
|
526
|
+
if (!existing) {
|
|
527
|
+
throw new TypeError("announcementBar.archiveAnnouncement: slug " + JSON.stringify(slug) + " not found");
|
|
528
|
+
}
|
|
529
|
+
// Already archived — return idempotently so an operator running
|
|
530
|
+
// an "archive everything past expiry" sweep doesn't have to
|
|
531
|
+
// special-case rows a coworker already archived.
|
|
532
|
+
return existing;
|
|
533
|
+
}
|
|
534
|
+
return await getAnnouncement(slug);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// -- activeAnnouncement ----------------------------------------------
|
|
538
|
+
|
|
539
|
+
async function activeAnnouncement(input) {
|
|
540
|
+
if (!input || typeof input !== "object") {
|
|
541
|
+
throw new TypeError("announcementBar.activeAnnouncement: input object required");
|
|
542
|
+
}
|
|
543
|
+
var viewerKind = _viewerKind(input.viewer_kind);
|
|
544
|
+
var nowTs;
|
|
545
|
+
if (input.now == null) {
|
|
546
|
+
nowTs = _now();
|
|
547
|
+
} else {
|
|
548
|
+
nowTs = _epochOpt(input.now, "now");
|
|
549
|
+
if (nowTs == null) nowTs = _now();
|
|
550
|
+
}
|
|
551
|
+
var customerId = null;
|
|
552
|
+
if (input.customer_id != null) {
|
|
553
|
+
if (typeof input.customer_id !== "string" || !input.customer_id.length) {
|
|
554
|
+
throw new TypeError("announcementBar.activeAnnouncement: customer_id must be a non-empty string when provided");
|
|
555
|
+
}
|
|
556
|
+
customerId = input.customer_id;
|
|
557
|
+
}
|
|
558
|
+
if (viewerKind === "logged_in" && customerId == null) {
|
|
559
|
+
throw new TypeError("announcementBar.activeAnnouncement: viewer_kind=\"logged_in\" requires customer_id");
|
|
560
|
+
}
|
|
561
|
+
if (viewerKind === "guest" && customerId != null) {
|
|
562
|
+
throw new TypeError("announcementBar.activeAnnouncement: viewer_kind=\"guest\" must not carry customer_id");
|
|
563
|
+
}
|
|
564
|
+
var sessionHash = null;
|
|
565
|
+
if (input.session_id != null) {
|
|
566
|
+
var sid = _sessionId(input.session_id);
|
|
567
|
+
sessionHash = _b().crypto.namespaceHash(SESSION_HASH_NAMESPACE, sid);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Pull every candidate row in the active window, ordered by
|
|
571
|
+
// updated_at DESC then slug ASC. The theme rank is applied JS-
|
|
572
|
+
// side so it doesn't depend on a CASE expression that varies
|
|
573
|
+
// across SQL dialects.
|
|
574
|
+
var rows = (await query(
|
|
575
|
+
"SELECT * FROM announcements " +
|
|
576
|
+
"WHERE archived_at IS NULL " +
|
|
577
|
+
" AND (starts_at IS NULL OR starts_at <= ?1) " +
|
|
578
|
+
" AND (expires_at IS NULL OR expires_at > ?1) " +
|
|
579
|
+
"ORDER BY updated_at DESC, slug ASC",
|
|
580
|
+
[nowTs],
|
|
581
|
+
)).rows;
|
|
582
|
+
|
|
583
|
+
if (!rows.length) return null;
|
|
584
|
+
|
|
585
|
+
// Pull dismissals for this session once (single query), then
|
|
586
|
+
// skip any candidate whose slug appears in the set. The session
|
|
587
|
+
// is unhashed at the boundary; the DB only ever sees the digest.
|
|
588
|
+
var dismissedSet = null;
|
|
589
|
+
if (sessionHash != null) {
|
|
590
|
+
var dRows = (await query(
|
|
591
|
+
"SELECT announcement_slug FROM announcement_dismissals WHERE session_id_hash = ?1",
|
|
592
|
+
[sessionHash],
|
|
593
|
+
)).rows;
|
|
594
|
+
dismissedSet = Object.create(null);
|
|
595
|
+
for (var d = 0; d < dRows.length; d += 1) dismissedSet[dRows[d].announcement_slug] = true;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Walk candidates in (theme_rank DESC, updated_at DESC, slug ASC)
|
|
599
|
+
// order. We've already sorted by (updated_at DESC, slug ASC) at
|
|
600
|
+
// the SQL layer, so we just need to find the row with the
|
|
601
|
+
// highest theme rank that passes the audience + dismissal filter,
|
|
602
|
+
// breaking ties using the SQL-supplied order (which is stable).
|
|
603
|
+
var best = null;
|
|
604
|
+
var bestRank = -1;
|
|
605
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
606
|
+
var hydrated = _hydrateRow(rows[i]);
|
|
607
|
+
|
|
608
|
+
// Audience gate.
|
|
609
|
+
if (hydrated.audience === "logged_in" && viewerKind !== "logged_in") continue;
|
|
610
|
+
if (hydrated.audience === "guest" && viewerKind !== "guest") continue;
|
|
611
|
+
if (hydrated.audience === "segment") {
|
|
612
|
+
if (viewerKind !== "logged_in") continue;
|
|
613
|
+
if (!customerSegments) {
|
|
614
|
+
throw new TypeError("announcementBar.activeAnnouncement: announcement " + JSON.stringify(hydrated.slug) +
|
|
615
|
+
" has audience=\"segment\" but no customerSegments handle was wired into create()");
|
|
616
|
+
}
|
|
617
|
+
if (typeof customerSegments.isMember !== "function") {
|
|
618
|
+
throw new TypeError("announcementBar.activeAnnouncement: customerSegments handle must expose isMember(customer_id, segment_slug)");
|
|
619
|
+
}
|
|
620
|
+
var member = await customerSegments.isMember(customerId, hydrated.segment_slug);
|
|
621
|
+
if (!member) continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Dismissal gate. Only applies when the storefront passes a
|
|
625
|
+
// session_id; a viewer with no session can't have dismissed
|
|
626
|
+
// anything yet, so the gate is a no-op for them.
|
|
627
|
+
if (dismissedSet && dismissedSet[hydrated.slug]) continue;
|
|
628
|
+
|
|
629
|
+
var rank = THEME_RANK[hydrated.theme];
|
|
630
|
+
if (rank == null) rank = -1;
|
|
631
|
+
if (rank > bestRank) {
|
|
632
|
+
best = hydrated;
|
|
633
|
+
bestRank = rank;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return best;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// -- recordDismissal --------------------------------------------------
|
|
640
|
+
|
|
641
|
+
async function recordDismissal(input) {
|
|
642
|
+
if (!input || typeof input !== "object") {
|
|
643
|
+
throw new TypeError("announcementBar.recordDismissal: input object required");
|
|
644
|
+
}
|
|
645
|
+
var slug = _slug(input.slug);
|
|
646
|
+
var sessionId = _sessionId(input.session_id);
|
|
647
|
+
var occurredAt = input.occurred_at == null ? _now() : _epochOpt(input.occurred_at, "occurred_at");
|
|
648
|
+
if (occurredAt == null) occurredAt = _now();
|
|
649
|
+
|
|
650
|
+
// Confirm the announcement exists so the dismissal log never
|
|
651
|
+
// accumulates orphan rows. (The FK enforces it at the DB layer;
|
|
652
|
+
// the JS check produces a clearer error message.)
|
|
653
|
+
var row = await _row(slug);
|
|
654
|
+
if (!row) {
|
|
655
|
+
throw new TypeError("announcementBar.recordDismissal: slug " + JSON.stringify(slug) + " not found");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
var hash = _b().crypto.namespaceHash(SESSION_HASH_NAMESPACE, sessionId);
|
|
659
|
+
|
|
660
|
+
// Dedup is enforced by the UNIQUE(announcement_slug,
|
|
661
|
+
// session_id_hash) index; a second dismissal from the same
|
|
662
|
+
// session is a no-op (the existing row keeps its occurred_at).
|
|
663
|
+
// INSERT OR IGNORE keeps the call idempotent without a separate
|
|
664
|
+
// SELECT-then-INSERT round-trip.
|
|
665
|
+
var id = _b().uuid.v7();
|
|
666
|
+
var r = await query(
|
|
667
|
+
"INSERT OR IGNORE INTO announcement_dismissals " +
|
|
668
|
+
"(id, announcement_slug, session_id_hash, occurred_at) " +
|
|
669
|
+
"VALUES (?1, ?2, ?3, ?4)",
|
|
670
|
+
[id, slug, hash, occurredAt],
|
|
671
|
+
);
|
|
672
|
+
var inserted = Number(r.rowCount || 0) > 0;
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
announcement_slug: slug,
|
|
676
|
+
session_id_hash: hash,
|
|
677
|
+
occurred_at: occurredAt,
|
|
678
|
+
recorded: inserted,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// -- renderHtml -------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
function renderHtml(input) {
|
|
685
|
+
if (!input || typeof input !== "object") {
|
|
686
|
+
throw new TypeError("announcementBar.renderHtml: input object required");
|
|
687
|
+
}
|
|
688
|
+
var announcement = input.announcement;
|
|
689
|
+
if (!announcement || typeof announcement !== "object") {
|
|
690
|
+
throw new TypeError("announcementBar.renderHtml: announcement object required");
|
|
691
|
+
}
|
|
692
|
+
var locale = input.locale;
|
|
693
|
+
if (locale != null) {
|
|
694
|
+
if (typeof locale !== "string" || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(locale)) {
|
|
695
|
+
throw new TypeError("announcementBar.renderHtml: locale must be a BCP-47-shape string (e.g. 'en-US')");
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
var escapeHtml = _b().template.escapeHtml;
|
|
699
|
+
|
|
700
|
+
var theme = escapeHtml(announcement.theme || "info");
|
|
701
|
+
var slug = escapeHtml(announcement.slug);
|
|
702
|
+
var message = escapeHtml(announcement.message);
|
|
703
|
+
var localeAttr = locale ? ' lang="' + escapeHtml(locale) + '"' : "";
|
|
704
|
+
|
|
705
|
+
var parts = [];
|
|
706
|
+
parts.push('<div class="announcement-bar announcement-bar--' + theme +
|
|
707
|
+
'" data-announcement-slug="' + slug + '"' +
|
|
708
|
+
(announcement.dismissible ? ' data-dismissible="true"' : "") +
|
|
709
|
+
localeAttr + '>');
|
|
710
|
+
parts.push('<span class="announcement-bar__message">' + message + '</span>');
|
|
711
|
+
if (announcement.link_url && announcement.link_label) {
|
|
712
|
+
parts.push('<a class="announcement-bar__cta" href="' + escapeHtml(announcement.link_url) +
|
|
713
|
+
'" data-announcement-slug="' + slug + '">' +
|
|
714
|
+
escapeHtml(announcement.link_label) + '</a>');
|
|
715
|
+
}
|
|
716
|
+
if (announcement.dismissible) {
|
|
717
|
+
parts.push('<button type="button" class="announcement-bar__dismiss" ' +
|
|
718
|
+
'data-announcement-slug="' + slug + '" aria-label="Dismiss announcement">×</button>');
|
|
719
|
+
}
|
|
720
|
+
parts.push('</div>');
|
|
721
|
+
return parts.join("");
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
726
|
+
MAX_MESSAGE_LEN: MAX_MESSAGE_LEN,
|
|
727
|
+
MAX_LINK_URL_LEN: MAX_LINK_URL_LEN,
|
|
728
|
+
MAX_LINK_LABEL_LEN: MAX_LINK_LABEL_LEN,
|
|
729
|
+
ALLOWED_THEMES: ALLOWED_THEMES,
|
|
730
|
+
ALLOWED_AUDIENCES: ALLOWED_AUDIENCES,
|
|
731
|
+
SESSION_HASH_NAMESPACE: SESSION_HASH_NAMESPACE,
|
|
732
|
+
|
|
733
|
+
defineAnnouncement: defineAnnouncement,
|
|
734
|
+
activeAnnouncement: activeAnnouncement,
|
|
735
|
+
recordDismissal: recordDismissal,
|
|
736
|
+
getAnnouncement: getAnnouncement,
|
|
737
|
+
listAnnouncements: listAnnouncements,
|
|
738
|
+
updateAnnouncement: updateAnnouncement,
|
|
739
|
+
archiveAnnouncement: archiveAnnouncement,
|
|
740
|
+
renderHtml: renderHtml,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
module.exports = {
|
|
745
|
+
create: create,
|
|
746
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
747
|
+
MAX_MESSAGE_LEN: MAX_MESSAGE_LEN,
|
|
748
|
+
MAX_LINK_URL_LEN: MAX_LINK_URL_LEN,
|
|
749
|
+
MAX_LINK_LABEL_LEN: MAX_LINK_LABEL_LEN,
|
|
750
|
+
ALLOWED_THEMES: ALLOWED_THEMES,
|
|
751
|
+
ALLOWED_AUDIENCES: ALLOWED_AUDIENCES,
|
|
752
|
+
SESSION_HASH_NAMESPACE: SESSION_HASH_NAMESPACE,
|
|
753
|
+
};
|