@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,804 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.productCompare
|
|
4
|
+
* @title Product compare — side-by-side comparison basket for the
|
|
5
|
+
* storefront
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* The storefront's "compare 2-4 products side-by-side" widget. A
|
|
9
|
+
* shopper browses a collection page, taps "compare" on each of two
|
|
10
|
+
* to four products, then navigates to the compare table — the
|
|
11
|
+
* primitive holds the basket (per-session, capped at four entries)
|
|
12
|
+
* and renders the per-attribute table the storefront paints.
|
|
13
|
+
*
|
|
14
|
+
* Four pieces:
|
|
15
|
+
*
|
|
16
|
+
* 1. Per-session compare basket (`addToCompare` /
|
|
17
|
+
* `removeFromCompare` / `getCompareList` / `clearCompareList`).
|
|
18
|
+
* The basket is anchored to the namespace-hashed session
|
|
19
|
+
* cookie; the raw cookie value never lands in the database. A
|
|
20
|
+
* logged-in shopper's `customer_id` rides alongside so the
|
|
21
|
+
* account-page widget can resume the basket on a different
|
|
22
|
+
* device.
|
|
23
|
+
*
|
|
24
|
+
* 2. Per-attribute resolver (`compareTable`). The compare table's
|
|
25
|
+
* rows are operator-declared attributes; the resolver walks
|
|
26
|
+
* the basket's product ids, asks the injected `catalog`
|
|
27
|
+
* handle for each product, pulls the requested attribute's
|
|
28
|
+
* value from the configured source (variant column / product
|
|
29
|
+
* column / metadata bag), and shapes the result as
|
|
30
|
+
* `{ products, rows: [{ attribute, values_per_product }] }`
|
|
31
|
+
* so the storefront's template renderer paints one cell per
|
|
32
|
+
* (attribute, product) pair without further joins.
|
|
33
|
+
*
|
|
34
|
+
* 3. Operator-extensible attribute catalog (`defineCompareAttribute`
|
|
35
|
+
* / `listAttributes`). Seven defaults ship in the primitive
|
|
36
|
+
* (price / sku / brand / vendor / weight / dimensions /
|
|
37
|
+
* inventory_status) so a fresh storefront serves a working
|
|
38
|
+
* compare table without operator setup; custom attributes
|
|
39
|
+
* (warranty, country_of_origin, certifications, ...) get added
|
|
40
|
+
* for category-specific compare tables.
|
|
41
|
+
*
|
|
42
|
+
* 4. Impression telemetry (`recordImpression` / `popularCompares`).
|
|
43
|
+
* Every "product added to compare" event lands in
|
|
44
|
+
* `compare_impressions` so the merchandising widget can render
|
|
45
|
+
* "most-compared products this week" without dragging the
|
|
46
|
+
* per-session basket table into the rollup.
|
|
47
|
+
*
|
|
48
|
+
* Cap:
|
|
49
|
+
* The basket caps at four entries. A `addToCompare` call against
|
|
50
|
+
* a full basket refuses with `error.code = "COMPARE_FULL"` so the
|
|
51
|
+
* storefront can surface "remove one to add another" without
|
|
52
|
+
* re-querying.
|
|
53
|
+
*
|
|
54
|
+
* Session-id privacy:
|
|
55
|
+
* The session id is `namespaceHash("product-compare-session", raw)`
|
|
56
|
+
* before persist. A database dump can't be replayed to recover
|
|
57
|
+
* active baskets, and the cleanup sweep that prunes stale baskets
|
|
58
|
+
* leaves the impressions row in place so the long-window rollup
|
|
59
|
+
* keeps its historical depth.
|
|
60
|
+
*
|
|
61
|
+
* Composes:
|
|
62
|
+
* - `b.guardUuid` — every product_id / customer_id is strict-UUID-
|
|
63
|
+
* sanitised at the entry point.
|
|
64
|
+
* - `b.crypto.namespaceHash` — session-id hashing.
|
|
65
|
+
* - `b.uuid.v7` — row ids (lexicographic + monotonic so impressions
|
|
66
|
+
* with the same `occurred_at` still sort deterministically).
|
|
67
|
+
* - `catalog` (optional, required by `compareTable`) — the table-
|
|
68
|
+
* rendering verb walks the basket's product ids through
|
|
69
|
+
* `catalog.getProduct(...)` to resolve each attribute value.
|
|
70
|
+
* Without a catalog handle, `compareTable` refuses (the other
|
|
71
|
+
* verbs work standalone — operators can hold a basket without
|
|
72
|
+
* resolving the table).
|
|
73
|
+
*
|
|
74
|
+
* @primitive productCompare
|
|
75
|
+
* @related b.guardUuid, b.crypto.namespaceHash, b.uuid, catalog
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
var bShop;
|
|
79
|
+
function _b() {
|
|
80
|
+
if (!bShop) bShop = require("./index");
|
|
81
|
+
return bShop.framework;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- constants ----------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
var MAX_COMPARE = 4;
|
|
87
|
+
var MIN_COMPARE_FOR_TABLE = 1;
|
|
88
|
+
var SESSION_NAMESPACE = "product-compare-session";
|
|
89
|
+
var SESSION_ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
90
|
+
var SLUG_RE = /^[a-z](?:[a-z0-9_-]*[a-z0-9])?$/;
|
|
91
|
+
var MAX_SLUG_LEN = 64;
|
|
92
|
+
var MAX_LABEL_LEN = 100;
|
|
93
|
+
var MAX_LIST_LIMIT = 200;
|
|
94
|
+
var DEFAULT_POPULAR_LIMIT = 20;
|
|
95
|
+
var MAX_SOURCE_KIND_LEN = 48;
|
|
96
|
+
var SOURCE_KIND_RE = /^[a-z](?:[a-z0-9_-]*[a-z0-9])?$/;
|
|
97
|
+
|
|
98
|
+
var ATTRIBUTE_SOURCES = Object.freeze(["variant", "product", "metadata"]);
|
|
99
|
+
var ATTRIBUTE_FORMATS = Object.freeze(["text", "number", "currency", "boolean", "enum"]);
|
|
100
|
+
|
|
101
|
+
// Seven baked-in defaults so a fresh storefront serves a working
|
|
102
|
+
// compare table without operator setup. Each row mirrors a column
|
|
103
|
+
// CHECK constraint in compare_attributes (source / format enum).
|
|
104
|
+
// Operators override by calling `defineCompareAttribute` with the
|
|
105
|
+
// same slug — that upserts the catalog row, after which the default
|
|
106
|
+
// is no longer consulted.
|
|
107
|
+
var DEFAULT_ATTRIBUTES = Object.freeze([
|
|
108
|
+
Object.freeze({ slug: "price", label: "Price", source: "variant", format: "currency" }),
|
|
109
|
+
Object.freeze({ slug: "sku", label: "SKU", source: "variant", format: "text" }),
|
|
110
|
+
Object.freeze({ slug: "brand", label: "Brand", source: "product", format: "text" }),
|
|
111
|
+
Object.freeze({ slug: "vendor", label: "Vendor", source: "product", format: "text" }),
|
|
112
|
+
Object.freeze({ slug: "weight", label: "Weight", source: "variant", format: "number" }),
|
|
113
|
+
Object.freeze({ slug: "dimensions", label: "Dimensions", source: "metadata", format: "text" }),
|
|
114
|
+
Object.freeze({ slug: "inventory_status", label: "Inventory status", source: "variant", format: "enum" }),
|
|
115
|
+
]);
|
|
116
|
+
var DEFAULT_ATTRIBUTE_SLUGS = Object.freeze(DEFAULT_ATTRIBUTES.map(function (a) { return a.slug; }));
|
|
117
|
+
|
|
118
|
+
// ---- monotonic clock ---------------------------------------------------
|
|
119
|
+
//
|
|
120
|
+
// Multiple addToCompare / recordImpression calls inside the same
|
|
121
|
+
// millisecond on a fast host would otherwise tie on `updated_at` /
|
|
122
|
+
// `occurred_at`, breaking the deterministic order the rollup +
|
|
123
|
+
// cleanup sweeps rely on. Bumping by 1ms on a tie keeps the timeline
|
|
124
|
+
// strictly increasing.
|
|
125
|
+
|
|
126
|
+
var _lastTs = 0;
|
|
127
|
+
function _now() {
|
|
128
|
+
var t = Date.now();
|
|
129
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
130
|
+
_lastTs = t;
|
|
131
|
+
return t;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- validators --------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function _uuid(s, label) {
|
|
137
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
138
|
+
catch (e) { throw new TypeError("product-compare: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _optionalUuid(s, label) {
|
|
142
|
+
if (s == null) return null;
|
|
143
|
+
return _uuid(s, label);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _sessionId(s) {
|
|
147
|
+
if (typeof s !== "string" || !SESSION_ID_RE.test(s)) {
|
|
148
|
+
throw new TypeError("product-compare: session_id must be 16-64 chars of [A-Za-z0-9_-]");
|
|
149
|
+
}
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _hashSession(s) {
|
|
154
|
+
return _b().crypto.namespaceHash(SESSION_NAMESPACE, s);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _slug(s, label) {
|
|
158
|
+
if (typeof s !== "string" || !s.length) {
|
|
159
|
+
throw new TypeError("product-compare: " + label + " must be a non-empty string");
|
|
160
|
+
}
|
|
161
|
+
if (s.length > MAX_SLUG_LEN) {
|
|
162
|
+
throw new TypeError("product-compare: " + label + " must be <= " + MAX_SLUG_LEN + " characters");
|
|
163
|
+
}
|
|
164
|
+
if (!SLUG_RE.test(s)) {
|
|
165
|
+
throw new TypeError("product-compare: " + label + " must match /[a-z][a-z0-9_-]*[a-z0-9]/");
|
|
166
|
+
}
|
|
167
|
+
return s;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _label(s) {
|
|
171
|
+
if (typeof s !== "string" || s.length === 0 || s.length > MAX_LABEL_LEN) {
|
|
172
|
+
throw new TypeError("product-compare: label must be a non-empty string <= " + MAX_LABEL_LEN + " chars");
|
|
173
|
+
}
|
|
174
|
+
return s;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function _source(s) {
|
|
178
|
+
if (ATTRIBUTE_SOURCES.indexOf(s) === -1) {
|
|
179
|
+
throw new TypeError("product-compare: source must be one of " + ATTRIBUTE_SOURCES.join(", "));
|
|
180
|
+
}
|
|
181
|
+
return s;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _format(s) {
|
|
185
|
+
if (ATTRIBUTE_FORMATS.indexOf(s) === -1) {
|
|
186
|
+
throw new TypeError("product-compare: format must be one of " + ATTRIBUTE_FORMATS.join(", "));
|
|
187
|
+
}
|
|
188
|
+
return s;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _sourceKind(s) {
|
|
192
|
+
if (typeof s !== "string" || !s.length) {
|
|
193
|
+
throw new TypeError("product-compare: source_kind must be a non-empty string");
|
|
194
|
+
}
|
|
195
|
+
if (s.length > MAX_SOURCE_KIND_LEN) {
|
|
196
|
+
throw new TypeError("product-compare: source_kind must be <= " + MAX_SOURCE_KIND_LEN + " chars");
|
|
197
|
+
}
|
|
198
|
+
if (!SOURCE_KIND_RE.test(s)) {
|
|
199
|
+
throw new TypeError("product-compare: source_kind must match /[a-z][a-z0-9_-]*[a-z0-9]/");
|
|
200
|
+
}
|
|
201
|
+
return s;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _epochMs(n, label) {
|
|
205
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
206
|
+
throw new TypeError("product-compare: " + label + " must be a positive integer (epoch ms)");
|
|
207
|
+
}
|
|
208
|
+
return n;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function _limit(n, label) {
|
|
212
|
+
if (n == null) return DEFAULT_POPULAR_LIMIT;
|
|
213
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
214
|
+
throw new TypeError("product-compare: " + label + " must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
215
|
+
}
|
|
216
|
+
return n;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function _days(n) {
|
|
220
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
221
|
+
throw new TypeError("product-compare: days must be a non-negative integer");
|
|
222
|
+
}
|
|
223
|
+
return n;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- attribute resolver helpers ----------------------------------------
|
|
227
|
+
//
|
|
228
|
+
// Given a product (as returned by catalog.getProduct(...)) and an
|
|
229
|
+
// attribute descriptor, pull the typed value. The shape is
|
|
230
|
+
// intentionally permissive — every operator wires `catalog` to their
|
|
231
|
+
// own schema, so the resolver walks the obvious paths and returns
|
|
232
|
+
// `null` when the value isn't present (rather than throwing). The
|
|
233
|
+
// storefront's renderer paints "—" for a null cell.
|
|
234
|
+
|
|
235
|
+
function _firstVariant(product) {
|
|
236
|
+
if (!product || typeof product !== "object") return null;
|
|
237
|
+
var variants = product.variants;
|
|
238
|
+
if (!Array.isArray(variants) || !variants.length) return null;
|
|
239
|
+
return variants[0];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _readMetadata(product, slug) {
|
|
243
|
+
if (!product || typeof product !== "object") return null;
|
|
244
|
+
var meta = product.metadata_json;
|
|
245
|
+
if (meta == null) meta = product.metadata;
|
|
246
|
+
if (meta && typeof meta === "string") {
|
|
247
|
+
try { meta = JSON.parse(meta); }
|
|
248
|
+
catch (_e) { return null; }
|
|
249
|
+
}
|
|
250
|
+
if (!meta || typeof meta !== "object") return null;
|
|
251
|
+
return Object.prototype.hasOwnProperty.call(meta, slug) ? meta[slug] : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function _resolveAttributeValue(product, attr) {
|
|
255
|
+
if (product == null) return null;
|
|
256
|
+
if (attr.source === "product") {
|
|
257
|
+
if (!Object.prototype.hasOwnProperty.call(product, attr.slug)) return null;
|
|
258
|
+
var pv = product[attr.slug];
|
|
259
|
+
return pv == null ? null : pv;
|
|
260
|
+
}
|
|
261
|
+
if (attr.source === "variant") {
|
|
262
|
+
var variant = _firstVariant(product);
|
|
263
|
+
if (!variant) return null;
|
|
264
|
+
// Variant pricing maps to a couple of common column shapes —
|
|
265
|
+
// `price`, `price_minor`, `unit_price_minor`. The default `price`
|
|
266
|
+
// attribute reads whichever one the operator's catalog populated.
|
|
267
|
+
if (attr.slug === "price") {
|
|
268
|
+
if (variant.price_minor != null) return variant.price_minor;
|
|
269
|
+
if (variant.unit_price_minor != null) return variant.unit_price_minor;
|
|
270
|
+
if (variant.price != null) return variant.price;
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
if (!Object.prototype.hasOwnProperty.call(variant, attr.slug)) return null;
|
|
274
|
+
var vv = variant[attr.slug];
|
|
275
|
+
return vv == null ? null : vv;
|
|
276
|
+
}
|
|
277
|
+
// metadata
|
|
278
|
+
return _readMetadata(product, attr.slug);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- row hydration ------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
function _hydrateList(row) {
|
|
284
|
+
if (!row) return null;
|
|
285
|
+
var ids;
|
|
286
|
+
try { ids = JSON.parse(row.product_ids_json || "[]"); }
|
|
287
|
+
catch (_e) { ids = []; }
|
|
288
|
+
return {
|
|
289
|
+
id: row.id,
|
|
290
|
+
session_id_hash: row.session_id_hash,
|
|
291
|
+
customer_id: row.customer_id == null ? null : row.customer_id,
|
|
292
|
+
product_ids: Array.isArray(ids) ? ids : [],
|
|
293
|
+
created_at: Number(row.created_at),
|
|
294
|
+
updated_at: Number(row.updated_at),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function _hydrateAttribute(row) {
|
|
299
|
+
if (!row) return null;
|
|
300
|
+
return {
|
|
301
|
+
slug: row.slug,
|
|
302
|
+
label: row.label,
|
|
303
|
+
source: row.source,
|
|
304
|
+
format: row.format,
|
|
305
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
306
|
+
created_at: Number(row.created_at),
|
|
307
|
+
updated_at: Number(row.updated_at),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---- factory ------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
function create(opts) {
|
|
314
|
+
opts = opts || {};
|
|
315
|
+
var query = opts.query;
|
|
316
|
+
if (!query) {
|
|
317
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// catalog is optional — required only by `compareTable`. Holding /
|
|
321
|
+
// mutating the basket without resolving the table works standalone
|
|
322
|
+
// so the storefront's add-to-compare tap doesn't have to wait for
|
|
323
|
+
// the catalog handle to be wired.
|
|
324
|
+
var catalog = opts.catalog || null;
|
|
325
|
+
if (catalog && typeof catalog.getProduct !== "function") {
|
|
326
|
+
throw new TypeError("product-compare.create: opts.catalog must expose a getProduct(product_id) method");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function _getListByHash(sessionHash) {
|
|
330
|
+
var r = await query("SELECT * FROM compare_lists WHERE session_id_hash = ?1", [sessionHash]);
|
|
331
|
+
return r.rows.length ? r.rows[0] : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function _getAttributeBySlug(slug) {
|
|
335
|
+
var r = await query("SELECT * FROM compare_attributes WHERE slug = ?1", [slug]);
|
|
336
|
+
return r.rows.length ? r.rows[0] : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Resolve an attribute descriptor by slug — operator-defined rows
|
|
340
|
+
// take precedence over the baked-in defaults so an operator can
|
|
341
|
+
// re-shape a default (point `price` at a different metadata key,
|
|
342
|
+
// for instance) without breaking historical compare-table reads.
|
|
343
|
+
async function _resolveAttribute(slug) {
|
|
344
|
+
var row = await _getAttributeBySlug(slug);
|
|
345
|
+
if (row && row.archived_at == null) return _hydrateAttribute(row);
|
|
346
|
+
if (row && row.archived_at != null) return null; // archived = excluded
|
|
347
|
+
for (var i = 0; i < DEFAULT_ATTRIBUTES.length; i += 1) {
|
|
348
|
+
if (DEFAULT_ATTRIBUTES[i].slug === slug) {
|
|
349
|
+
return {
|
|
350
|
+
slug: DEFAULT_ATTRIBUTES[i].slug,
|
|
351
|
+
label: DEFAULT_ATTRIBUTES[i].label,
|
|
352
|
+
source: DEFAULT_ATTRIBUTES[i].source,
|
|
353
|
+
format: DEFAULT_ATTRIBUTES[i].format,
|
|
354
|
+
archived_at: null,
|
|
355
|
+
created_at: 0,
|
|
356
|
+
updated_at: 0,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function _upsertList(sessionHash, customerId, productIds, now) {
|
|
364
|
+
var existing = await _getListByHash(sessionHash);
|
|
365
|
+
var idsJson = JSON.stringify(productIds);
|
|
366
|
+
if (existing) {
|
|
367
|
+
// Customer id only floats forward — once a basket is claimed by
|
|
368
|
+
// an authenticated shopper the column stays populated even if a
|
|
369
|
+
// later anonymous mutation reaches the same session hash (the
|
|
370
|
+
// session cookie is per-device; the customer id is per-account).
|
|
371
|
+
var nextCustomerId = existing.customer_id != null ? existing.customer_id : customerId;
|
|
372
|
+
await query(
|
|
373
|
+
"UPDATE compare_lists SET product_ids_json = ?1, customer_id = ?2, updated_at = ?3 " +
|
|
374
|
+
"WHERE session_id_hash = ?4",
|
|
375
|
+
[idsJson, nextCustomerId, now, sessionHash],
|
|
376
|
+
);
|
|
377
|
+
} else {
|
|
378
|
+
await query(
|
|
379
|
+
"INSERT INTO compare_lists (id, session_id_hash, customer_id, product_ids_json, " +
|
|
380
|
+
"created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5)",
|
|
381
|
+
[_b().uuid.v7(), sessionHash, customerId, idsJson, now],
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
return _hydrateList(await _getListByHash(sessionHash));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
|
|
389
|
+
MAX_COMPARE: MAX_COMPARE,
|
|
390
|
+
ATTRIBUTE_SOURCES: ATTRIBUTE_SOURCES,
|
|
391
|
+
ATTRIBUTE_FORMATS: ATTRIBUTE_FORMATS,
|
|
392
|
+
DEFAULT_ATTRIBUTE_SLUGS: DEFAULT_ATTRIBUTE_SLUGS,
|
|
393
|
+
|
|
394
|
+
// -- addToCompare ---------------------------------------------------
|
|
395
|
+
//
|
|
396
|
+
// Push a product onto the session's compare basket. Idempotent —
|
|
397
|
+
// a repeat add of the same product id is a no-op (the storefront's
|
|
398
|
+
// tap surface may re-fire while the response is in flight; the
|
|
399
|
+
// primitive collapses the duplicate so the basket order stays
|
|
400
|
+
// stable). Refuses with `error.code = "COMPARE_FULL"` when the
|
|
401
|
+
// basket is at the 4-item cap.
|
|
402
|
+
addToCompare: async function (input) {
|
|
403
|
+
if (!input || typeof input !== "object") {
|
|
404
|
+
throw new TypeError("product-compare.addToCompare: input object required");
|
|
405
|
+
}
|
|
406
|
+
var sessionId = _sessionId(input.session_id);
|
|
407
|
+
var productId = _uuid(input.product_id, "product_id");
|
|
408
|
+
var customerId = _optionalUuid(input.customer_id == null ? null : input.customer_id, "customer_id");
|
|
409
|
+
var sessionHash = _hashSession(sessionId);
|
|
410
|
+
|
|
411
|
+
var existing = await _getListByHash(sessionHash);
|
|
412
|
+
var ids = [];
|
|
413
|
+
if (existing) {
|
|
414
|
+
try { ids = JSON.parse(existing.product_ids_json || "[]"); }
|
|
415
|
+
catch (_e) { ids = []; }
|
|
416
|
+
if (!Array.isArray(ids)) ids = [];
|
|
417
|
+
}
|
|
418
|
+
if (ids.indexOf(productId) !== -1) {
|
|
419
|
+
// Idempotent re-add — bump updated_at so the cleanup sweep
|
|
420
|
+
// sees fresh activity but don't grow the basket.
|
|
421
|
+
return await _upsertList(sessionHash, customerId, ids, _now());
|
|
422
|
+
}
|
|
423
|
+
if (ids.length >= MAX_COMPARE) {
|
|
424
|
+
var fullErr = new Error(
|
|
425
|
+
"product-compare.addToCompare: basket is full (max " + MAX_COMPARE +
|
|
426
|
+
" products); remove an entry before adding another"
|
|
427
|
+
);
|
|
428
|
+
fullErr.code = "COMPARE_FULL";
|
|
429
|
+
throw fullErr;
|
|
430
|
+
}
|
|
431
|
+
ids.push(productId);
|
|
432
|
+
return await _upsertList(sessionHash, customerId, ids, _now());
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
// -- removeFromCompare ----------------------------------------------
|
|
436
|
+
//
|
|
437
|
+
// Drop a product from the session's basket. Idempotent — removing
|
|
438
|
+
// a product that isn't in the basket is a no-op (the storefront's
|
|
439
|
+
// multi-tap "remove" surface may re-fire). Returns the updated
|
|
440
|
+
// list (or the empty-basket shape when the basket didn't exist).
|
|
441
|
+
removeFromCompare: async function (input) {
|
|
442
|
+
if (!input || typeof input !== "object") {
|
|
443
|
+
throw new TypeError("product-compare.removeFromCompare: input object required");
|
|
444
|
+
}
|
|
445
|
+
var sessionId = _sessionId(input.session_id);
|
|
446
|
+
var productId = _uuid(input.product_id, "product_id");
|
|
447
|
+
var sessionHash = _hashSession(sessionId);
|
|
448
|
+
|
|
449
|
+
var existing = await _getListByHash(sessionHash);
|
|
450
|
+
if (!existing) {
|
|
451
|
+
return {
|
|
452
|
+
id: null,
|
|
453
|
+
session_id_hash: sessionHash,
|
|
454
|
+
customer_id: null,
|
|
455
|
+
product_ids: [],
|
|
456
|
+
created_at: 0,
|
|
457
|
+
updated_at: 0,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
var ids;
|
|
461
|
+
try { ids = JSON.parse(existing.product_ids_json || "[]"); }
|
|
462
|
+
catch (_e) { ids = []; }
|
|
463
|
+
if (!Array.isArray(ids)) ids = [];
|
|
464
|
+
var idx = ids.indexOf(productId);
|
|
465
|
+
if (idx === -1) {
|
|
466
|
+
// Idempotent — return the current basket without churning
|
|
467
|
+
// updated_at.
|
|
468
|
+
return _hydrateList(existing);
|
|
469
|
+
}
|
|
470
|
+
ids.splice(idx, 1);
|
|
471
|
+
return await _upsertList(sessionHash, existing.customer_id, ids, _now());
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
// -- getCompareList -------------------------------------------------
|
|
475
|
+
//
|
|
476
|
+
// Read the current basket for a session. Returns the empty-basket
|
|
477
|
+
// shape on miss so the storefront's compare-table page can render
|
|
478
|
+
// an empty state without a separate null branch.
|
|
479
|
+
getCompareList: async function (input) {
|
|
480
|
+
if (!input || typeof input !== "object") {
|
|
481
|
+
throw new TypeError("product-compare.getCompareList: input object required");
|
|
482
|
+
}
|
|
483
|
+
var sessionId = _sessionId(input.session_id);
|
|
484
|
+
var sessionHash = _hashSession(sessionId);
|
|
485
|
+
var existing = await _getListByHash(sessionHash);
|
|
486
|
+
if (!existing) {
|
|
487
|
+
return {
|
|
488
|
+
id: null,
|
|
489
|
+
session_id_hash: sessionHash,
|
|
490
|
+
customer_id: null,
|
|
491
|
+
product_ids: [],
|
|
492
|
+
created_at: 0,
|
|
493
|
+
updated_at: 0,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
return _hydrateList(existing);
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
// -- clearCompareList -----------------------------------------------
|
|
500
|
+
//
|
|
501
|
+
// Drop the basket for a session. Returns `{ cleared: <count> }`
|
|
502
|
+
// (0 when no basket existed, 1 otherwise) so the caller can
|
|
503
|
+
// confirm the action without re-reading.
|
|
504
|
+
clearCompareList: async function (input) {
|
|
505
|
+
if (!input || typeof input !== "object") {
|
|
506
|
+
throw new TypeError("product-compare.clearCompareList: input object required");
|
|
507
|
+
}
|
|
508
|
+
var sessionId = _sessionId(input.session_id);
|
|
509
|
+
var sessionHash = _hashSession(sessionId);
|
|
510
|
+
var r = await query(
|
|
511
|
+
"DELETE FROM compare_lists WHERE session_id_hash = ?1",
|
|
512
|
+
[sessionHash],
|
|
513
|
+
);
|
|
514
|
+
return { cleared: Number(r.rowCount || 0) };
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
// -- compareTable ---------------------------------------------------
|
|
518
|
+
//
|
|
519
|
+
// Resolve the side-by-side comparison table for a session's
|
|
520
|
+
// basket. Reads each product via the injected `catalog` handle,
|
|
521
|
+
// walks the requested attributes (defaulting to the seven baked-
|
|
522
|
+
// in defaults), and shapes the result as
|
|
523
|
+
// `{ products, rows: [{ attribute, values_per_product }] }` so
|
|
524
|
+
// the storefront's template paints one cell per (attribute,
|
|
525
|
+
// product) pair. Refuses when no catalog is wired (the basket
|
|
526
|
+
// verbs work standalone, but the table view needs product data).
|
|
527
|
+
compareTable: async function (input) {
|
|
528
|
+
if (!input || typeof input !== "object") {
|
|
529
|
+
throw new TypeError("product-compare.compareTable: input object required");
|
|
530
|
+
}
|
|
531
|
+
if (!catalog) {
|
|
532
|
+
throw new TypeError("product-compare.compareTable: opts.catalog must be wired to resolve the compare table");
|
|
533
|
+
}
|
|
534
|
+
var sessionId = _sessionId(input.session_id);
|
|
535
|
+
var sessionHash = _hashSession(sessionId);
|
|
536
|
+
|
|
537
|
+
// Attribute selection. When the caller omits `attributes` we
|
|
538
|
+
// fall back to the seven baked-in defaults. When supplied, we
|
|
539
|
+
// resolve each slug against the operator-defined catalog +
|
|
540
|
+
// defaults; an unknown slug throws so the caller catches the
|
|
541
|
+
// typo at request time rather than rendering a silently-empty
|
|
542
|
+
// column.
|
|
543
|
+
var requested;
|
|
544
|
+
if (input.attributes == null) {
|
|
545
|
+
requested = DEFAULT_ATTRIBUTE_SLUGS.slice();
|
|
546
|
+
} else {
|
|
547
|
+
if (!Array.isArray(input.attributes) || input.attributes.length === 0) {
|
|
548
|
+
throw new TypeError("product-compare.compareTable: attributes must be a non-empty array of slugs when provided");
|
|
549
|
+
}
|
|
550
|
+
requested = [];
|
|
551
|
+
var seen = Object.create(null);
|
|
552
|
+
for (var i = 0; i < input.attributes.length; i += 1) {
|
|
553
|
+
var slug = _slug(input.attributes[i], "attributes[" + i + "]");
|
|
554
|
+
if (seen[slug]) {
|
|
555
|
+
throw new TypeError(
|
|
556
|
+
"product-compare.compareTable: attributes[" + i + "] " + JSON.stringify(slug) +
|
|
557
|
+
" duplicates a previous entry"
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
seen[slug] = true;
|
|
561
|
+
requested.push(slug);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Resolve the descriptors before the catalog lookups so an
|
|
566
|
+
// unknown slug fails fast.
|
|
567
|
+
var attrs = [];
|
|
568
|
+
for (var j = 0; j < requested.length; j += 1) {
|
|
569
|
+
var attr = await _resolveAttribute(requested[j]);
|
|
570
|
+
if (!attr) {
|
|
571
|
+
throw new TypeError(
|
|
572
|
+
"product-compare.compareTable: attribute slug " + JSON.stringify(requested[j]) +
|
|
573
|
+
" is not defined (operator must call defineCompareAttribute or pick a default)"
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
attrs.push(attr);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
var listRow = await _getListByHash(sessionHash);
|
|
580
|
+
var productIds = [];
|
|
581
|
+
if (listRow) {
|
|
582
|
+
try { productIds = JSON.parse(listRow.product_ids_json || "[]"); }
|
|
583
|
+
catch (_e) { productIds = []; }
|
|
584
|
+
}
|
|
585
|
+
if (!Array.isArray(productIds)) productIds = [];
|
|
586
|
+
|
|
587
|
+
// Walk the basket's product ids through the catalog. A missing
|
|
588
|
+
// product (deleted between basket-add and table-render) lands
|
|
589
|
+
// as null in the products list AND every attribute cell — the
|
|
590
|
+
// storefront's renderer paints "Product unavailable" without a
|
|
591
|
+
// separate filter pass.
|
|
592
|
+
var products = [];
|
|
593
|
+
for (var k = 0; k < productIds.length; k += 1) {
|
|
594
|
+
var pid = productIds[k];
|
|
595
|
+
var prod = null;
|
|
596
|
+
try { prod = await catalog.getProduct(pid); }
|
|
597
|
+
catch (_e) { prod = null; }
|
|
598
|
+
products.push(prod);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
var rows = [];
|
|
602
|
+
for (var m = 0; m < attrs.length; m += 1) {
|
|
603
|
+
var thisAttr = attrs[m];
|
|
604
|
+
var values = [];
|
|
605
|
+
for (var n = 0; n < products.length; n += 1) {
|
|
606
|
+
values.push(_resolveAttributeValue(products[n], thisAttr));
|
|
607
|
+
}
|
|
608
|
+
rows.push({
|
|
609
|
+
attribute: {
|
|
610
|
+
slug: thisAttr.slug,
|
|
611
|
+
label: thisAttr.label,
|
|
612
|
+
source: thisAttr.source,
|
|
613
|
+
format: thisAttr.format,
|
|
614
|
+
},
|
|
615
|
+
values_per_product: values,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
session_id_hash: sessionHash,
|
|
621
|
+
product_ids: productIds.slice(),
|
|
622
|
+
products: products,
|
|
623
|
+
rows: rows,
|
|
624
|
+
};
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
// -- defineCompareAttribute -----------------------------------------
|
|
628
|
+
//
|
|
629
|
+
// Register / update an operator-defined attribute. Upsert
|
|
630
|
+
// semantics on `slug` — re-defining the same slug updates the
|
|
631
|
+
// row in place. An operator can shadow a baked-in default by
|
|
632
|
+
// re-defining its slug (point `price` at a metadata bag key, for
|
|
633
|
+
// instance); the new descriptor takes precedence on subsequent
|
|
634
|
+
// `compareTable` reads.
|
|
635
|
+
defineCompareAttribute: async function (input) {
|
|
636
|
+
if (!input || typeof input !== "object") {
|
|
637
|
+
throw new TypeError("product-compare.defineCompareAttribute: input object required");
|
|
638
|
+
}
|
|
639
|
+
var slug = _slug(input.slug, "slug");
|
|
640
|
+
var label = _label(input.label);
|
|
641
|
+
var source = _source(input.source);
|
|
642
|
+
var format = _format(input.format);
|
|
643
|
+
var now = _now();
|
|
644
|
+
|
|
645
|
+
var existing = await _getAttributeBySlug(slug);
|
|
646
|
+
if (existing) {
|
|
647
|
+
await query(
|
|
648
|
+
"UPDATE compare_attributes SET label = ?1, source = ?2, format = ?3, " +
|
|
649
|
+
"archived_at = NULL, updated_at = ?4 WHERE slug = ?5",
|
|
650
|
+
[label, source, format, now, slug],
|
|
651
|
+
);
|
|
652
|
+
} else {
|
|
653
|
+
await query(
|
|
654
|
+
"INSERT INTO compare_attributes (slug, label, source, format, archived_at, " +
|
|
655
|
+
"created_at, updated_at) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5)",
|
|
656
|
+
[slug, label, source, format, now],
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
return _hydrateAttribute(await _getAttributeBySlug(slug));
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
// -- listAttributes -------------------------------------------------
|
|
663
|
+
//
|
|
664
|
+
// Operator-facing read of the active attribute catalog. Merges
|
|
665
|
+
// the baked-in defaults with the operator-defined rows; an
|
|
666
|
+
// operator-defined slug shadows the same-slug default. Archived
|
|
667
|
+
// rows are excluded. Returned in alphabetical-by-slug order so
|
|
668
|
+
// the admin UI table is stable across releases.
|
|
669
|
+
listAttributes: async function () {
|
|
670
|
+
var rows = (await query(
|
|
671
|
+
"SELECT * FROM compare_attributes WHERE archived_at IS NULL ORDER BY slug ASC",
|
|
672
|
+
[],
|
|
673
|
+
)).rows;
|
|
674
|
+
var bySlug = Object.create(null);
|
|
675
|
+
var i;
|
|
676
|
+
for (i = 0; i < DEFAULT_ATTRIBUTES.length; i += 1) {
|
|
677
|
+
bySlug[DEFAULT_ATTRIBUTES[i].slug] = {
|
|
678
|
+
slug: DEFAULT_ATTRIBUTES[i].slug,
|
|
679
|
+
label: DEFAULT_ATTRIBUTES[i].label,
|
|
680
|
+
source: DEFAULT_ATTRIBUTES[i].source,
|
|
681
|
+
format: DEFAULT_ATTRIBUTES[i].format,
|
|
682
|
+
archived_at: null,
|
|
683
|
+
created_at: 0,
|
|
684
|
+
updated_at: 0,
|
|
685
|
+
default: true,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
for (i = 0; i < rows.length; i += 1) {
|
|
689
|
+
var h = _hydrateAttribute(rows[i]);
|
|
690
|
+
h.default = false;
|
|
691
|
+
bySlug[h.slug] = h;
|
|
692
|
+
}
|
|
693
|
+
var out = Object.keys(bySlug).sort().map(function (k) { return bySlug[k]; });
|
|
694
|
+
return out;
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
// -- recordImpression -----------------------------------------------
|
|
698
|
+
//
|
|
699
|
+
// Append a "this product was placed into a compare basket"
|
|
700
|
+
// event. Drives `popularCompares({ from, to })`. The session id
|
|
701
|
+
// is namespace-hashed identically to the basket-table write
|
|
702
|
+
// path so an operator-side rollup can detect "this product is
|
|
703
|
+
// compared from many distinct sessions" by counting distinct
|
|
704
|
+
// `session_id_hash` values within a window.
|
|
705
|
+
recordImpression: async function (input) {
|
|
706
|
+
if (!input || typeof input !== "object") {
|
|
707
|
+
throw new TypeError("product-compare.recordImpression: input object required");
|
|
708
|
+
}
|
|
709
|
+
var productId = _uuid(input.product_id, "product_id");
|
|
710
|
+
var sourceKind = _sourceKind(input.source_kind);
|
|
711
|
+
var sessionId = _sessionId(input.session_id);
|
|
712
|
+
var sessionHash = _hashSession(sessionId);
|
|
713
|
+
var now = _now();
|
|
714
|
+
var id = _b().uuid.v7();
|
|
715
|
+
await query(
|
|
716
|
+
"INSERT INTO compare_impressions (id, product_id, source_kind, session_id_hash, occurred_at) " +
|
|
717
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
718
|
+
[id, productId, sourceKind, sessionHash, now],
|
|
719
|
+
);
|
|
720
|
+
return {
|
|
721
|
+
id: id,
|
|
722
|
+
product_id: productId,
|
|
723
|
+
source_kind: sourceKind,
|
|
724
|
+
session_id_hash: sessionHash,
|
|
725
|
+
occurred_at: now,
|
|
726
|
+
};
|
|
727
|
+
},
|
|
728
|
+
|
|
729
|
+
// -- popularCompares ------------------------------------------------
|
|
730
|
+
//
|
|
731
|
+
// Rollup the impressions ledger over a window. Returns
|
|
732
|
+
// `[ { product_id, impressions, distinct_sessions } ]` sorted by
|
|
733
|
+
// impressions desc + product_id asc (the tiebreak keeps the
|
|
734
|
+
// ordering deterministic across releases). The distinct-sessions
|
|
735
|
+
// count lets the merchandising widget weight "many shoppers
|
|
736
|
+
// showed interest" over "one shopper compared this 50 times in a
|
|
737
|
+
// row" without an extra query.
|
|
738
|
+
popularCompares: async function (input) {
|
|
739
|
+
if (!input || typeof input !== "object") {
|
|
740
|
+
throw new TypeError("product-compare.popularCompares: input object required");
|
|
741
|
+
}
|
|
742
|
+
var from = _epochMs(input.from, "from");
|
|
743
|
+
var to = _epochMs(input.to, "to");
|
|
744
|
+
if (from > to) {
|
|
745
|
+
throw new TypeError("product-compare.popularCompares: from must be <= to");
|
|
746
|
+
}
|
|
747
|
+
var limit = _limit(input.limit, "limit");
|
|
748
|
+
|
|
749
|
+
var rows = (await query(
|
|
750
|
+
"SELECT product_id, COUNT(*) AS n, COUNT(DISTINCT session_id_hash) AS d " +
|
|
751
|
+
"FROM compare_impressions WHERE occurred_at >= ?1 AND occurred_at <= ?2 " +
|
|
752
|
+
"GROUP BY product_id ORDER BY n DESC, product_id ASC LIMIT ?3",
|
|
753
|
+
[from, to, limit],
|
|
754
|
+
)).rows;
|
|
755
|
+
|
|
756
|
+
var out = [];
|
|
757
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
758
|
+
out.push({
|
|
759
|
+
product_id: rows[i].product_id,
|
|
760
|
+
impressions: Number(rows[i].n),
|
|
761
|
+
distinct_sessions: Number(rows[i].d),
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
return out;
|
|
765
|
+
},
|
|
766
|
+
|
|
767
|
+
// -- cleanupOlderThan -----------------------------------------------
|
|
768
|
+
//
|
|
769
|
+
// Operator scheduler entry point. Sweeps abandoned compare
|
|
770
|
+
// baskets whose `updated_at` is older than the supplied age, plus
|
|
771
|
+
// the impressions ledger past the same threshold. Operators
|
|
772
|
+
// typically wire this to a daily cron with `days=90` so the
|
|
773
|
+
// tables stay bounded as the shopper population grows. Returns
|
|
774
|
+
// `{ baskets_removed, impressions_removed }` so the operator
|
|
775
|
+
// dashboard can render the sweep's footprint.
|
|
776
|
+
cleanupOlderThan: async function (days) {
|
|
777
|
+
_days(days);
|
|
778
|
+
var threshold = _now() - (days * 24 * 60 * 60 * 1000);
|
|
779
|
+
var lists = await query(
|
|
780
|
+
"DELETE FROM compare_lists WHERE updated_at < ?1",
|
|
781
|
+
[threshold],
|
|
782
|
+
);
|
|
783
|
+
var impressions = await query(
|
|
784
|
+
"DELETE FROM compare_impressions WHERE occurred_at < ?1",
|
|
785
|
+
[threshold],
|
|
786
|
+
);
|
|
787
|
+
return {
|
|
788
|
+
baskets_removed: Number(lists.rowCount || 0),
|
|
789
|
+
impressions_removed: Number(impressions.rowCount || 0),
|
|
790
|
+
};
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
module.exports = {
|
|
796
|
+
create: create,
|
|
797
|
+
MAX_COMPARE: MAX_COMPARE,
|
|
798
|
+
MIN_COMPARE_FOR_TABLE: MIN_COMPARE_FOR_TABLE,
|
|
799
|
+
ATTRIBUTE_SOURCES: ATTRIBUTE_SOURCES,
|
|
800
|
+
ATTRIBUTE_FORMATS: ATTRIBUTE_FORMATS,
|
|
801
|
+
DEFAULT_ATTRIBUTES: DEFAULT_ATTRIBUTES,
|
|
802
|
+
DEFAULT_ATTRIBUTE_SLUGS: DEFAULT_ATTRIBUTE_SLUGS,
|
|
803
|
+
SESSION_NAMESPACE: SESSION_NAMESPACE,
|
|
804
|
+
};
|