@active-reach/web-sdk 1.4.0 → 1.5.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.
Files changed (34) hide show
  1. package/dist/aegis.min.js +1 -1
  2. package/dist/aegis.min.js.map +1 -1
  3. package/dist/{analytics-D9BAnJAu.mjs → analytics-C00PJUSy.mjs} +7 -2
  4. package/dist/{analytics-D9BAnJAu.mjs.map → analytics-C00PJUSy.mjs.map} +1 -1
  5. package/dist/core/prefetch-bundle-client.d.ts +150 -0
  6. package/dist/core/prefetch-bundle-client.d.ts.map +1 -0
  7. package/dist/governance/name-governor.d.ts +10 -0
  8. package/dist/governance/name-governor.d.ts.map +1 -1
  9. package/dist/inapp/AegisInAppManager.d.ts +26 -1
  10. package/dist/inapp/AegisInAppManager.d.ts.map +1 -1
  11. package/dist/inapp/renderers/carousel-cards.d.ts +15 -0
  12. package/dist/inapp/renderers/carousel-cards.d.ts.map +1 -0
  13. package/dist/inapp/renderers/coachmark-tour.d.ts +24 -0
  14. package/dist/inapp/renderers/coachmark-tour.d.ts.map +1 -0
  15. package/dist/inapp/renderers/index.d.ts +12 -0
  16. package/dist/inapp/renderers/index.d.ts.map +1 -0
  17. package/dist/inapp/renderers/product-recommendation.d.ts +23 -0
  18. package/dist/inapp/renderers/product-recommendation.d.ts.map +1 -0
  19. package/dist/inapp/renderers/progress-bar.d.ts +24 -0
  20. package/dist/inapp/renderers/progress-bar.d.ts.map +1 -0
  21. package/dist/inapp/renderers/sticky-bar.d.ts +14 -0
  22. package/dist/inapp/renderers/sticky-bar.d.ts.map +1 -0
  23. package/dist/inapp/renderers/types.d.ts +27 -0
  24. package/dist/inapp/renderers/types.d.ts.map +1 -0
  25. package/dist/inbox/AegisInbox.d.ts +103 -0
  26. package/dist/inbox/AegisInbox.d.ts.map +1 -0
  27. package/dist/inbox/index.d.ts +3 -0
  28. package/dist/inbox/index.d.ts.map +1 -0
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1304 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/react.js +1 -1
  34. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { l as logger, A as Aegis } from "./analytics-D9BAnJAu.mjs";
2
- import { B, E, N, R, m } from "./analytics-D9BAnJAu.mjs";
1
+ import { l as logger, A as Aegis } from "./analytics-C00PJUSy.mjs";
2
+ import { B, E, N, R, m } from "./analytics-C00PJUSy.mjs";
3
3
  import { AegisWebPush } from "./push/AegisWebPush.js";
4
4
  function debounce(func, wait) {
5
5
  let timeoutId = null;
@@ -25,6 +25,715 @@ function throttle(func, limit) {
25
25
  }
26
26
  };
27
27
  }
28
+ const MAX_CARDS_RENDERED = 10;
29
+ function renderCarouselCards(ctx) {
30
+ const { campaign, trackEvent, sanitizeUrl, sanitizeColor, log, addAnimationStyles } = ctx;
31
+ const ic = campaign.interactive_config || {};
32
+ const rawCards = Array.isArray(ic.cards) ? ic.cards : [];
33
+ const cards = rawCards.slice(0, MAX_CARDS_RENDERED);
34
+ if (cards.length === 0) {
35
+ log("carousel_cards rendered with zero cards — skipping", "warn");
36
+ return;
37
+ }
38
+ const autoplay = Number(ic.autoplay_ms) || 0;
39
+ const loop = ic.loop !== false;
40
+ addAnimationStyles();
41
+ const bg = sanitizeColor(campaign.background_color || "#ffffff");
42
+ const fg = sanitizeColor(campaign.text_color || "#0f172a");
43
+ const overlay = document.createElement("div");
44
+ overlay.className = "aegis-in-app-carousel-overlay";
45
+ overlay.setAttribute("data-campaign-id", campaign.id);
46
+ overlay.style.cssText = `
47
+ position: fixed; left: 0; right: 0; bottom: 0;
48
+ z-index: 99999; padding: 12px 12px 20px;
49
+ background: rgba(0,0,0,0.12); backdrop-filter: blur(8px);
50
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
51
+ animation: aegisSlideInFromBottom 0.3s ease-out;
52
+ `;
53
+ const card = document.createElement("div");
54
+ card.style.cssText = `
55
+ background: ${bg}; color: ${fg}; border-radius: 16px;
56
+ box-shadow: 0 8px 20px rgba(0,0,0,0.08); padding: 16px;
57
+ max-width: 520px; margin: 0 auto; position: relative;
58
+ `;
59
+ const header = document.createElement("div");
60
+ header.style.cssText = "display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px;";
61
+ const headerText = document.createElement("div");
62
+ const title = document.createElement("div");
63
+ title.textContent = campaign.title;
64
+ title.style.cssText = "font-weight: 700; font-size: 15px; margin-bottom: 2px;";
65
+ const body = document.createElement("div");
66
+ body.textContent = campaign.body;
67
+ body.style.cssText = "font-size: 13px; opacity: 0.75;";
68
+ headerText.appendChild(title);
69
+ headerText.appendChild(body);
70
+ header.appendChild(headerText);
71
+ const closeBtn = document.createElement("button");
72
+ closeBtn.textContent = "✕";
73
+ closeBtn.setAttribute("aria-label", "Close");
74
+ closeBtn.style.cssText = `
75
+ background: transparent; border: none; color: inherit;
76
+ font-size: 16px; cursor: pointer; opacity: 0.6; padding: 2px 6px;
77
+ `;
78
+ closeBtn.addEventListener("click", () => {
79
+ trackEvent(campaign.id, "dismissed");
80
+ overlay.remove();
81
+ });
82
+ header.appendChild(closeBtn);
83
+ card.appendChild(header);
84
+ const track = document.createElement("div");
85
+ track.style.cssText = `
86
+ display: flex; gap: 10px; overflow-x: auto; scroll-snap-type: x mandatory;
87
+ scrollbar-width: none; -ms-overflow-style: none; padding-bottom: 4px;
88
+ `;
89
+ track.style.msOverflowStyle = "none";
90
+ track.addEventListener("wheel", (e) => {
91
+ if (Math.abs(e.deltaX) < Math.abs(e.deltaY)) return;
92
+ track.scrollLeft += e.deltaX;
93
+ });
94
+ cards.forEach((c, i) => {
95
+ const tile = document.createElement("div");
96
+ tile.setAttribute("data-card-index", String(i));
97
+ tile.style.cssText = `
98
+ flex: 0 0 auto; width: 140px; scroll-snap-align: start;
99
+ background: ${fg}0a; border-radius: 12px; padding: 10px;
100
+ display: flex; flex-direction: column; gap: 6px;
101
+ cursor: ${c.cta_url ? "pointer" : "default"};
102
+ `;
103
+ if (c.image_url) {
104
+ const img = document.createElement("img");
105
+ const safe = sanitizeUrl(c.image_url);
106
+ if (safe) {
107
+ img.src = safe;
108
+ img.alt = "";
109
+ img.loading = "lazy";
110
+ img.style.cssText = "width: 100%; height: 96px; border-radius: 8px; object-fit: cover;";
111
+ tile.appendChild(img);
112
+ }
113
+ }
114
+ if (c.title) {
115
+ const t = document.createElement("div");
116
+ t.textContent = c.title;
117
+ t.style.cssText = "font-weight: 600; font-size: 13px; line-height: 1.3;";
118
+ tile.appendChild(t);
119
+ }
120
+ if (c.body) {
121
+ const b = document.createElement("div");
122
+ b.textContent = c.body;
123
+ b.style.cssText = "font-size: 11.5px; opacity: 0.72; line-height: 1.3;";
124
+ tile.appendChild(b);
125
+ }
126
+ if (c.cta_text && c.cta_url) {
127
+ const cta = document.createElement("button");
128
+ cta.textContent = c.cta_text;
129
+ cta.style.cssText = `
130
+ margin-top: auto; background: ${fg}; color: ${bg};
131
+ border: none; padding: 6px 10px; border-radius: 999px;
132
+ font-size: 12px; font-weight: 600; cursor: pointer;
133
+ `;
134
+ const goto2 = (e) => {
135
+ e.stopPropagation();
136
+ trackEvent(campaign.id, "clicked");
137
+ const safe = sanitizeUrl(c.cta_url);
138
+ if (safe) window.location.href = safe;
139
+ };
140
+ cta.addEventListener("click", goto2);
141
+ tile.appendChild(cta);
142
+ tile.addEventListener("click", goto2);
143
+ }
144
+ track.appendChild(tile);
145
+ });
146
+ card.appendChild(track);
147
+ const dots = document.createElement("div");
148
+ dots.style.cssText = "display: flex; justify-content: center; gap: 6px; margin-top: 10px;";
149
+ const dotEls = [];
150
+ cards.forEach(() => {
151
+ const dot = document.createElement("span");
152
+ dot.style.cssText = `
153
+ width: 6px; height: 6px; border-radius: 999px; background: ${fg};
154
+ opacity: 0.25; transition: opacity 0.2s;
155
+ `;
156
+ dotEls.push(dot);
157
+ dots.appendChild(dot);
158
+ });
159
+ card.appendChild(dots);
160
+ const setActive = (idx) => {
161
+ dotEls.forEach((d, i) => d.style.opacity = i === idx ? "0.9" : "0.25");
162
+ };
163
+ setActive(0);
164
+ let activeIdx = 0;
165
+ const tiles = track.querySelectorAll("[data-card-index]");
166
+ const goto = (idx) => {
167
+ activeIdx = (idx % cards.length + cards.length) % cards.length;
168
+ const el = tiles[activeIdx];
169
+ if (el) {
170
+ el.scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" });
171
+ setActive(activeIdx);
172
+ }
173
+ };
174
+ let autoplayTimer = null;
175
+ if (autoplay > 0 && cards.length > 1) {
176
+ autoplayTimer = setInterval(() => {
177
+ const next = activeIdx + 1;
178
+ if (next >= cards.length && !loop) {
179
+ if (autoplayTimer) clearInterval(autoplayTimer);
180
+ return;
181
+ }
182
+ goto(next);
183
+ }, autoplay);
184
+ }
185
+ overlay.addEventListener("remove", () => {
186
+ if (autoplayTimer) clearInterval(autoplayTimer);
187
+ });
188
+ track.addEventListener("scroll", () => {
189
+ const approx = Math.round(track.scrollLeft / 150);
190
+ if (approx !== activeIdx && approx >= 0 && approx < cards.length) {
191
+ activeIdx = approx;
192
+ setActive(activeIdx);
193
+ }
194
+ });
195
+ overlay.appendChild(card);
196
+ document.body.appendChild(overlay);
197
+ }
198
+ const DISMISS_STORAGE_PREFIX = "aegis_sticky_dismissed:";
199
+ function renderStickyBar(ctx) {
200
+ const { campaign, trackEvent, sanitizeUrl, sanitizeColor, log, addAnimationStyles } = ctx;
201
+ const ic = campaign.interactive_config || {};
202
+ const position = ic.sticky_position === "top" ? "top" : "bottom";
203
+ const dismissible = ic.sticky_dismissible !== false;
204
+ const autoHide = Number(ic.sticky_auto_hide_ms) || 0;
205
+ try {
206
+ if (typeof localStorage !== "undefined" && localStorage.getItem(DISMISS_STORAGE_PREFIX + campaign.id)) {
207
+ log(`sticky_bar ${campaign.id} suppressed — user dismissed earlier`);
208
+ return;
209
+ }
210
+ } catch {
211
+ }
212
+ addAnimationStyles();
213
+ const bg = sanitizeColor(
214
+ ic.sticky_bg_color || campaign.background_color || "#4169e1"
215
+ );
216
+ const fg = sanitizeColor(campaign.text_color || "#ffffff");
217
+ const bar = document.createElement("div");
218
+ bar.className = "aegis-in-app-sticky-bar";
219
+ bar.setAttribute("data-campaign-id", campaign.id);
220
+ const positionCss = position === "top" ? "top: 0; left: 0; right: 0; animation: aegisSlideDown 0.3s ease-out;" : "bottom: 0; left: 0; right: 0; animation: aegisSlideInFromBottom 0.3s ease-out;";
221
+ bar.style.cssText = `
222
+ position: fixed; ${positionCss}
223
+ background: ${bg}; color: ${fg};
224
+ padding: 10px 14px; z-index: 999998;
225
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
226
+ display: flex; align-items: center; gap: 12px; justify-content: center;
227
+ box-shadow: 0 ${position === "top" ? "2px" : "-2px"} 8px rgba(0,0,0,0.08);
228
+ `;
229
+ const label = document.createElement("div");
230
+ label.style.cssText = "flex: 0 1 auto; font-size: 13px; font-weight: 500; text-align: center;";
231
+ const strong = document.createElement("strong");
232
+ strong.textContent = campaign.title;
233
+ strong.style.cssText = "margin-right: 6px; font-weight: 700;";
234
+ label.appendChild(strong);
235
+ label.appendChild(document.createTextNode(campaign.body));
236
+ bar.appendChild(label);
237
+ if (campaign.action_url && campaign.button_text) {
238
+ const cta = document.createElement("button");
239
+ cta.textContent = campaign.button_text;
240
+ cta.style.cssText = `
241
+ background: ${fg}; color: ${bg};
242
+ border: none; padding: 6px 14px; border-radius: 999px;
243
+ font-size: 12px; font-weight: 700; cursor: pointer; white-space: nowrap;
244
+ `;
245
+ cta.addEventListener("click", () => {
246
+ trackEvent(campaign.id, "clicked");
247
+ const safe = sanitizeUrl(campaign.action_url);
248
+ if (safe) window.location.href = safe;
249
+ });
250
+ bar.appendChild(cta);
251
+ }
252
+ let autoHideTimer = null;
253
+ const remove = (persist) => {
254
+ if (autoHideTimer) clearTimeout(autoHideTimer);
255
+ if (persist) {
256
+ try {
257
+ if (typeof localStorage !== "undefined") {
258
+ localStorage.setItem(DISMISS_STORAGE_PREFIX + campaign.id, "1");
259
+ }
260
+ } catch {
261
+ }
262
+ }
263
+ bar.remove();
264
+ };
265
+ if (dismissible) {
266
+ const close = document.createElement("button");
267
+ close.textContent = "✕";
268
+ close.setAttribute("aria-label", "Dismiss");
269
+ close.style.cssText = `
270
+ background: transparent; border: none; color: inherit;
271
+ font-size: 16px; cursor: pointer; padding: 0 4px; opacity: 0.75;
272
+ `;
273
+ close.addEventListener("click", () => {
274
+ trackEvent(campaign.id, "dismissed");
275
+ remove(true);
276
+ });
277
+ bar.appendChild(close);
278
+ }
279
+ if (autoHide > 0) {
280
+ autoHideTimer = setTimeout(() => remove(false), autoHide);
281
+ }
282
+ document.body.appendChild(bar);
283
+ }
284
+ function renderProgressBar(ctx) {
285
+ const { campaign, trackEvent, sanitizeColor, log, addAnimationStyles } = ctx;
286
+ const ic = campaign.interactive_config || {};
287
+ const goalType = ic.progress_goal_type || "cart_total";
288
+ const threshold = Number(ic.progress_threshold);
289
+ if (!(threshold > 0)) {
290
+ log("progress_bar requires a positive progress_threshold — skipping", "warn");
291
+ return;
292
+ }
293
+ const rewardText = ic.progress_reward_text || campaign.body || "Unlocked!";
294
+ const source = ic.progress_source === "sse" ? "sse" : "client";
295
+ addAnimationStyles();
296
+ const bg = sanitizeColor(campaign.background_color || "#f8fafc");
297
+ const fill = sanitizeColor(campaign.text_color || "#4169e1");
298
+ const fg = "#0f172a";
299
+ const bar = document.createElement("div");
300
+ bar.className = "aegis-in-app-progress-bar";
301
+ bar.setAttribute("data-campaign-id", campaign.id);
302
+ bar.style.cssText = `
303
+ position: fixed; left: 12px; right: 12px; bottom: 12px;
304
+ max-width: 540px; margin: 0 auto;
305
+ background: ${bg}; color: ${fg}; border-radius: 12px;
306
+ padding: 10px 14px; z-index: 999997;
307
+ box-shadow: 0 8px 20px rgba(0,0,0,0.08);
308
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
309
+ animation: aegisSlideInFromBottom 0.3s ease-out;
310
+ `;
311
+ const label = document.createElement("div");
312
+ label.style.cssText = "display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; margin-bottom: 6px;";
313
+ const leading = document.createElement("span");
314
+ leading.textContent = campaign.title;
315
+ label.appendChild(leading);
316
+ const numeric = document.createElement("span");
317
+ numeric.style.cssText = "opacity: 0.7; font-weight: 500;";
318
+ label.appendChild(numeric);
319
+ bar.appendChild(label);
320
+ const shell = document.createElement("div");
321
+ shell.style.cssText = `
322
+ height: 8px; border-radius: 999px; background: ${fill}22;
323
+ overflow: hidden;
324
+ `;
325
+ const fillEl = document.createElement("div");
326
+ fillEl.style.cssText = `
327
+ height: 100%; border-radius: 999px; background: ${fill};
328
+ width: 0%; transition: width 0.4s cubic-bezier(.4,0,.2,1);
329
+ `;
330
+ shell.appendChild(fillEl);
331
+ bar.appendChild(shell);
332
+ const footnote = document.createElement("div");
333
+ footnote.style.cssText = "font-size: 11.5px; opacity: 0.65; margin-top: 6px;";
334
+ bar.appendChild(footnote);
335
+ const readCurrentValue = () => {
336
+ var _a, _b;
337
+ if (source === "sse") {
338
+ const hook = window.__aegisProgressSSE;
339
+ return hook && typeof hook[campaign.id] === "number" ? hook[campaign.id] : 0;
340
+ }
341
+ try {
342
+ if (goalType === "cart_total" || goalType === "items_in_cart") {
343
+ const win = window;
344
+ if ((_a = win.Shopify) == null ? void 0 : _a.checkout) {
345
+ const v = parseFloat(String(win.Shopify.checkout.total_price || 0));
346
+ return goalType === "cart_total" ? v : 0;
347
+ }
348
+ if (win.aegis_cart) {
349
+ if (goalType === "items_in_cart") {
350
+ return Array.isArray(win.aegis_cart.cart_items) ? win.aegis_cart.cart_items.length : 0;
351
+ }
352
+ return Number(win.aegis_cart.cart_total || 0);
353
+ }
354
+ try {
355
+ const cacheStr = (_b = window.localStorage) == null ? void 0 : _b.getItem("mage-cache-storage");
356
+ if (cacheStr) {
357
+ const cache = JSON.parse(cacheStr);
358
+ const cart = cache.cart;
359
+ if (cart) {
360
+ return goalType === "cart_total" ? Number(cart.subtotalAmount || 0) : Array.isArray(cart.items) ? cart.items.length : 0;
361
+ }
362
+ }
363
+ } catch {
364
+ }
365
+ }
366
+ return 0;
367
+ } catch {
368
+ return 0;
369
+ }
370
+ };
371
+ let lastFiredUnlock = false;
372
+ const update = () => {
373
+ const cur = readCurrentValue();
374
+ const pct = Math.max(0, Math.min(100, cur / threshold * 100));
375
+ fillEl.style.width = `${pct}%`;
376
+ numeric.textContent = `${cur.toFixed(0)} / ${threshold.toFixed(0)}`;
377
+ if (pct >= 100) {
378
+ footnote.textContent = rewardText;
379
+ if (!lastFiredUnlock) {
380
+ lastFiredUnlock = true;
381
+ trackEvent(campaign.id, "clicked");
382
+ }
383
+ } else {
384
+ const remaining = Math.max(0, threshold - cur);
385
+ footnote.textContent = `Add ${remaining.toFixed(0)} more to unlock: ${rewardText}`;
386
+ }
387
+ };
388
+ update();
389
+ const pollTimer = setInterval(update, 1e3);
390
+ const cleanup = () => clearInterval(pollTimer);
391
+ window.addEventListener("beforeunload", cleanup, { once: true });
392
+ bar.addEventListener("remove", cleanup);
393
+ document.body.appendChild(bar);
394
+ }
395
+ const RESUME_PREFIX = "aegis_coachmark_progress:";
396
+ function readResumeIdx(resumeKey) {
397
+ try {
398
+ if (typeof localStorage === "undefined") return 0;
399
+ const raw = localStorage.getItem(RESUME_PREFIX + resumeKey);
400
+ const n = raw ? parseInt(raw, 10) : 0;
401
+ return Number.isFinite(n) && n >= 0 ? n : 0;
402
+ } catch {
403
+ return 0;
404
+ }
405
+ }
406
+ function writeResumeIdx(resumeKey, idx) {
407
+ try {
408
+ if (typeof localStorage !== "undefined") {
409
+ localStorage.setItem(RESUME_PREFIX + resumeKey, String(idx));
410
+ }
411
+ } catch {
412
+ }
413
+ }
414
+ function clearResume(resumeKey) {
415
+ try {
416
+ if (typeof localStorage !== "undefined") {
417
+ localStorage.removeItem(RESUME_PREFIX + resumeKey);
418
+ }
419
+ } catch {
420
+ }
421
+ }
422
+ function renderCoachmarkTour(ctx) {
423
+ const { campaign, trackEvent, sanitizeColor, log, addAnimationStyles } = ctx;
424
+ const ic = campaign.interactive_config || {};
425
+ const resumeKey = ic.resume_key;
426
+ if (!resumeKey) {
427
+ log("coachmark_tour requires interactive_config.resume_key — skipping", "warn");
428
+ return;
429
+ }
430
+ const steps = Array.isArray(ic.steps) ? ic.steps : [];
431
+ if (steps.length === 0) {
432
+ log("coachmark_tour has no steps — skipping", "warn");
433
+ return;
434
+ }
435
+ const allowSkip = ic.allow_skip !== false;
436
+ const showDots = ic.show_progress_dots !== false;
437
+ addAnimationStyles();
438
+ const bg = sanitizeColor(campaign.background_color || "#0f172a");
439
+ const fg = sanitizeColor(campaign.text_color || "#ffffff");
440
+ let current = readResumeIdx(resumeKey);
441
+ if (current >= steps.length) {
442
+ log(`coachmark_tour ${resumeKey} already complete — not re-showing`);
443
+ return;
444
+ }
445
+ trackEvent(campaign.id, "impression");
446
+ let pointerEl = null;
447
+ let tipEl = null;
448
+ let highlightEl = null;
449
+ const cleanupOne = () => {
450
+ pointerEl == null ? void 0 : pointerEl.remove();
451
+ tipEl == null ? void 0 : tipEl.remove();
452
+ highlightEl == null ? void 0 : highlightEl.remove();
453
+ pointerEl = null;
454
+ tipEl = null;
455
+ highlightEl = null;
456
+ };
457
+ const finish = (opts) => {
458
+ cleanupOne();
459
+ if (opts.skipped) {
460
+ trackEvent(campaign.id, "dismissed");
461
+ } else {
462
+ trackEvent(campaign.id, "clicked");
463
+ }
464
+ writeResumeIdx(resumeKey, steps.length);
465
+ };
466
+ const showStep = (idx) => {
467
+ cleanupOne();
468
+ const step = steps[idx];
469
+ if (!step) {
470
+ finish({ skipped: false });
471
+ return;
472
+ }
473
+ writeResumeIdx(resumeKey, idx);
474
+ const selector = step.anchor_web;
475
+ let anchor = null;
476
+ if (selector) {
477
+ try {
478
+ anchor = document.querySelector(selector);
479
+ } catch {
480
+ anchor = null;
481
+ }
482
+ }
483
+ if (!anchor) {
484
+ log(`coachmark step ${idx} — selector '${selector}' not found; advancing`, "warn");
485
+ showStep(idx + 1);
486
+ return;
487
+ }
488
+ const rect = anchor.getBoundingClientRect();
489
+ highlightEl = document.createElement("div");
490
+ highlightEl.style.cssText = `
491
+ position: fixed;
492
+ top: ${rect.top - 6}px; left: ${rect.left - 6}px;
493
+ width: ${rect.width + 12}px; height: ${rect.height + 12}px;
494
+ border-radius: 10px; z-index: 999998;
495
+ box-shadow: 0 0 0 2px ${fg}, 0 0 0 9999px rgba(0,0,0,0.4);
496
+ pointer-events: none;
497
+ transition: all 0.2s ease;
498
+ `;
499
+ document.body.appendChild(highlightEl);
500
+ const placement = step.placement || "bottom";
501
+ tipEl = document.createElement("div");
502
+ tipEl.setAttribute("data-campaign-id", campaign.id);
503
+ tipEl.style.cssText = `
504
+ position: fixed; z-index: 999999;
505
+ background: ${bg}; color: ${fg};
506
+ padding: 12px 14px; border-radius: 12px;
507
+ max-width: 260px;
508
+ box-shadow: 0 8px 24px rgba(0,0,0,0.25);
509
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
510
+ animation: aegisFadeIn 0.2s ease;
511
+ `;
512
+ const titleEl = document.createElement("div");
513
+ titleEl.textContent = step.title;
514
+ titleEl.style.cssText = "font-weight: 700; font-size: 13px; margin-bottom: 4px;";
515
+ tipEl.appendChild(titleEl);
516
+ const bodyEl = document.createElement("div");
517
+ bodyEl.textContent = step.body;
518
+ bodyEl.style.cssText = "font-size: 12.5px; line-height: 1.4; opacity: 0.9;";
519
+ tipEl.appendChild(bodyEl);
520
+ const controls = document.createElement("div");
521
+ controls.style.cssText = "display: flex; align-items: center; justify-content: space-between; margin-top: 10px; gap: 8px;";
522
+ if (showDots) {
523
+ const dots = document.createElement("div");
524
+ dots.style.cssText = "display: flex; gap: 4px;";
525
+ steps.forEach((_, i) => {
526
+ const d = document.createElement("span");
527
+ d.style.cssText = `
528
+ width: 5px; height: 5px; border-radius: 999px;
529
+ background: ${fg}; opacity: ${i === idx ? "0.95" : "0.3"};
530
+ `;
531
+ dots.appendChild(d);
532
+ });
533
+ controls.appendChild(dots);
534
+ } else {
535
+ controls.appendChild(document.createElement("span"));
536
+ }
537
+ const buttons = document.createElement("div");
538
+ buttons.style.cssText = "display: flex; gap: 6px;";
539
+ if (allowSkip && idx < steps.length - 1) {
540
+ const skip = document.createElement("button");
541
+ skip.textContent = "Skip";
542
+ skip.style.cssText = `
543
+ background: transparent; color: inherit; opacity: 0.7;
544
+ border: none; font-size: 12px; cursor: pointer; padding: 6px 8px;
545
+ `;
546
+ skip.addEventListener("click", () => finish({ skipped: true }));
547
+ buttons.appendChild(skip);
548
+ }
549
+ const next = document.createElement("button");
550
+ const isLast = idx === steps.length - 1;
551
+ next.textContent = step.cta_text || (isLast ? "Done" : "Next");
552
+ next.style.cssText = `
553
+ background: ${fg}; color: ${bg};
554
+ border: none; padding: 6px 12px; border-radius: 999px;
555
+ font-size: 12px; font-weight: 700; cursor: pointer;
556
+ `;
557
+ next.addEventListener("click", () => {
558
+ if (isLast) {
559
+ finish({ skipped: false });
560
+ } else {
561
+ showStep(idx + 1);
562
+ }
563
+ });
564
+ buttons.appendChild(next);
565
+ controls.appendChild(buttons);
566
+ tipEl.appendChild(controls);
567
+ document.body.appendChild(tipEl);
568
+ const tipRect = tipEl.getBoundingClientRect();
569
+ const margin = 12;
570
+ let top = rect.bottom + margin;
571
+ let left = rect.left;
572
+ if (placement === "top") {
573
+ top = rect.top - tipRect.height - margin;
574
+ } else if (placement === "left") {
575
+ top = rect.top;
576
+ left = rect.left - tipRect.width - margin;
577
+ } else if (placement === "right") {
578
+ top = rect.top;
579
+ left = rect.right + margin;
580
+ }
581
+ top = Math.max(8, Math.min(window.innerHeight - tipRect.height - 8, top));
582
+ left = Math.max(8, Math.min(window.innerWidth - tipRect.width - 8, left));
583
+ tipEl.style.top = `${top}px`;
584
+ tipEl.style.left = `${left}px`;
585
+ };
586
+ showStep(current);
587
+ window.aegisResetTour = (key) => clearResume(key);
588
+ }
589
+ const MAX_PRODUCTS = 24;
590
+ function renderProductRecommendation(ctx) {
591
+ const { campaign, trackEvent, sanitizeUrl, sanitizeColor, log, addAnimationStyles } = ctx;
592
+ const ic = campaign.interactive_config || {};
593
+ const rawCards = Array.isArray(ic.cards) ? ic.cards : [];
594
+ const cards = rawCards.slice(0, MAX_PRODUCTS);
595
+ if (cards.length === 0) {
596
+ log("product_recommendation rendered with zero products — skipping", "warn");
597
+ return;
598
+ }
599
+ const layout = ic.rec_layout || "grid";
600
+ const ctaDefault = ic.rec_cta_text || "Shop now";
601
+ addAnimationStyles();
602
+ const bg = sanitizeColor(campaign.background_color || "#ffffff");
603
+ const fg = sanitizeColor(campaign.text_color || "#0f172a");
604
+ const accent = sanitizeColor("#4169e1");
605
+ const overlay = document.createElement("div");
606
+ overlay.className = "aegis-in-app-product-rec";
607
+ overlay.setAttribute("data-campaign-id", campaign.id);
608
+ overlay.style.cssText = `
609
+ position: fixed; inset: 0;
610
+ background: rgba(15,23,42,0.55); backdrop-filter: blur(4px);
611
+ display: flex; align-items: flex-end; justify-content: center;
612
+ z-index: 99999; animation: aegisFadeIn 0.25s ease;
613
+ `;
614
+ const sheet = document.createElement("div");
615
+ sheet.style.cssText = `
616
+ background: ${bg}; color: ${fg};
617
+ max-width: 560px; width: 100%; max-height: 80vh; overflow-y: auto;
618
+ border-radius: 20px 20px 0 0;
619
+ padding: 18px 16px 22px;
620
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
621
+ animation: aegisSlideInFromBottom 0.3s ease-out;
622
+ box-shadow: 0 -12px 30px rgba(0,0,0,0.12);
623
+ `;
624
+ const handle = document.createElement("div");
625
+ handle.style.cssText = `
626
+ width: 36px; height: 4px; border-radius: 999px; background: ${fg}1f;
627
+ margin: 0 auto 12px;
628
+ `;
629
+ sheet.appendChild(handle);
630
+ const header = document.createElement("div");
631
+ header.style.cssText = "display: flex; justify-content: space-between; align-items: flex-start; gap: 12px;";
632
+ const headerText = document.createElement("div");
633
+ const title = document.createElement("div");
634
+ title.textContent = campaign.title;
635
+ title.style.cssText = "font-weight: 700; font-size: 16px; margin-bottom: 2px;";
636
+ const body = document.createElement("div");
637
+ body.textContent = campaign.body;
638
+ body.style.cssText = "font-size: 13px; opacity: 0.7; line-height: 1.4;";
639
+ headerText.appendChild(title);
640
+ headerText.appendChild(body);
641
+ header.appendChild(headerText);
642
+ const close = document.createElement("button");
643
+ close.textContent = "✕";
644
+ close.setAttribute("aria-label", "Close");
645
+ close.style.cssText = `
646
+ background: transparent; border: none; color: inherit;
647
+ font-size: 18px; cursor: pointer; padding: 4px 8px; opacity: 0.7;
648
+ `;
649
+ close.addEventListener("click", () => {
650
+ trackEvent(campaign.id, "dismissed");
651
+ overlay.remove();
652
+ });
653
+ header.appendChild(close);
654
+ sheet.appendChild(header);
655
+ const grid = document.createElement("div");
656
+ if (layout === "row" || layout === "carousel") {
657
+ grid.style.cssText = `
658
+ display: flex; gap: 10px; overflow-x: auto;
659
+ scroll-snap-type: x mandatory; padding: 14px 0 4px;
660
+ `;
661
+ } else {
662
+ grid.style.cssText = `
663
+ display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
664
+ padding-top: 14px;
665
+ `;
666
+ }
667
+ cards.forEach((c) => {
668
+ const tile = document.createElement("div");
669
+ const isRow = layout === "row" || layout === "carousel";
670
+ tile.style.cssText = `
671
+ background: ${fg}08; border-radius: 12px;
672
+ padding: 10px; display: flex; flex-direction: column; gap: 6px;
673
+ cursor: ${c.cta_url ? "pointer" : "default"};
674
+ ${isRow ? "flex: 0 0 150px; scroll-snap-align: start;" : ""}
675
+ transition: transform 0.15s ease;
676
+ `;
677
+ if (c.image_url) {
678
+ const img = document.createElement("img");
679
+ const safe = sanitizeUrl(c.image_url);
680
+ if (safe) {
681
+ img.src = safe;
682
+ img.alt = "";
683
+ img.loading = "lazy";
684
+ img.style.cssText = "width: 100%; aspect-ratio: 1 / 1; border-radius: 8px; object-fit: cover;";
685
+ tile.appendChild(img);
686
+ }
687
+ }
688
+ if (c.title) {
689
+ const t = document.createElement("div");
690
+ t.textContent = c.title;
691
+ t.style.cssText = "font-weight: 600; font-size: 13px; line-height: 1.3;";
692
+ tile.appendChild(t);
693
+ }
694
+ const price = c.metadata && typeof c.metadata === "object" ? c.metadata.price : void 0;
695
+ if (price !== void 0) {
696
+ const p = document.createElement("div");
697
+ p.textContent = String(price);
698
+ p.style.cssText = `color: ${accent}; font-weight: 700; font-size: 13px;`;
699
+ tile.appendChild(p);
700
+ } else if (c.body) {
701
+ const b = document.createElement("div");
702
+ b.textContent = c.body;
703
+ b.style.cssText = "font-size: 11.5px; opacity: 0.72; line-height: 1.3;";
704
+ tile.appendChild(b);
705
+ }
706
+ const cta = document.createElement("button");
707
+ cta.textContent = c.cta_text || ctaDefault;
708
+ cta.style.cssText = `
709
+ margin-top: auto;
710
+ background: ${accent}; color: #fff;
711
+ border: none; padding: 7px 10px; border-radius: 999px;
712
+ font-size: 12px; font-weight: 600; cursor: pointer;
713
+ `;
714
+ const go = (e) => {
715
+ e.stopPropagation();
716
+ trackEvent(campaign.id, "clicked");
717
+ if (c.cta_url) {
718
+ const safe = sanitizeUrl(c.cta_url);
719
+ if (safe) window.location.href = safe;
720
+ }
721
+ };
722
+ cta.addEventListener("click", go);
723
+ tile.appendChild(cta);
724
+ if (c.cta_url) tile.addEventListener("click", go);
725
+ grid.appendChild(tile);
726
+ });
727
+ sheet.appendChild(grid);
728
+ overlay.appendChild(sheet);
729
+ overlay.addEventListener("click", (e) => {
730
+ if (e.target === overlay) {
731
+ trackEvent(campaign.id, "dismissed");
732
+ overlay.remove();
733
+ }
734
+ });
735
+ document.body.appendChild(overlay);
736
+ }
28
737
  class AegisInAppManager {
29
738
  constructor(config) {
30
739
  this.campaigns = [];
@@ -213,11 +922,60 @@ class AegisInAppManager {
213
922
  return assignments[campaignId] ?? void 0;
214
923
  }
215
924
  tryDisplayNextCampaign() {
216
- const campaign = this.campaigns.find((c) => !this.displayedCampaigns.has(c.id));
925
+ const campaign = this.campaigns.find(
926
+ (c) => !this.displayedCampaigns.has(c.id) && !c.client_trigger
927
+ );
217
928
  if (campaign) {
218
929
  this.displayCampaign(campaign);
219
930
  }
220
931
  }
932
+ /**
933
+ * Evaluate the currently armed campaigns against a client-side event
934
+ * and render any that match their `client_trigger`.
935
+ *
936
+ * Called by the host app (e.g., the EcommerceTracker on product_viewed),
937
+ * or by future TriggerEngine bridges. Safe to call repeatedly for the
938
+ * same event — dedup happens via `displayedCampaigns`.
939
+ *
940
+ * Supported trigger types (align with the cell-plane server-side
941
+ * `display_rules.trigger_type`):
942
+ * - `custom_event` : fire when eventName matches config.event
943
+ * - `product_match` : fire on `product_viewed` when eventData.product_id
944
+ * matches config.product_id (string or string[])
945
+ * - `delay` : client-side setTimeout — evaluated at armeng
946
+ * time (kicked off from `displayCampaign`), not here.
947
+ */
948
+ onClientEvent(eventName, eventData = {}) {
949
+ for (const c of this.campaigns) {
950
+ if (this.displayedCampaigns.has(c.id)) continue;
951
+ if (!c.client_trigger) continue;
952
+ if (this.matchesClientTrigger(c.client_trigger, eventName, eventData)) {
953
+ this.displayCampaign(c);
954
+ }
955
+ }
956
+ }
957
+ matchesClientTrigger(trigger, eventName, eventData) {
958
+ var _a;
959
+ const cfg = trigger.config || {};
960
+ switch (trigger.type) {
961
+ case "custom_event":
962
+ return typeof cfg.event === "string" && cfg.event === eventName;
963
+ case "product_match": {
964
+ if (eventName !== "product_viewed" && eventName !== "product_view") {
965
+ return false;
966
+ }
967
+ const wantedRaw = cfg.product_id;
968
+ const wanted = Array.isArray(wantedRaw) ? wantedRaw : typeof wantedRaw === "string" ? [wantedRaw] : [];
969
+ if (wanted.length === 0) return false;
970
+ const actual = String(
971
+ eventData.product_id ?? eventData.productId ?? ((_a = eventData.product) == null ? void 0 : _a.id) ?? ""
972
+ );
973
+ return wanted.includes(actual);
974
+ }
975
+ default:
976
+ return false;
977
+ }
978
+ }
221
979
  displayCampaign(campaign) {
222
980
  this.displayedCampaigns.add(campaign.id);
223
981
  const interactiveSubTypes = /* @__PURE__ */ new Set([
@@ -256,9 +1014,49 @@ class AegisInAppManager {
256
1014
  case "tooltip":
257
1015
  this.renderTooltip(campaign);
258
1016
  break;
1017
+ // Preload-first display types (2026-04-22). These renderers own their
1018
+ // own impression tracking so we don't double-count alongside the
1019
+ // trailing `this.trackEvent(... 'impression')` below — return early.
1020
+ case "carousel_cards":
1021
+ renderCarouselCards(this.buildRenderContext(campaign));
1022
+ this.trackEvent(campaign.id, "impression");
1023
+ return;
1024
+ case "sticky_bar":
1025
+ renderStickyBar(this.buildRenderContext(campaign));
1026
+ this.trackEvent(campaign.id, "impression");
1027
+ return;
1028
+ case "progress_bar":
1029
+ renderProgressBar(this.buildRenderContext(campaign));
1030
+ this.trackEvent(campaign.id, "impression");
1031
+ return;
1032
+ case "coachmark_tour":
1033
+ renderCoachmarkTour(this.buildRenderContext(campaign));
1034
+ return;
1035
+ case "product_recommendation":
1036
+ renderProductRecommendation(this.buildRenderContext(campaign));
1037
+ this.trackEvent(campaign.id, "impression");
1038
+ return;
259
1039
  }
260
1040
  this.trackEvent(campaign.id, "impression");
261
1041
  }
1042
+ /**
1043
+ * Build the shared context passed into the preload-first renderers.
1044
+ * Matches the interface in `./renderers/types.ts`. Kept as a private
1045
+ * method (rather than inlined at each call-site) so future renderer
1046
+ * additions stay consistent and easy to audit.
1047
+ */
1048
+ buildRenderContext(campaign) {
1049
+ return {
1050
+ campaign,
1051
+ trackEvent: (id, evt) => {
1052
+ void this.trackEvent(id, evt);
1053
+ },
1054
+ sanitizeUrl: (url) => this.sanitizeUrl(url),
1055
+ sanitizeColor: (color) => this.sanitizeColor(color),
1056
+ log: (msg, level) => this.log(msg, level),
1057
+ addAnimationStyles: () => this.addAnimationStyles()
1058
+ };
1059
+ }
262
1060
  /**
263
1061
  * Renders interactive sub-type campaigns (spin wheel, NPS, quiz, etc.)
264
1062
  * using DOM-safe rendering. These sub-types use the campaign's
@@ -1341,6 +2139,14 @@ class AegisInAppManager {
1341
2139
  from { transform: scale(0.9); opacity: 0; }
1342
2140
  to { transform: scale(1); opacity: 1; }
1343
2141
  }
2142
+
2143
+ /* Bottom-anchored slide IN (opposite of aegisSlideUp which slides OUT).
2144
+ Used by the preload-first renderers: sticky_bar (bottom),
2145
+ progress_bar, carousel_cards, product_recommendation. */
2146
+ @keyframes aegisSlideInFromBottom {
2147
+ from { transform: translateY(100%); opacity: 0; }
2148
+ to { transform: translateY(0); opacity: 1; }
2149
+ }
1344
2150
  `;
1345
2151
  document.head.appendChild(style);
1346
2152
  }
@@ -1401,6 +2207,10 @@ class AegisInAppManager {
1401
2207
  event_type: eventType,
1402
2208
  user_id: this.userId,
1403
2209
  contact_id: this.contactId,
2210
+ // Property ID so the cell-plane's in_app.rendered emitter can
2211
+ // fan the event out to the property-scoped inbox_writer. Without
2212
+ // this, inbox materialization cannot target the right property.
2213
+ property_id: this.propertyId,
1404
2214
  platform: "web",
1405
2215
  variant_id: this.getVariantId(campaignId) ?? void 0
1406
2216
  },
@@ -4351,6 +5161,495 @@ class SdkConfigPoller {
4351
5161
  }
4352
5162
  }
4353
5163
  }
5164
+ const DEFAULT_POLL_MS = 3e5;
5165
+ class PrefetchBundleClient {
5166
+ constructor(options) {
5167
+ this.currentETag = null;
5168
+ this.currentBundle = null;
5169
+ this.pollTimer = null;
5170
+ this.listeners = [];
5171
+ this.inflightRefetch = null;
5172
+ this.apiHost = options.apiHost;
5173
+ this.writeKey = options.writeKey;
5174
+ this.organizationId = options.organizationId;
5175
+ this.contactId = options.contactId;
5176
+ this.userId = options.userId;
5177
+ this.propertyId = options.propertyId;
5178
+ this.abAssignments = options.abAssignments ?? {};
5179
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_MS;
5180
+ this.contextProvider = options.contextProvider ?? (() => ({}));
5181
+ }
5182
+ /**
5183
+ * Initial fetch + start the background ETag poll. Resolves to the bundle
5184
+ * (or `null` if the first fetch failed — callers should fall back to an
5185
+ * empty campaign list, not abort SDK init).
5186
+ */
5187
+ async start() {
5188
+ const bundle = await this.fetch();
5189
+ this.pollTimer = setInterval(() => {
5190
+ this.fetch().catch((err) => logger.warn("prefetch-bundle poll failed:", err));
5191
+ }, this.pollIntervalMs);
5192
+ return bundle;
5193
+ }
5194
+ stop() {
5195
+ if (this.pollTimer) {
5196
+ clearInterval(this.pollTimer);
5197
+ this.pollTimer = null;
5198
+ }
5199
+ }
5200
+ getBundle() {
5201
+ return this.currentBundle;
5202
+ }
5203
+ getCampaigns() {
5204
+ var _a;
5205
+ return ((_a = this.currentBundle) == null ? void 0 : _a.campaigns) ?? [];
5206
+ }
5207
+ getInbox() {
5208
+ var _a;
5209
+ return ((_a = this.currentBundle) == null ? void 0 : _a.inbox) ?? { unread_count: 0, page: [], cursor: null };
5210
+ }
5211
+ /** Force a refresh (dedupes concurrent calls). Exposed so inbox mutations
5212
+ * and campaign-activation SSE messages can trigger a refetch on demand. */
5213
+ async refresh() {
5214
+ if (this.inflightRefetch) return this.inflightRefetch;
5215
+ this.inflightRefetch = this.fetch();
5216
+ try {
5217
+ return await this.inflightRefetch;
5218
+ } finally {
5219
+ this.inflightRefetch = null;
5220
+ }
5221
+ }
5222
+ /** Subscribe to bundle changes (emits on first fetch + on any content
5223
+ * change — ETag 304 does NOT fire the listener). Returns an unsubscribe. */
5224
+ onChange(listener) {
5225
+ this.listeners.push(listener);
5226
+ return () => {
5227
+ const idx = this.listeners.indexOf(listener);
5228
+ if (idx >= 0) this.listeners.splice(idx, 1);
5229
+ };
5230
+ }
5231
+ /**
5232
+ * Update the identity tuple — called by AegisInAppManager when the host
5233
+ * app resolves a contactId post-init (e.g., after login). Triggers an
5234
+ * immediate refresh since the eligible campaigns + inbox will differ.
5235
+ */
5236
+ updateIdentity(partial) {
5237
+ let changed = false;
5238
+ if (partial.contactId !== void 0 && partial.contactId !== this.contactId) {
5239
+ this.contactId = partial.contactId;
5240
+ changed = true;
5241
+ }
5242
+ if (partial.userId !== void 0 && partial.userId !== this.userId) {
5243
+ this.userId = partial.userId;
5244
+ changed = true;
5245
+ }
5246
+ if (partial.organizationId !== void 0 && partial.organizationId !== this.organizationId) {
5247
+ this.organizationId = partial.organizationId;
5248
+ changed = true;
5249
+ }
5250
+ if (partial.propertyId !== void 0 && partial.propertyId !== this.propertyId) {
5251
+ this.propertyId = partial.propertyId;
5252
+ changed = true;
5253
+ }
5254
+ if (changed) {
5255
+ this.currentETag = null;
5256
+ this.refresh().catch(
5257
+ (err) => logger.warn("prefetch-bundle identity-refresh failed:", err)
5258
+ );
5259
+ }
5260
+ }
5261
+ /** Update cached AB assignments. Called by downstream renderers after
5262
+ * they display a variant. Persisted to localStorage by higher layers. */
5263
+ setAbAssignments(assignments) {
5264
+ this.abAssignments = { ...assignments };
5265
+ }
5266
+ // ── internals ──────────────────────────────────────────────────────────
5267
+ async fetch() {
5268
+ const ctx = this.contextProvider();
5269
+ const qs = new URLSearchParams();
5270
+ if (ctx.device_type) qs.set("device_type", ctx.device_type);
5271
+ if (ctx.page_url) qs.set("page_url", ctx.page_url);
5272
+ if (ctx.geo) qs.set("geo", ctx.geo);
5273
+ qs.set("is_new_user", ctx.is_new_user ? "true" : "false");
5274
+ const url = `${this.apiHost}/v1/sdk/prefetch-bundle?${qs.toString()}`;
5275
+ const headers = {
5276
+ "X-Aegis-Write-Key": this.writeKey,
5277
+ Accept: "application/json"
5278
+ };
5279
+ if (this.organizationId) headers["X-Organization-ID"] = this.organizationId;
5280
+ if (this.contactId) headers["X-Contact-ID"] = this.contactId;
5281
+ if (this.userId) headers["X-User-ID"] = this.userId;
5282
+ if (this.propertyId) headers["X-Property-Id"] = this.propertyId;
5283
+ if (this.currentETag) headers["If-None-Match"] = this.currentETag;
5284
+ if (Object.keys(this.abAssignments).length > 0) {
5285
+ try {
5286
+ headers["X-AB-Assignments"] = btoa(JSON.stringify(this.abAssignments));
5287
+ } catch {
5288
+ }
5289
+ }
5290
+ let response;
5291
+ try {
5292
+ response = await fetch(url, { method: "GET", headers });
5293
+ } catch (err) {
5294
+ logger.warn("prefetch-bundle fetch network error:", err);
5295
+ return this.currentBundle;
5296
+ }
5297
+ if (response.status === 304) {
5298
+ return this.currentBundle;
5299
+ }
5300
+ if (!response.ok) {
5301
+ logger.warn(`prefetch-bundle fetch failed: HTTP ${response.status}`);
5302
+ return this.currentBundle;
5303
+ }
5304
+ const etag = response.headers.get("ETag");
5305
+ let bundle;
5306
+ try {
5307
+ bundle = await response.json();
5308
+ } catch (err) {
5309
+ logger.warn("prefetch-bundle invalid JSON:", err);
5310
+ return this.currentBundle;
5311
+ }
5312
+ if (etag) this.currentETag = etag;
5313
+ this.currentBundle = bundle;
5314
+ for (const l of this.listeners) {
5315
+ try {
5316
+ l(bundle);
5317
+ } catch (err) {
5318
+ logger.warn("prefetch-bundle listener threw:", err);
5319
+ }
5320
+ }
5321
+ return bundle;
5322
+ }
5323
+ }
5324
+ const DEFAULT_UNREAD_POLL = 6e4;
5325
+ const IDB_NAME = "aegis-inbox";
5326
+ const IDB_STORE = "messages";
5327
+ const IDB_VERSION = 1;
5328
+ class AegisInbox {
5329
+ constructor(config) {
5330
+ this.entries = [];
5331
+ this.unreadCount = 0;
5332
+ this.cursor = null;
5333
+ this.listeners = [];
5334
+ this.pollTimer = null;
5335
+ this.bundleUnsub = null;
5336
+ this.apiHost = config.apiHost;
5337
+ this.writeKey = config.writeKey;
5338
+ this.organizationId = config.organizationId;
5339
+ this.contactId = config.contactId;
5340
+ this.propertyId = config.propertyId;
5341
+ this.bundleClient = config.bundleClient;
5342
+ this.unreadPollIntervalMs = config.unreadPollIntervalMs ?? DEFAULT_UNREAD_POLL;
5343
+ }
5344
+ /** Hydrate from bundle (if available), restore the IDB cache (if any),
5345
+ * then start the background unread-count poll. */
5346
+ async initialize() {
5347
+ if (!this.contactId || !this.propertyId) {
5348
+ logger.warn("AegisInbox: no contact/property yet — staying dormant");
5349
+ return;
5350
+ }
5351
+ const cached = await this.readCache();
5352
+ if (cached.length > 0) {
5353
+ this.entries = cached;
5354
+ this.recomputeUnread();
5355
+ this.emit();
5356
+ }
5357
+ if (this.bundleClient) {
5358
+ const inbox = this.bundleClient.getInbox();
5359
+ this.seedFromBundle(inbox.page, inbox.unread_count);
5360
+ this.bundleUnsub = this.bundleClient.onChange((bundle) => {
5361
+ this.seedFromBundle(bundle.inbox.page, bundle.inbox.unread_count);
5362
+ });
5363
+ } else {
5364
+ await this.refreshList();
5365
+ }
5366
+ this.pollTimer = setInterval(() => {
5367
+ this.refreshUnreadCount().catch(
5368
+ (err) => logger.warn("inbox unread-count poll failed:", err)
5369
+ );
5370
+ }, this.unreadPollIntervalMs);
5371
+ }
5372
+ stop() {
5373
+ if (this.pollTimer) {
5374
+ clearInterval(this.pollTimer);
5375
+ this.pollTimer = null;
5376
+ }
5377
+ if (this.bundleUnsub) {
5378
+ this.bundleUnsub();
5379
+ this.bundleUnsub = null;
5380
+ }
5381
+ }
5382
+ /** Update identity after login / property switch. Clears the local
5383
+ * inbox since it's intrinsically contact+property scoped. */
5384
+ setIdentity(partial) {
5385
+ let changed = false;
5386
+ if (partial.contactId !== void 0 && partial.contactId !== this.contactId) {
5387
+ this.contactId = partial.contactId;
5388
+ changed = true;
5389
+ }
5390
+ if (partial.propertyId !== void 0 && partial.propertyId !== this.propertyId) {
5391
+ this.propertyId = partial.propertyId;
5392
+ changed = true;
5393
+ }
5394
+ if (partial.organizationId !== void 0 && partial.organizationId !== this.organizationId) {
5395
+ this.organizationId = partial.organizationId;
5396
+ changed = true;
5397
+ }
5398
+ if (changed) {
5399
+ this.entries = [];
5400
+ this.unreadCount = 0;
5401
+ this.cursor = null;
5402
+ this.emit();
5403
+ void this.clearCache();
5404
+ void this.refreshList();
5405
+ }
5406
+ }
5407
+ // ── Read-side API ────────────────────────────────────────────────────
5408
+ getEntries() {
5409
+ return this.entries;
5410
+ }
5411
+ getUnreadCount() {
5412
+ return this.unreadCount;
5413
+ }
5414
+ /** Pagination cursor (ISO-8601 timestamp of the oldest entry currently
5415
+ * held). Pass it back via `loadMore()` to fetch older history. */
5416
+ getCursor() {
5417
+ return this.cursor;
5418
+ }
5419
+ /** Append one more page of older entries using the current cursor. */
5420
+ async loadMore() {
5421
+ if (!this.contactId || !this.propertyId || !this.cursor) return;
5422
+ try {
5423
+ const url = `${this.apiHost}/v1/in-app/inbox?limit=20&cursor=${encodeURIComponent(this.cursor)}`;
5424
+ const resp = await fetch(url, { headers: this.headers() });
5425
+ if (!resp.ok) return;
5426
+ const body = await resp.json();
5427
+ const byId = new Set(this.entries.map((e) => e.id));
5428
+ for (const e of body.entries) {
5429
+ if (!byId.has(e.id)) this.entries.push(e);
5430
+ }
5431
+ this.cursor = body.cursor;
5432
+ void this.writeCache();
5433
+ this.emit();
5434
+ } catch (err) {
5435
+ logger.warn("inbox loadMore failed:", err);
5436
+ }
5437
+ }
5438
+ onChange(l) {
5439
+ this.listeners.push(l);
5440
+ return () => {
5441
+ const i = this.listeners.indexOf(l);
5442
+ if (i >= 0) this.listeners.splice(i, 1);
5443
+ };
5444
+ }
5445
+ async refreshList() {
5446
+ if (!this.contactId || !this.propertyId) return;
5447
+ try {
5448
+ const resp = await fetch(
5449
+ `${this.apiHost}/v1/in-app/inbox?limit=20`,
5450
+ { headers: this.headers() }
5451
+ );
5452
+ if (!resp.ok) {
5453
+ logger.warn(`inbox list fetch failed: HTTP ${resp.status}`);
5454
+ return;
5455
+ }
5456
+ const body = await resp.json();
5457
+ this.entries = body.entries;
5458
+ this.unreadCount = body.unread_count;
5459
+ this.cursor = body.cursor;
5460
+ void this.writeCache();
5461
+ this.emit();
5462
+ } catch (err) {
5463
+ logger.warn("inbox list fetch error:", err);
5464
+ }
5465
+ }
5466
+ async refreshUnreadCount() {
5467
+ if (!this.contactId || !this.propertyId) return;
5468
+ try {
5469
+ const resp = await fetch(
5470
+ `${this.apiHost}/v1/in-app/inbox/unread-count`,
5471
+ { headers: this.headers() }
5472
+ );
5473
+ if (!resp.ok) return;
5474
+ const body = await resp.json();
5475
+ if (body.unread_count !== this.unreadCount) {
5476
+ this.unreadCount = body.unread_count;
5477
+ this.emit();
5478
+ if (this.unreadCount > 0) {
5479
+ void this.refreshList();
5480
+ }
5481
+ }
5482
+ } catch {
5483
+ }
5484
+ }
5485
+ // ── Mutations (optimistic + beacon) ──────────────────────────────────
5486
+ async markRead(messageId) {
5487
+ const entry = this.entries.find((e) => e.id === messageId);
5488
+ if (!entry || entry.read) return;
5489
+ entry.read = true;
5490
+ entry.read_at = (/* @__PURE__ */ new Date()).toISOString();
5491
+ this.recomputeUnread();
5492
+ void this.writeCache();
5493
+ this.emit();
5494
+ await this.postBeacon(`/v1/in-app/inbox/${messageId}/read`);
5495
+ }
5496
+ async dismiss(messageId) {
5497
+ const idx = this.entries.findIndex((e) => e.id === messageId);
5498
+ if (idx < 0) return;
5499
+ const [entry] = this.entries.splice(idx, 1);
5500
+ if (entry && !entry.read) {
5501
+ this.recomputeUnread();
5502
+ }
5503
+ void this.writeCache();
5504
+ this.emit();
5505
+ await this.postBeacon(`/v1/in-app/inbox/${messageId}/dismiss`);
5506
+ }
5507
+ async markAllRead() {
5508
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5509
+ for (const e of this.entries) {
5510
+ if (!e.read) {
5511
+ e.read = true;
5512
+ e.read_at = now;
5513
+ }
5514
+ }
5515
+ this.unreadCount = 0;
5516
+ void this.writeCache();
5517
+ this.emit();
5518
+ await this.postBeacon("/v1/in-app/inbox/read-all");
5519
+ }
5520
+ // ── Internals ────────────────────────────────────────────────────────
5521
+ seedFromBundle(page, unreadCount) {
5522
+ const byId = new Map(this.entries.map((e) => [e.id, e]));
5523
+ const merged = page.map((incoming) => {
5524
+ const existing = byId.get(incoming.id);
5525
+ if (existing && existing.read && !incoming.read) {
5526
+ return { ...incoming, read: true, read_at: existing.read_at };
5527
+ }
5528
+ return incoming;
5529
+ });
5530
+ for (const [id, e] of byId) {
5531
+ if (!merged.find((m2) => m2.id === id)) merged.push(e);
5532
+ }
5533
+ merged.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
5534
+ this.entries = merged;
5535
+ this.unreadCount = unreadCount;
5536
+ void this.writeCache();
5537
+ this.emit();
5538
+ }
5539
+ recomputeUnread() {
5540
+ this.unreadCount = this.entries.filter((e) => !e.read).length;
5541
+ }
5542
+ emit() {
5543
+ const snapshot = { entries: [...this.entries], unreadCount: this.unreadCount };
5544
+ for (const l of this.listeners) {
5545
+ try {
5546
+ l(snapshot);
5547
+ } catch (err) {
5548
+ logger.warn("inbox listener threw:", err);
5549
+ }
5550
+ }
5551
+ }
5552
+ headers() {
5553
+ const h = {
5554
+ "X-Aegis-Write-Key": this.writeKey,
5555
+ Accept: "application/json"
5556
+ };
5557
+ if (this.organizationId) h["X-Organization-ID"] = this.organizationId;
5558
+ if (this.contactId) h["X-Contact-ID"] = this.contactId;
5559
+ if (this.propertyId) h["X-Property-Id"] = this.propertyId;
5560
+ return h;
5561
+ }
5562
+ /** Best-effort beacon-style POST. Uses `keepalive: true` so it survives
5563
+ * a navigation away from the current page (e.g., user taps the CTA
5564
+ * which takes them to another URL right after dismiss). */
5565
+ async postBeacon(path) {
5566
+ if (!this.contactId || !this.propertyId) return;
5567
+ try {
5568
+ await fetch(`${this.apiHost}${path}`, {
5569
+ method: "POST",
5570
+ headers: this.headers(),
5571
+ keepalive: true
5572
+ });
5573
+ } catch (err) {
5574
+ logger.warn(`inbox mutation POST ${path} failed:`, err);
5575
+ }
5576
+ }
5577
+ // ── IndexedDB cache (non-critical) ───────────────────────────────────
5578
+ async openDb() {
5579
+ if (typeof indexedDB === "undefined") return null;
5580
+ return new Promise((resolve) => {
5581
+ try {
5582
+ const req = indexedDB.open(IDB_NAME, IDB_VERSION);
5583
+ req.onerror = () => resolve(null);
5584
+ req.onupgradeneeded = () => {
5585
+ const db = req.result;
5586
+ if (!db.objectStoreNames.contains(IDB_STORE)) {
5587
+ db.createObjectStore(IDB_STORE);
5588
+ }
5589
+ };
5590
+ req.onsuccess = () => resolve(req.result);
5591
+ } catch {
5592
+ resolve(null);
5593
+ }
5594
+ });
5595
+ }
5596
+ cacheKey() {
5597
+ if (!this.contactId || !this.propertyId) return null;
5598
+ return `${this.organizationId || "default"}:${this.propertyId}:${this.contactId}`;
5599
+ }
5600
+ async writeCache() {
5601
+ const key = this.cacheKey();
5602
+ if (!key) return;
5603
+ const db = await this.openDb();
5604
+ if (!db) return;
5605
+ try {
5606
+ const tx = db.transaction(IDB_STORE, "readwrite");
5607
+ tx.objectStore(IDB_STORE).put(this.entries, key);
5608
+ await new Promise((res, rej) => {
5609
+ tx.oncomplete = () => res();
5610
+ tx.onerror = () => rej(tx.error);
5611
+ });
5612
+ } catch {
5613
+ } finally {
5614
+ db.close();
5615
+ }
5616
+ }
5617
+ async readCache() {
5618
+ const key = this.cacheKey();
5619
+ if (!key) return [];
5620
+ const db = await this.openDb();
5621
+ if (!db) return [];
5622
+ try {
5623
+ const tx = db.transaction(IDB_STORE, "readonly");
5624
+ const req = tx.objectStore(IDB_STORE).get(key);
5625
+ return await new Promise((res) => {
5626
+ req.onsuccess = () => res(req.result || []);
5627
+ req.onerror = () => res([]);
5628
+ });
5629
+ } catch {
5630
+ return [];
5631
+ } finally {
5632
+ db.close();
5633
+ }
5634
+ }
5635
+ async clearCache() {
5636
+ const key = this.cacheKey();
5637
+ if (!key) return;
5638
+ const db = await this.openDb();
5639
+ if (!db) return;
5640
+ try {
5641
+ const tx = db.transaction(IDB_STORE, "readwrite");
5642
+ tx.objectStore(IDB_STORE).delete(key);
5643
+ await new Promise((res) => {
5644
+ tx.oncomplete = () => res();
5645
+ tx.onerror = () => res();
5646
+ });
5647
+ } catch {
5648
+ } finally {
5649
+ db.close();
5650
+ }
5651
+ }
5652
+ }
4354
5653
  const COOKIE_NAME = "aegis_fpc";
4355
5654
  const COOKIE_MAX_AGE_DAYS = 365 * 2;
4356
5655
  class BootstrapError extends Error {
@@ -4428,6 +5727,7 @@ const aegis = new Aegis();
4428
5727
  export {
4429
5728
  Aegis,
4430
5729
  AegisInAppManager,
5730
+ AegisInbox,
4431
5731
  AegisPlacementManager,
4432
5732
  AegisWebPush,
4433
5733
  AegisWidgetManager,
@@ -4435,6 +5735,7 @@ export {
4435
5735
  BootstrapError,
4436
5736
  E as EcommerceTracker,
4437
5737
  N as NameGovernor,
5738
+ PrefetchBundleClient,
4438
5739
  R as RateLimiter,
4439
5740
  SdkConfigPoller,
4440
5741
  TriggerEngine,