@active-reach/web-sdk 1.12.0 → 1.14.0

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.
@@ -1764,6 +1764,24 @@ class EcommerceTracker {
1764
1764
  variant_id: wishlist.product.variant_id
1765
1765
  });
1766
1766
  }
1767
+ // -- Back-in-stock waitlist --
1768
+ //
1769
+ // Server-side substrate: contact_events row keyed on
1770
+ // (organization_id, contact_id, event_name='product_waitlisted',
1771
+ // event_properties['product_id']). Resolved by
1772
+ // product_event_trigger_service._get_waitlisted_contacts and fanned out via
1773
+ // catalog.back_in_stock journey trigger when stock_event_handler_worker
1774
+ // detects the SKU flipping back into stock.
1775
+ productWaitlisted(waitlist) {
1776
+ this.aegis.track("product_waitlisted", {
1777
+ product_id: waitlist.product.product_id,
1778
+ sku: waitlist.product.sku ?? waitlist.product.product_id,
1779
+ variant_id: waitlist.product.variant_id,
1780
+ name: waitlist.product.name,
1781
+ price: waitlist.product.price,
1782
+ channels: waitlist.channels
1783
+ });
1784
+ }
1767
1785
  // -- Promotions --
1768
1786
  promotionViewed(promo) {
1769
1787
  this.aegis.track("promotion_viewed", { ...promo });
@@ -2215,6 +2233,134 @@ const _TraitGovernor = class _TraitGovernor {
2215
2233
  };
2216
2234
  _TraitGovernor.WARN_CAP = 3;
2217
2235
  let TraitGovernor = _TraitGovernor;
2236
+ const VALID_CHANNELS = /* @__PURE__ */ new Set([
2237
+ "email",
2238
+ "sms",
2239
+ "push",
2240
+ "webpush",
2241
+ "whatsapp",
2242
+ "rcs",
2243
+ "inapp"
2244
+ ]);
2245
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2246
+ const PHONE_RE = /^\+[1-9]\d{7,14}$/;
2247
+ const SHA256_HEX_RE = /^[a-f0-9]{64}$/i;
2248
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
2249
+ class UserNamespace {
2250
+ constructor(aegis) {
2251
+ this.pendingTraits = {};
2252
+ this.aegis = aegis;
2253
+ }
2254
+ /**
2255
+ * Authoritative identity write. Flushes any pending pre-login traits
2256
+ * along with the supplied `traits` argument as a single identify call.
2257
+ */
2258
+ login(userId, traits) {
2259
+ if (typeof userId !== "string" || userId.length === 0) {
2260
+ logger.warn("[user.login] userId must be a non-empty string");
2261
+ return;
2262
+ }
2263
+ const merged = { ...this.pendingTraits, ...traits || {} };
2264
+ this.pendingTraits = {};
2265
+ this.aegis.identify(userId, merged);
2266
+ }
2267
+ /**
2268
+ * Drops the current userId and any pending pre-login traits. Does NOT
2269
+ * fire a server-side logout event — that's a separate explicit `track`.
2270
+ */
2271
+ logout() {
2272
+ this.pendingTraits = {};
2273
+ this.aegis.reset();
2274
+ }
2275
+ setAttribute(key, value) {
2276
+ if (typeof key !== "string" || key.length === 0) {
2277
+ logger.warn("[user.setAttribute] key must be a non-empty string");
2278
+ return;
2279
+ }
2280
+ this.writeTraits({ [key]: value });
2281
+ }
2282
+ setAttributes(map) {
2283
+ if (!map || typeof map !== "object") {
2284
+ logger.warn("[user.setAttributes] map must be an object");
2285
+ return;
2286
+ }
2287
+ this.writeTraits(map);
2288
+ }
2289
+ setEmail(email) {
2290
+ if (!EMAIL_RE.test(email)) {
2291
+ logger.warn("[user.setEmail] invalid email format");
2292
+ return;
2293
+ }
2294
+ this.writeTraits({ email: email.toLowerCase() });
2295
+ }
2296
+ setPhone(phone) {
2297
+ if (!PHONE_RE.test(phone)) {
2298
+ logger.warn("[user.setPhone] phone must be E.164 (e.g. +15551234567)");
2299
+ return;
2300
+ }
2301
+ this.writeTraits({ phone });
2302
+ }
2303
+ setHashedEmail(sha256Hex) {
2304
+ if (!SHA256_HEX_RE.test(sha256Hex)) {
2305
+ logger.warn("[user.setHashedEmail] expected 64-char hex SHA-256");
2306
+ return;
2307
+ }
2308
+ this.writeTraits({ email_sha256: sha256Hex.toLowerCase() });
2309
+ }
2310
+ setHashedPhone(sha256Hex) {
2311
+ if (!SHA256_HEX_RE.test(sha256Hex)) {
2312
+ logger.warn("[user.setHashedPhone] expected 64-char hex SHA-256");
2313
+ return;
2314
+ }
2315
+ this.writeTraits({ phone_sha256: sha256Hex.toLowerCase() });
2316
+ }
2317
+ setBirthDate(iso) {
2318
+ if (!ISO_DATE_RE.test(iso)) {
2319
+ logger.warn("[user.setBirthDate] expected YYYY-MM-DD");
2320
+ return;
2321
+ }
2322
+ this.writeTraits({ birth_date: iso });
2323
+ }
2324
+ setOptIn(channel, granted) {
2325
+ if (!VALID_CHANNELS.has(channel)) {
2326
+ logger.warn(`[user.setOptIn] unknown channel: ${channel}`);
2327
+ return;
2328
+ }
2329
+ if (typeof granted !== "boolean") {
2330
+ logger.warn("[user.setOptIn] granted must be a boolean");
2331
+ return;
2332
+ }
2333
+ this.writeTraits({ [`opt_in_${channel}`]: granted });
2334
+ }
2335
+ /**
2336
+ * HMAC-signed identity token. Ingress-side verification not yet wired —
2337
+ * the trait is forwarded as-is and persisted on the contact record
2338
+ * until that lands.
2339
+ */
2340
+ setSecureToken(token) {
2341
+ if (typeof token !== "string" || token.length === 0) {
2342
+ logger.warn("[user.setSecureToken] token must be a non-empty string");
2343
+ return;
2344
+ }
2345
+ this.writeTraits({ _secure_token: token });
2346
+ }
2347
+ /**
2348
+ * Test/inspection hook — returns a copy of the pending traits buffer.
2349
+ * Used by the e2e smoke test to assert pre-login accumulation.
2350
+ */
2351
+ _getPendingTraits() {
2352
+ return { ...this.pendingTraits };
2353
+ }
2354
+ // ---------------------------------------------------------------------------
2355
+ writeTraits(traits) {
2356
+ const userId = this.aegis.getUserId();
2357
+ if (userId) {
2358
+ this.aegis.identify(userId, traits);
2359
+ return;
2360
+ }
2361
+ Object.assign(this.pendingTraits, traits);
2362
+ }
2363
+ }
2218
2364
  class Aegis {
2219
2365
  constructor() {
2220
2366
  this.config = null;
@@ -2225,6 +2371,7 @@ class Aegis {
2225
2371
  this.initPromise = null;
2226
2372
  this.consent = null;
2227
2373
  this._ecommerce = null;
2374
+ this._user = null;
2228
2375
  this.rateLimiter = null;
2229
2376
  this.nameGovernor = new NameGovernor();
2230
2377
  this.traitGovernor = new TraitGovernor();
@@ -2233,6 +2380,8 @@ class Aegis {
2233
2380
  this._originalPushState = null;
2234
2381
  this._originalReplaceState = null;
2235
2382
  this._lastEventIds = /* @__PURE__ */ new Map();
2383
+ this._workspaceCodes = /* @__PURE__ */ new Set();
2384
+ this._runtimeWorkspace = null;
2236
2385
  }
2237
2386
  async init(writeKey, config) {
2238
2387
  if (this.initPromise) {
@@ -2400,6 +2549,151 @@ class Aegis {
2400
2549
  });
2401
2550
  }
2402
2551
  }
2552
+ /**
2553
+ * Symmetric counterpart to `use(plugin)`. Calls the plugin's `destroy()`
2554
+ * hook (if defined) and removes it from the registry so its hooks no
2555
+ * longer fire on subsequent events. Safe to call with a name that
2556
+ * isn't registered — logs a warning and returns.
2557
+ *
2558
+ * Primary use case: React components that register a plugin in
2559
+ * `useEffect` and need to unregister in the cleanup function so HMR /
2560
+ * provider remount doesn't leak listeners. See cashier-portal's
2561
+ * MetaPixelInjector for an example.
2562
+ */
2563
+ removePlugin(name) {
2564
+ this.plugins.unregister(name);
2565
+ }
2566
+ /**
2567
+ * Plugin-handshake P1 — ingest the workspace_code allowlist from the
2568
+ * bootstrap response. Pass `result.workspaceCodes` from `bootstrap()`
2569
+ * here so the SDK can do path-segment workspace detection on
2570
+ * customer-facing pages like `/south/rewards`, `/ghatkopar/feedback`.
2571
+ *
2572
+ * Empty array is the right answer for single-outlet tenants — SDK
2573
+ * will skip the path cascade and fall through to query param /
2574
+ * setWorkspace() / gateway origin lookup.
2575
+ *
2576
+ * Safe to call before init() — the set is consulted at event-fire time.
2577
+ */
2578
+ ingestWorkspaceCodes(codes) {
2579
+ this._workspaceCodes = new Set(
2580
+ (codes ?? []).filter((c) => typeof c === "string" && c.length > 0)
2581
+ );
2582
+ logger.debug("Workspace codes ingested", { count: this._workspaceCodes.size });
2583
+ }
2584
+ /**
2585
+ * Plugin-handshake P1 (Track E) — explicitly set the workspace for
2586
+ * subsequent events. Used by:
2587
+ * - SPAs where workspace context changes via in-app routing
2588
+ * - Mobile / RN apps that have no URL
2589
+ * - Custom integrations that resolve workspace from their own state
2590
+ *
2591
+ * Pass null to clear (or call clearWorkspace()).
2592
+ *
2593
+ * Persisted to sessionStorage so SPA reloads / page transitions on the
2594
+ * same outlet inherit it without re-calling.
2595
+ */
2596
+ setWorkspace(codeOrId) {
2597
+ this._runtimeWorkspace = codeOrId;
2598
+ if (typeof window !== "undefined" && window.sessionStorage) {
2599
+ try {
2600
+ if (codeOrId) {
2601
+ window.sessionStorage.setItem("aegis_runtime_ws", codeOrId);
2602
+ } else {
2603
+ window.sessionStorage.removeItem("aegis_runtime_ws");
2604
+ }
2605
+ } catch {
2606
+ }
2607
+ }
2608
+ logger.debug("Runtime workspace set", { value: codeOrId });
2609
+ }
2610
+ /** Symmetric helper to clear the runtime workspace. */
2611
+ clearWorkspace() {
2612
+ this.setWorkspace(null);
2613
+ }
2614
+ /**
2615
+ * Inspect the URL's first path segment and return it if it's in the
2616
+ * org's workspace_code allowlist. Skips well-known non-workspace prefixes
2617
+ * (e.g. `b` for bill short codes; `s` is the storefront app slug).
2618
+ *
2619
+ * Returns undefined when:
2620
+ * - window is undefined (SSR / Node)
2621
+ * - path is empty / root
2622
+ * - first segment isn't in `_workspaceCodes` (e.g. /products, /cart)
2623
+ *
2624
+ * The returned value is a workspace CODE (slug like "south"), not a
2625
+ * UUID. The gateway normalizes code → UUID server-side via
2626
+ * workspace_subaccounts lookup with Redis cache.
2627
+ */
2628
+ getPathWorkspaceCode() {
2629
+ if (typeof window === "undefined") return void 0;
2630
+ if (this._workspaceCodes.size === 0) return void 0;
2631
+ try {
2632
+ const segments = window.location.pathname.split("/").filter(Boolean);
2633
+ if (segments.length === 0) return void 0;
2634
+ const first = segments[0].toLowerCase();
2635
+ return this._workspaceCodes.has(first) ? first : void 0;
2636
+ } catch {
2637
+ return void 0;
2638
+ }
2639
+ }
2640
+ /**
2641
+ * Resolve the effective `workspace_id` for the next event.
2642
+ *
2643
+ * Cascade (highest precedence first):
2644
+ * 1. `this.config.workspace_id` — explicit operator config (headless
2645
+ * SDK usage, server-rendered apps, native shells).
2646
+ * 2. `?ws=` query param on the current URL — the P3 storefront URL
2647
+ * contract. The outlet picker writes this via `router.replace`.
2648
+ * 3. URL path segment (P1 Track A) — `/south/rewards` → "south" when
2649
+ * "south" is in the org's workspace_code allowlist.
2650
+ * 4. `aegis.setWorkspace()` runtime override (P1 Track E) — SPAs +
2651
+ * mobile + custom integrations.
2652
+ * 5. sessionStorage `aegis_runtime_ws` — persists setWorkspace across
2653
+ * reloads/SPA route changes.
2654
+ * 6. `undefined` — gateway falls back to `resolveByOrigin` lookup
2655
+ * against the property's allowed_origins.
2656
+ *
2657
+ * The returned value can be EITHER a UUID (from config / ?ws=) or a
2658
+ * workspace CODE slug (from path / setWorkspace). The gateway
2659
+ * normalizes slug → UUID server-side; analytics layer never needs to
2660
+ * worry about the difference.
2661
+ *
2662
+ * See docs/architecture/MULTI_OUTLET_URL_STRATEGY.md §2.3 and
2663
+ * docs/architecture/PLUGIN_HANDSHAKE_AUTOMATION.md §2.
2664
+ */
2665
+ /**
2666
+ * Public so peer SDK surfaces (AegisMessageRuntime / AegisInAppManager /
2667
+ * AegisPlacementManager / AegisWidgetManager) can plumb the same
2668
+ * resolved workspace into their own gateway POSTs. Each manager calls
2669
+ * the gateway on its own endpoint (`/v1/in_app/events`,
2670
+ * `/v1/placements/track`, `/v1/widgets/track-event`); without this,
2671
+ * those events arrive at event-ingress with no workspace_id stamped
2672
+ * → impressions/clicks fall back to the org's primary workspace and
2673
+ * cross-outlet attribution breaks.
2674
+ */
2675
+ getEffectiveWorkspaceId() {
2676
+ var _a;
2677
+ const configured = (_a = this.config) == null ? void 0 : _a.workspace_id;
2678
+ if (configured) return configured;
2679
+ if (typeof window === "undefined") return void 0;
2680
+ try {
2681
+ const ws = new URLSearchParams(window.location.search).get("ws");
2682
+ if (ws && ws.length > 0) return ws;
2683
+ } catch {
2684
+ }
2685
+ const pathWs = this.getPathWorkspaceCode();
2686
+ if (pathWs) return pathWs;
2687
+ if (this._runtimeWorkspace) return this._runtimeWorkspace;
2688
+ try {
2689
+ if (window.sessionStorage) {
2690
+ const cached = window.sessionStorage.getItem("aegis_runtime_ws");
2691
+ if (cached && cached.length > 0) return cached;
2692
+ }
2693
+ } catch {
2694
+ }
2695
+ return void 0;
2696
+ }
2403
2697
  track(eventName, properties) {
2404
2698
  if (!this.assertInitialized()) return;
2405
2699
  const messageId = generateMessageId();
@@ -2412,7 +2706,7 @@ class Aegis {
2412
2706
  anonymousId: this.identity.getAnonymousId(),
2413
2707
  userId: this.identity.getUserId() || void 0,
2414
2708
  sessionId: this.session.getSessionId(),
2415
- workspace_id: this.config.workspace_id || void 0,
2709
+ workspace_id: this.getEffectiveWorkspaceId(),
2416
2710
  context: buildContext(this.config, this.session)
2417
2711
  };
2418
2712
  this._lastEventIds.set(eventName, messageId);
@@ -2420,7 +2714,7 @@ class Aegis {
2420
2714
  }
2421
2715
  identify(userId, traits) {
2422
2716
  if (!this.assertInitialized()) return;
2423
- const wsForGovernor = this.config.workspace_id || null;
2717
+ const wsForGovernor = this.getEffectiveWorkspaceId() || null;
2424
2718
  const { sanitized: governedTraits } = this.traitGovernor.process(traits, wsForGovernor);
2425
2719
  this.identity.setUserId(userId, governedTraits);
2426
2720
  const event = {
@@ -2431,7 +2725,7 @@ class Aegis {
2431
2725
  anonymousId: this.identity.getAnonymousId(),
2432
2726
  userId,
2433
2727
  sessionId: this.session.getSessionId(),
2434
- workspace_id: this.config.workspace_id || void 0,
2728
+ workspace_id: this.getEffectiveWorkspaceId(),
2435
2729
  context: buildContext(this.config, this.session)
2436
2730
  };
2437
2731
  this.captureEvent(event);
@@ -2447,14 +2741,14 @@ class Aegis {
2447
2741
  anonymousId: this.identity.getAnonymousId(),
2448
2742
  userId: this.identity.getUserId() || void 0,
2449
2743
  sessionId: this.session.getSessionId(),
2450
- workspace_id: this.config.workspace_id || void 0,
2744
+ workspace_id: this.getEffectiveWorkspaceId(),
2451
2745
  context: buildContext(this.config, this.session)
2452
2746
  };
2453
2747
  this.captureEvent(event);
2454
2748
  }
2455
2749
  group(groupId, traits) {
2456
2750
  if (!this.assertInitialized()) return;
2457
- const wsForGovernor = this.config.workspace_id || null;
2751
+ const wsForGovernor = this.getEffectiveWorkspaceId() || null;
2458
2752
  const { sanitized: governedTraits } = this.traitGovernor.process(traits, wsForGovernor);
2459
2753
  const event = {
2460
2754
  type: "group",
@@ -2484,6 +2778,31 @@ class Aegis {
2484
2778
  };
2485
2779
  this.captureEvent(event);
2486
2780
  }
2781
+ /**
2782
+ * SPA-logical screen view. Distinct from `page()` (URL-bound) — fires
2783
+ * when the user enters a logical surface like cart / checkout / drawer
2784
+ * without a URL change. Gateway maps `screen` → `engagement.screen_view`.
2785
+ */
2786
+ screen(name, properties) {
2787
+ if (!this.assertInitialized()) return;
2788
+ if (typeof name !== "string" || name.length === 0) {
2789
+ logger.warn("aegis.screen: name must be a non-empty string");
2790
+ return;
2791
+ }
2792
+ const event = {
2793
+ type: "screen",
2794
+ name,
2795
+ properties: properties || {},
2796
+ messageId: generateMessageId(),
2797
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2798
+ anonymousId: this.identity.getAnonymousId(),
2799
+ userId: this.identity.getUserId() || void 0,
2800
+ sessionId: this.session.getSessionId(),
2801
+ workspace_id: this.getEffectiveWorkspaceId(),
2802
+ context: buildContext(this.config, this.session)
2803
+ };
2804
+ this.captureEvent(event);
2805
+ }
2487
2806
  async captureEvent(event) {
2488
2807
  var _a;
2489
2808
  if (((_a = this.config) == null ? void 0 : _a.enable_consent_mode) && this.consent) {
@@ -2570,6 +2889,7 @@ class Aegis {
2570
2889
  reset() {
2571
2890
  if (!this.assertInitialized()) return;
2572
2891
  this.identity.reset();
2892
+ this._user = null;
2573
2893
  logger.info("User identity reset");
2574
2894
  }
2575
2895
  /**
@@ -2754,6 +3074,12 @@ class Aegis {
2754
3074
  }
2755
3075
  return this._ecommerce;
2756
3076
  }
3077
+ get user() {
3078
+ if (!this._user) {
3079
+ this._user = new UserNamespace(this);
3080
+ }
3081
+ return this._user;
3082
+ }
2757
3083
  destroy() {
2758
3084
  this.stopSPATracking();
2759
3085
  if (this.queue) {
@@ -2780,7 +3106,8 @@ export {
2780
3106
  NameGovernor as N,
2781
3107
  RateLimiter as R,
2782
3108
  Storage as S,
3109
+ UserNamespace as U,
2783
3110
  logger as l,
2784
3111
  murmurhash3_x86_32 as m
2785
3112
  };
2786
- //# sourceMappingURL=analytics-6PR9ERDS.mjs.map
3113
+ //# sourceMappingURL=analytics-DGt-CSgi.mjs.map