@blamejs/blamejs-shop 0.0.72 → 0.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,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
+ };