@usereq/widget 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usereq/widget",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -19,7 +19,6 @@ import { widgetTriggerRuleKey } from "../shared/widget-config";
19
19
  import { WidgetRuntime, type WidgetRuntimeStatus } from "../renderer/index";
20
20
  import { buildAssistantMessageRevealSteps } from "../chat-widget";
21
21
  import { DEFAULT_STOP_CONVERSATION_PROMPT } from "../shared/stop-confirmation";
22
- import type { WidgetTriggerRuleType } from "../shared/analytics";
23
22
  import {
24
23
  bootstrapWidget,
25
24
  resetWidgetSession,
@@ -27,10 +26,6 @@ import {
27
26
  startWidgetConversation,
28
27
  submitWidgetMessage,
29
28
  } from "../runtime/bootstrap";
30
- import {
31
- createWidgetAnalyticsTracker,
32
- type WidgetAnalyticsTracker,
33
- } from "../runtime/analytics";
34
29
  import { getWidgetShadowCss } from "../shared/shadow-css";
35
30
  import { WIDGET_SHADOW_THEME_CSS } from "../shared/shadow-theme";
36
31
 
@@ -72,8 +67,6 @@ export class AgentWidgetElement extends HTMLElement {
72
67
  private sendVersion = 0;
73
68
  private confirmationVersion = 0;
74
69
  private bootstrapPhase: "idle" | "booting" | "ready" | "blocked" = "idle";
75
- private analyticsTrackerKey = "";
76
- private analyticsTracker: WidgetAnalyticsTracker | null = null;
77
70
  private autoStartTriggered = false;
78
71
 
79
72
  constructor() {
@@ -110,7 +103,6 @@ export class AgentWidgetElement extends HTMLElement {
110
103
 
111
104
  connectedCallback() {
112
105
  this.syncAttributes();
113
- this.recreateAnalyticsTracker();
114
106
  this.renderWidget();
115
107
  if (this.agentId) {
116
108
  void this.ensureBootstrap();
@@ -119,9 +111,6 @@ export class AgentWidgetElement extends HTMLElement {
119
111
 
120
112
  disconnectedCallback() {
121
113
  this.bootstrapPromise = null;
122
- this.analyticsTracker?.dispose();
123
- this.analyticsTracker = null;
124
- this.analyticsTrackerKey = "";
125
114
  this.root.unmount();
126
115
  }
127
116
 
@@ -131,7 +120,6 @@ export class AgentWidgetElement extends HTMLElement {
131
120
  newValue?: string | null,
132
121
  ) {
133
122
  this.syncAttributes();
134
- this.recreateAnalyticsTracker();
135
123
  if (name === "agent-id" && oldValue !== newValue) {
136
124
  this.clearAgentState();
137
125
  }
@@ -202,6 +190,10 @@ export class AgentWidgetElement extends HTMLElement {
202
190
  return this.sessionConfig?.widgetConfig.spotlightMessages ?? [];
203
191
  }
204
192
 
193
+ private getCtaLink(): string | null {
194
+ return this.sessionConfig?.widgetConfig.ctaLink ?? null;
195
+ }
196
+
205
197
  private clearAgentState() {
206
198
  this.sendVersion += 1;
207
199
  this.confirmationVersion += 1;
@@ -217,22 +209,6 @@ export class AgentWidgetElement extends HTMLElement {
217
209
  this.status = "idle";
218
210
  }
219
211
 
220
- private recreateAnalyticsTracker() {
221
- const nextKey = `${this.mode}:${this.agentId}`;
222
- if (nextKey === this.analyticsTrackerKey) {
223
- return;
224
- }
225
-
226
- this.analyticsTracker?.dispose();
227
- this.analyticsTracker = createWidgetAnalyticsTracker({
228
- mode: this.mode,
229
- agentId: this.agentId,
230
- getSession: () => this.sessionState,
231
- getConversationId: () => this.sessionState?.conversationId,
232
- });
233
- this.analyticsTrackerKey = nextKey;
234
- }
235
-
236
212
  private renderWidget() {
237
213
  if (
238
214
  this.bootstrapPhase !== "ready" ||
@@ -289,6 +265,7 @@ export class AgentWidgetElement extends HTMLElement {
289
265
  widgetTitle: this.getWidgetTitle(),
290
266
  welcomeMessage: this.getWelcomeMessage(),
291
267
  spotlights: this.getSpotlights(),
268
+ ctaLink: this.getCtaLink(),
292
269
  open: this.open,
293
270
  status: this.status,
294
271
  messages: this.messages,
@@ -305,10 +282,6 @@ export class AgentWidgetElement extends HTMLElement {
305
282
  onSendMessage: (content: string) => void this.sendMessage(content),
306
283
  onConfirmationDecision: (confirmationId: string, accepted: boolean) =>
307
284
  void this.handleConfirmationDecision(confirmationId, accepted),
308
- onWidgetVisible: (triggerRuleType: WidgetTriggerRuleType | null) =>
309
- this.analyticsTracker?.trackView(triggerRuleType),
310
- onLinkClick: (linkUrl: string) =>
311
- this.analyticsTracker?.trackLinkClicked(linkUrl),
312
285
  portalContainer: this.shadowRootRef,
313
286
  }),
314
287
  );
@@ -355,10 +328,6 @@ export class AgentWidgetElement extends HTMLElement {
355
328
  this.welcomeMessage = state.session.widgetConfig.welcomeMessage;
356
329
  this.status = "ready";
357
330
  this.bootstrapPhase = "ready";
358
- if (this.mode === "embed") {
359
- this.analyticsTracker?.trackScriptLoaded();
360
- this.analyticsTracker?.trackUniqueScriptLoaded();
361
- }
362
331
  this.renderWidget();
363
332
  } catch (error) {
364
333
  if (runVersion !== this.bootstrapVersion) {
@@ -461,12 +430,8 @@ export class AgentWidgetElement extends HTMLElement {
461
430
  }
462
431
 
463
432
  private async handleOpenChange(nextOpen: boolean) {
464
- const wasOpen = this.open;
465
433
  this.open = nextOpen;
466
434
  this.renderWidget();
467
- if (!wasOpen && nextOpen && this.mode === "embed") {
468
- this.analyticsTracker?.trackClick();
469
- }
470
435
 
471
436
  if (this.open && (this.status === "idle" || !this.sessionState)) {
472
437
  await this.ensureBootstrap();
package/src/index.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  export * from "./shared/widget-config";
2
- export * from "./shared/analytics";
3
2
  export * from "./renderer/index";
4
3
  export * from "./types";
5
4
  export * from "./shared/shadow-css";
@@ -27,7 +27,6 @@ import {
27
27
  } from "../shared/stop-confirmation";
28
28
  import { widgetCss } from "../styles/widget.css";
29
29
  import { useWidgetTriggerGate } from "../runtime/trigger-rule";
30
- import type { WidgetTriggerRuleType } from "../shared/analytics";
31
30
 
32
31
  export type WidgetRuntimeMode = "embed" | "preview";
33
32
 
@@ -49,6 +48,7 @@ export type WidgetRuntimeProps = {
49
48
  widgetTitle: string;
50
49
  welcomeMessage: string | null;
51
50
  spotlights: string[];
51
+ ctaLink?: string | null;
52
52
  open: boolean;
53
53
  status: WidgetRuntimeStatus;
54
54
  messages: WidgetMessage[];
@@ -64,8 +64,6 @@ export type WidgetRuntimeProps = {
64
64
  accepted: boolean,
65
65
  ) => void | Promise<void>;
66
66
  portalContainer?: HTMLElement | DocumentFragment | null;
67
- onWidgetVisible?: (triggerRuleType: WidgetTriggerRuleType | null) => void;
68
- onLinkClick?: (linkUrl: string) => void;
69
67
  onLauncherClick?: () => void;
70
68
  };
71
69
 
@@ -88,6 +86,7 @@ export function WidgetRuntime({
88
86
  widgetTitle,
89
87
  welcomeMessage,
90
88
  spotlights,
89
+ ctaLink,
91
90
  open,
92
91
  status,
93
92
  messages,
@@ -100,21 +99,10 @@ export function WidgetRuntime({
100
99
  onStopConversation,
101
100
  onConfirmationDecision,
102
101
  portalContainer,
103
- onWidgetVisible,
104
- onLinkClick,
105
102
  onLauncherClick,
106
103
  }: WidgetRuntimeProps) {
107
104
  const { ready } = useWidgetTriggerGate(triggerRule);
108
105
  const { horizontal, vertical } = splitWidgetPlacement(placement);
109
- const viewTrackedRef = React.useRef(false);
110
-
111
- React.useEffect(() => {
112
- if (mode !== "embed" || !ready || viewTrackedRef.current) {
113
- return;
114
- }
115
- viewTrackedRef.current = true;
116
- onWidgetVisible?.(triggerRule?.triggerRuleType ?? null);
117
- }, [mode, onWidgetVisible, ready, triggerRule]);
118
106
 
119
107
  const { panelMessages, confirmation } = React.useMemo(
120
108
  () => splitConfirmationFromMessages(messages, onConfirmationDecision),
@@ -141,12 +129,14 @@ export function WidgetRuntime({
141
129
  appearance: toChatAppearance(appearance),
142
130
  welcomeMessage,
143
131
  spotlights,
132
+ ctaLink,
144
133
  actionLabel: appearance.actionText || undefined,
145
134
  showStopConversation: Boolean(onStopConversation),
146
135
  }),
147
136
  [
148
137
  agentAvatarUrl,
149
138
  appearance,
139
+ ctaLink,
150
140
  onStopConversation,
151
141
  placement,
152
142
  preset,
@@ -219,7 +209,6 @@ export function WidgetRuntime({
219
209
  onStopConversation ? handleStop : undefined
220
210
  }
221
211
  onLauncherClick={onLauncherClick}
222
- onLinkClick={onLinkClick}
223
212
  emptyAction={wrappedEmptyAction}
224
213
  confirmation={confirmation}
225
214
  portalContainer={portalEl}
@@ -1,5 +1,4 @@
1
1
  import type {
2
- WidgetAnalyticsBatchRequest,
3
2
  WidgetConversation,
4
3
  WidgetMessage,
5
4
  WidgetPublicChatSendChatResponse,
@@ -91,33 +90,6 @@ export async function sendPublicChatConfirmation(input: {
91
90
  );
92
91
  }
93
92
 
94
- export async function sendWidgetAnalyticsBatch(
95
- input: WidgetAnalyticsBatchRequest,
96
- ): Promise<void> {
97
- const response = await fetch(
98
- new URL("/api/widget/events/batch", `${getWidgetApiBase()}/`).toString(),
99
- {
100
- method: "POST",
101
- headers: input.sessionToken
102
- ? { Authorization: `Bearer ${input.sessionToken}` }
103
- : undefined,
104
- body: JSON.stringify(input),
105
- credentials: "omit",
106
- keepalive: true,
107
- },
108
- );
109
-
110
- if (!response.ok) {
111
- const text = await response.text();
112
- const payload = text ? JSON.parse(text) : null;
113
- throw new Error(
114
- typeof payload === "object" && payload && "error" in payload
115
- ? String((payload as { error?: string }).error ?? response.statusText)
116
- : response.statusText,
117
- );
118
- }
119
- }
120
-
121
93
  export async function getCurrentConversation(sessionToken: string): Promise<{
122
94
  conversation: WidgetConversation | null;
123
95
  }> {
package/src/types.ts CHANGED
@@ -25,14 +25,6 @@ export {
25
25
  widgetAppearanceToAttributes,
26
26
  type WidgetTriggerRule,
27
27
  } from "./shared/widget-config";
28
- export type {
29
- WidgetAnalyticsBatchEvent,
30
- WidgetAnalyticsBatchRequest,
31
- WidgetAnalyticsBatchResponse,
32
- WidgetAnalyticsEventType,
33
- WidgetClientAnalyticsEventType,
34
- WidgetTriggerRuleType,
35
- } from "./shared/analytics";
36
28
 
37
29
  export type WidgetSessionConfig = {
38
30
  sessionToken: string;
@@ -43,6 +35,7 @@ export type WidgetSessionConfig = {
43
35
  agentAvatarUrl: string | null;
44
36
  welcomeMessage: string | null;
45
37
  spotlightMessages: string[];
38
+ ctaLink: string | null;
46
39
  widgetAppearance: WidgetAppearance;
47
40
  widgetBehavior: WidgetBehavior;
48
41
  widgetVariant: WidgetVariant;
@@ -1,431 +0,0 @@
1
- import type { WidgetSessionState } from "../types";
2
- import type {
3
- WidgetAnalyticsBatchEvent,
4
- WidgetAnalyticsBatchRequest,
5
- WidgetClientAnalyticsEventType,
6
- WidgetTriggerRuleType,
7
- } from "../shared/analytics";
8
- import { getWidgetApiBase } from "./api-origin";
9
-
10
- const ANALYTICS_ENABLED = false;
11
-
12
- const ANALYTICS_ENDPOINT = "/api/widget/events/batch";
13
- const DEFAULT_BATCH_SIZE = 10;
14
- const DEFAULT_FLUSH_INTERVAL_MS = 2500;
15
- const MAX_FLUSH_INTERVAL_MS = 120_000;
16
- const MAX_QUEUE_SIZE = 200;
17
- const MAX_EVENT_ATTEMPTS = 5;
18
- const MAX_EVENT_AGE_MS = 10 * 60 * 1000;
19
- const UNIQUE_SCRIPT_LOADED_KEY_PREFIX =
20
- "usereq-widget:unique-script-loaded:";
21
-
22
- type QueuedAnalyticsEvent = {
23
- event: WidgetAnalyticsBatchEvent;
24
- sessionToken: string;
25
- attempts: number;
26
- enqueuedAt: number;
27
- };
28
-
29
- type SendOutcome =
30
- | { outcome: "ok" }
31
- | { outcome: "drop" }
32
- | { outcome: "retry"; retryAfterMs?: number };
33
-
34
- function parseRetryAfter(headerValue: string | null): number | undefined {
35
- if (!headerValue) return undefined;
36
- const trimmed = headerValue.trim();
37
- if (!trimmed) return undefined;
38
- const seconds = Number(trimmed);
39
- if (Number.isFinite(seconds) && seconds >= 0) {
40
- return Math.min(MAX_FLUSH_INTERVAL_MS, seconds * 1000);
41
- }
42
- const dateMs = Date.parse(trimmed);
43
- if (!Number.isNaN(dateMs)) {
44
- const delta = dateMs - Date.now();
45
- if (delta > 0) return Math.min(MAX_FLUSH_INTERVAL_MS, delta);
46
- }
47
- return undefined;
48
- }
49
-
50
- function classifyResponse(response: Response): SendOutcome {
51
- if (response.ok) return { outcome: "ok" };
52
- const status = response.status;
53
- if (status === 408 || status === 425 || status === 429 || status >= 500) {
54
- return {
55
- outcome: "retry",
56
- retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
57
- };
58
- }
59
- if (status >= 400 && status < 500) return { outcome: "drop" };
60
- return { outcome: "retry" };
61
- }
62
-
63
- export type WidgetAnalyticsTrackerOptions = {
64
- mode: "embed" | "preview";
65
- agentId: string;
66
- getSession: () => WidgetSessionState | null;
67
- getConversationId: () => string | undefined;
68
- batchSize?: number;
69
- flushIntervalMs?: number;
70
- };
71
-
72
- export class WidgetAnalyticsTracker {
73
- private readonly mode: "embed" | "preview";
74
- private readonly agentId: string;
75
- private readonly getSession: () => WidgetSessionState | null;
76
- private readonly getConversationId: () => string | undefined;
77
- private readonly batchSize: number;
78
- private readonly flushIntervalMs: number;
79
- private readonly uniqueScriptSeen = new Set<string>();
80
- private flushTimer: number | null = null;
81
- private queue: QueuedAnalyticsEvent[] = [];
82
- private disposed = false;
83
- private consecutiveFailures = 0;
84
- private nextAllowedFlushAt = 0;
85
-
86
- constructor(options: WidgetAnalyticsTrackerOptions) {
87
- this.mode = options.mode;
88
- this.agentId = options.agentId;
89
- this.getSession = options.getSession;
90
- this.getConversationId = options.getConversationId;
91
- this.batchSize = Math.max(1, options.batchSize ?? DEFAULT_BATCH_SIZE);
92
- this.flushIntervalMs = Math.max(
93
- 200,
94
- options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
95
- );
96
-
97
- if (ANALYTICS_ENABLED && this.mode === "embed") {
98
- window.addEventListener("pagehide", this.handlePageHide, {
99
- capture: true,
100
- });
101
- document.addEventListener(
102
- "visibilitychange",
103
- this.handleVisibilityChange,
104
- true,
105
- );
106
- }
107
- }
108
-
109
- dispose() {
110
- if (this.disposed) return;
111
- this.disposed = true;
112
- this.clearFlushTimer();
113
- if (!ANALYTICS_ENABLED) return;
114
- window.removeEventListener("pagehide", this.handlePageHide, true);
115
- document.removeEventListener(
116
- "visibilitychange",
117
- this.handleVisibilityChange,
118
- true,
119
- );
120
- void this.flush({ transport: "beacon" });
121
- }
122
-
123
- trackScriptLoaded() {
124
- this.track({ eventType: "SCRIPT_LOADED" });
125
- }
126
-
127
- trackUniqueScriptLoaded() {
128
- if (!ANALYTICS_ENABLED) return;
129
- const sessionToken = this.getSession()?.sessionToken?.trim();
130
- if (!sessionToken) return;
131
- const uniqueKey = `${this.agentId}:${sessionToken}`;
132
-
133
- if (this.uniqueScriptSeen.has(uniqueKey)) return;
134
- if (this.hasSessionUniqueScriptLoaded(uniqueKey)) {
135
- this.uniqueScriptSeen.add(uniqueKey);
136
- return;
137
- }
138
-
139
- this.uniqueScriptSeen.add(uniqueKey);
140
- this.setSessionUniqueScriptLoaded(uniqueKey);
141
- this.track({ eventType: "UNIQUE_SCRIPT_LOADED" });
142
- }
143
-
144
- trackView(triggerRuleType: WidgetTriggerRuleType | null) {
145
- this.track({ eventType: "VIEW", triggerRuleType });
146
- if (triggerRuleType === "element_displayed") {
147
- this.track({
148
- eventType: "DISPLAY_AFTER_ELEMENT_SHOWED",
149
- triggerRuleType,
150
- });
151
- }
152
- }
153
-
154
- trackClick() {
155
- this.track({ eventType: "CLICK" });
156
- }
157
-
158
- trackLinkClicked(linkUrl: string) {
159
- const raw = linkUrl.trim();
160
- if (!raw) return;
161
- const resolvedUrl = this.normalizeLinkUrl(raw);
162
- this.track({ eventType: "LINK_CLICKED", linkUrl: resolvedUrl });
163
- }
164
-
165
- track(event: Omit<WidgetAnalyticsBatchEvent, "occurredAt"> & {
166
- occurredAt?: string;
167
- }) {
168
- if (!ANALYTICS_ENABLED) return;
169
- if (this.disposed || this.mode !== "embed") return;
170
-
171
- const sessionToken = this.getSession()?.sessionToken?.trim();
172
- if (!sessionToken) return;
173
-
174
- const { occurredAt, conversationId, triggerRuleType, linkUrl, ...rest } = event;
175
- const resolvedConversationId = conversationId ?? this.getConversationId();
176
- const queuedEvent: WidgetAnalyticsBatchEvent = {
177
- ...rest,
178
- occurredAt: occurredAt ?? new Date().toISOString(),
179
- ...(resolvedConversationId ? { conversationId: resolvedConversationId } : {}),
180
- ...(triggerRuleType ? { triggerRuleType } : {}),
181
- ...(linkUrl ? { linkUrl } : {}),
182
- };
183
-
184
- this.enqueue({
185
- event: queuedEvent,
186
- sessionToken,
187
- attempts: 0,
188
- enqueuedAt: Date.now(),
189
- });
190
-
191
- if (this.queue.length >= this.batchSize) {
192
- void this.flush();
193
- return;
194
- }
195
-
196
- this.scheduleFlush();
197
- }
198
-
199
- private enqueue(entry: QueuedAnalyticsEvent) {
200
- this.queue.push(entry);
201
- if (this.queue.length > MAX_QUEUE_SIZE) {
202
- this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE);
203
- }
204
- }
205
-
206
- private pruneStale(now: number) {
207
- if (this.queue.length === 0) return;
208
- this.queue = this.queue.filter(
209
- (entry) => now - entry.enqueuedAt < MAX_EVENT_AGE_MS,
210
- );
211
- }
212
-
213
- async flush(options?: {
214
- transport?: "default" | "beacon";
215
- }): Promise<void> {
216
- if (!ANALYTICS_ENABLED) return;
217
- if (this.disposed) return;
218
-
219
- const now = Date.now();
220
- this.pruneStale(now);
221
- if (this.queue.length === 0) {
222
- this.clearFlushTimer();
223
- return;
224
- }
225
-
226
- this.clearFlushTimer();
227
- const transport = options?.transport ?? "default";
228
- const snapshot = this.queue;
229
- this.queue = [];
230
-
231
- const grouped = new Map<string, QueuedAnalyticsEvent[]>();
232
- for (const entry of snapshot) {
233
- const group = grouped.get(entry.sessionToken);
234
- if (group) {
235
- group.push(entry);
236
- } else {
237
- grouped.set(entry.sessionToken, [entry]);
238
- }
239
- }
240
-
241
- const failed: QueuedAnalyticsEvent[] = [];
242
- let maxRetryAfterMs = 0;
243
- let anyRetry = false;
244
- let anySuccess = false;
245
-
246
- for (const [sessionToken, entries] of grouped.entries()) {
247
- const result = await this.sendBatch({
248
- events: entries.map((entry) => entry.event),
249
- sessionToken,
250
- transport,
251
- });
252
-
253
- if (result.outcome === "ok") {
254
- anySuccess = true;
255
- continue;
256
- }
257
- if (result.outcome === "drop") {
258
- continue;
259
- }
260
- anyRetry = true;
261
- if (result.retryAfterMs && result.retryAfterMs > maxRetryAfterMs) {
262
- maxRetryAfterMs = result.retryAfterMs;
263
- }
264
- for (const entry of entries) {
265
- const attempts = entry.attempts + 1;
266
- if (attempts >= MAX_EVENT_ATTEMPTS) continue;
267
- failed.push({ ...entry, attempts });
268
- }
269
- }
270
-
271
- if (anySuccess && !anyRetry) {
272
- this.consecutiveFailures = 0;
273
- this.nextAllowedFlushAt = 0;
274
- } else if (anyRetry) {
275
- this.consecutiveFailures += 1;
276
- }
277
-
278
- if (failed.length > 0) {
279
- for (const entry of failed) {
280
- this.enqueue(entry);
281
- }
282
- const backoff = this.computeBackoffDelay();
283
- const delay = Math.max(backoff, maxRetryAfterMs);
284
- this.nextAllowedFlushAt = Date.now() + delay;
285
- this.scheduleFlush(delay);
286
- }
287
- }
288
-
289
- private computeBackoffDelay(): number {
290
- const exponent = Math.max(0, this.consecutiveFailures - 1);
291
- const base = this.flushIntervalMs * Math.pow(2, exponent);
292
- const jitter = base * 0.2 * Math.random();
293
- return Math.min(MAX_FLUSH_INTERVAL_MS, base + jitter);
294
- }
295
-
296
- private async sendBatch(input: {
297
- events: WidgetAnalyticsBatchEvent[];
298
- sessionToken: string;
299
- transport: "default" | "beacon";
300
- }): Promise<SendOutcome> {
301
- if (input.events.length === 0) return { outcome: "ok" };
302
-
303
- const url = new URL(ANALYTICS_ENDPOINT, `${getWidgetApiBase()}/`).toString();
304
- const sanitizedEvents = input.events.map((event) => {
305
- const { conversationId, triggerRuleType, linkUrl, ...rest } = event;
306
- return {
307
- ...rest,
308
- ...(conversationId ? { conversationId } : {}),
309
- ...(triggerRuleType ? { triggerRuleType } : {}),
310
- ...(linkUrl ? { linkUrl } : {}),
311
- };
312
- });
313
- const payload: WidgetAnalyticsBatchRequest = {
314
- events: sanitizedEvents,
315
- pageUrl: window.location.href,
316
- referrer: document.referrer || undefined,
317
- // sendBeacon cannot set Authorization header, so include token in body as fallback.
318
- sessionToken: input.sessionToken,
319
- };
320
-
321
- if (input.transport === "beacon" && typeof navigator.sendBeacon === "function") {
322
- const body = new Blob([JSON.stringify(payload)], {
323
- type: "application/json",
324
- });
325
- if (navigator.sendBeacon(url, body)) {
326
- return { outcome: "ok" };
327
- }
328
- }
329
-
330
- try {
331
- const response = await fetch(url, {
332
- method: "POST",
333
- headers: {
334
- Authorization: `Bearer ${input.sessionToken}`,
335
- "Content-Type": "application/json",
336
- },
337
- body: JSON.stringify(payload),
338
- credentials: "omit",
339
- keepalive: input.transport === "beacon",
340
- });
341
- return classifyResponse(response);
342
- } catch {
343
- return { outcome: "retry" };
344
- }
345
- }
346
-
347
- private scheduleFlush(delayMs?: number) {
348
- if (this.flushTimer != null) return;
349
- const delay = Math.max(0, delayMs ?? this.flushIntervalMs);
350
- this.flushTimer = window.setTimeout(() => {
351
- this.flushTimer = null;
352
- void this.flush();
353
- }, delay);
354
- }
355
-
356
- private clearFlushTimer() {
357
- if (this.flushTimer == null) return;
358
- window.clearTimeout(this.flushTimer);
359
- this.flushTimer = null;
360
- }
361
-
362
- private hasSessionUniqueScriptLoaded(uniqueKey: string): boolean {
363
- try {
364
- return (
365
- sessionStorage.getItem(`${UNIQUE_SCRIPT_LOADED_KEY_PREFIX}${uniqueKey}`) ===
366
- "1"
367
- );
368
- } catch {
369
- return false;
370
- }
371
- }
372
-
373
- private setSessionUniqueScriptLoaded(uniqueKey: string) {
374
- try {
375
- sessionStorage.setItem(
376
- `${UNIQUE_SCRIPT_LOADED_KEY_PREFIX}${uniqueKey}`,
377
- "1",
378
- );
379
- } catch {
380
- // no-op
381
- }
382
- }
383
-
384
- private readonly handlePageHide = () => {
385
- void this.flush({ transport: "beacon" });
386
- };
387
-
388
- private readonly handleVisibilityChange = () => {
389
- if (document.visibilityState === "hidden") {
390
- void this.flush({ transport: "beacon" });
391
- }
392
- };
393
-
394
- private normalizeLinkUrl(value: string): string {
395
- try {
396
- return new URL(value, window.location.href).toString();
397
- } catch {
398
- return value;
399
- }
400
- }
401
- }
402
-
403
- export function createWidgetAnalyticsTracker(
404
- options: WidgetAnalyticsTrackerOptions,
405
- ): WidgetAnalyticsTracker {
406
- return new WidgetAnalyticsTracker(options);
407
- }
408
-
409
- export function isWidgetTriggerRuleType(
410
- value: string | null | undefined,
411
- ): value is WidgetTriggerRuleType {
412
- return (
413
- value === "time_delay" ||
414
- value === "scroll_to_bottom" ||
415
- value === "element_clicked" ||
416
- value === "element_displayed"
417
- );
418
- }
419
-
420
- export function isWidgetClientAnalyticsEventType(
421
- value: string | null | undefined,
422
- ): value is WidgetClientAnalyticsEventType {
423
- return (
424
- value === "SCRIPT_LOADED" ||
425
- value === "UNIQUE_SCRIPT_LOADED" ||
426
- value === "VIEW" ||
427
- value === "CLICK" ||
428
- value === "DISPLAY_AFTER_ELEMENT_SHOWED" ||
429
- value === "LINK_CLICKED"
430
- );
431
- }