@usereq/widget 0.1.3 → 0.1.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.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -10,25 +10,46 @@ import {
10
10
  } from "@usereq/ui/components/avatar";
11
11
  import { Button } from "@usereq/ui/components/button";
12
12
  import { Textarea } from "@usereq/ui/components/textarea";
13
- import { X, SendHorizonal, Phone, Video, Info, ThumbsUp } from "lucide-react";
13
+ import {
14
+ ChevronDown,
15
+ SendHorizonal,
16
+ Phone,
17
+ Video,
18
+ Info,
19
+ } from "lucide-react";
14
20
  import {
15
21
  DEFAULT_AGENT_NAME_BUBBLE,
16
- DEFAULT_COMPOSER_PLACEHOLDER,
17
22
  DEFAULT_POWERED_BY,
18
23
  DEFAULT_SPOTLIGHT_MESSAGES,
19
24
  } from "../chat-widget-defaults";
20
25
  import {
21
- ChatWidgetMessages,
22
26
  ChatWidgetPanel,
23
27
  ChatWidgetPoweredBy,
28
+ ChatWidgetTyping,
24
29
  useIsChatWidgetFullscreenViewport,
25
30
  useSpotlightFade,
26
31
  type ChatWidgetDataProps,
32
+ type ChatWidgetPreviewMessage,
27
33
  } from "../components/chat-widget-primitives";
34
+ import {
35
+ Conversation,
36
+ ConversationContent,
37
+ ConversationEmptyState,
38
+ ConversationScrollButton,
39
+ } from "../components/conversation";
40
+ import { foldStopConfirmationMessages } from "../stop-confirmation";
41
+ import { getChatWidgetMessagesLayoutClasses } from "../components/chat-widget-layout";
42
+ import {
43
+ Message,
44
+ MessageContent,
45
+ MessageResponse,
46
+ } from "../components/message";
28
47
 
29
48
  const MESSENGER_GRADIENT =
30
49
  "linear-gradient(135deg, #00B2FF 0%, #006AFF 50%, #8E5BFF 100%)";
31
50
 
51
+ const MESSENGER_BRAND_COLOR = "#0084FF";
52
+
32
53
  const PANEL_WIDTH = 372;
33
54
  const PANEL_HEIGHT = 580;
34
55
  const LAUNCHER_SIZE = 60;
@@ -40,7 +61,6 @@ const fadeTransition = { duration: 0.35 };
40
61
  export function ChatWidgetMessenger({
41
62
  agentName = DEFAULT_AGENT_NAME_BUBBLE,
42
63
  agentAvatarUrl,
43
- appearance,
44
64
  spotlightMessages = [],
45
65
  messages = [],
46
66
  poweredBy = DEFAULT_POWERED_BY,
@@ -50,7 +70,6 @@ export function ChatWidgetMessenger({
50
70
  sendDisabled = false,
51
71
  onSendMessage,
52
72
  isTyping,
53
- onSuggestion,
54
73
  onLauncherClick,
55
74
  className,
56
75
  open,
@@ -120,7 +139,7 @@ export function ChatWidgetMessenger({
120
139
  transition={fadeTransition}
121
140
  className="absolute right-0 bottom-[calc(60px+10px)] flex w-full max-w-70 flex-col items-end"
122
141
  >
123
- <div className="rounded-3xl bg-white px-4 py-2.5 text-right shadow-[0_4px_16px_rgba(0,0,0,0.12)]">
142
+ <div className="rounded-3xl bg-background px-4 py-2.5 text-right shadow-[0_4px_16px_rgba(0,0,0,0.12)]">
124
143
  <p className="text-[14px] leading-snug text-foreground">
125
144
  {spotlightText}
126
145
  </p>
@@ -134,7 +153,7 @@ export function ChatWidgetMessenger({
134
153
  open={effectiveOpen}
135
154
  onOpenChange={handleOpenChange}
136
155
  portalContainer={portalContainer}
137
- desktopClassName="absolute right-0 overflow-hidden rounded-3xl bg-background shadow-[0_8px_32px_rgba(0,0,0,0.16)] border border-black/5"
156
+ desktopClassName="absolute right-0 overflow-hidden rounded-3xl bg-background shadow-[0_8px_32px_rgba(0,0,0,0.16)] border border-border"
138
157
  desktopStyle={{
139
158
  width: PANEL_WIDTH,
140
159
  height: PANEL_HEIGHT,
@@ -149,20 +168,16 @@ export function ChatWidgetMessenger({
149
168
  onStopConversation={onStopConversation ?? undefined}
150
169
  stopDisabled={stopDisabled}
151
170
  />
152
- <div className="flex min-h-0 flex-1 flex-col bg-[#F5F5F7] dark:bg-background overflow-hidden">
153
- <ChatWidgetMessages
171
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
172
+ <MessengerMessages
154
173
  messages={messages}
155
174
  isTyping={isTyping ?? status === "sending"}
156
175
  status={status}
157
- suggestions={spotlightForFade}
158
176
  errorMessage={errorMessage}
159
- appearance={appearance}
177
+ agentName={agentName}
178
+ agentAvatarUrl={agentAvatarUrl}
160
179
  onConfirmationDecision={onConfirmationDecision}
161
180
  onLinkClick={onLinkClick}
162
- onSuggestion={(value: string) => {
163
- setDraftValue(value);
164
- onSuggestion?.(value);
165
- }}
166
181
  emptyAction={emptyAction}
167
182
  />
168
183
  </div>
@@ -199,7 +214,7 @@ export function ChatWidgetMessenger({
199
214
  style={{
200
215
  width: LAUNCHER_SIZE,
201
216
  height: LAUNCHER_SIZE,
202
- background: MESSENGER_GRADIENT,
217
+ backgroundColor: MESSENGER_BRAND_COLOR,
203
218
  boxShadow: "0 6px 20px rgba(0, 132, 255, 0.45)",
204
219
  }}
205
220
  aria-label={effectiveOpen ? "Close chat" : "Open chat"}
@@ -209,7 +224,7 @@ export function ChatWidgetMessenger({
209
224
  whileTap={previewOnly ? undefined : { scale: 0.96 }}
210
225
  >
211
226
  {effectiveOpen ? (
212
- <X className="size-7" strokeWidth={2.4} />
227
+ <ChevronDown className="size-7" strokeWidth={2.4} />
213
228
  ) : (
214
229
  <MessengerLogo className="size-8" />
215
230
  )}
@@ -219,6 +234,169 @@ export function ChatWidgetMessenger({
219
234
  );
220
235
  }
221
236
 
237
+ function MessengerMessages({
238
+ messages,
239
+ isTyping,
240
+ status,
241
+ errorMessage,
242
+ agentName,
243
+ agentAvatarUrl,
244
+ onConfirmationDecision,
245
+ onLinkClick,
246
+ emptyAction,
247
+ }: {
248
+ messages: ChatWidgetPreviewMessage[];
249
+ isTyping: boolean;
250
+ status: ChatWidgetDataProps["status"];
251
+ errorMessage?: string | null;
252
+ agentName: string;
253
+ agentAvatarUrl?: string | null;
254
+ onConfirmationDecision?: ChatWidgetDataProps["onConfirmationDecision"];
255
+ onLinkClick?: (linkUrl: string) => void;
256
+ emptyAction?: { label: string; onClick: () => void; disabled?: boolean };
257
+ }) {
258
+ const { scrollClassName, contentClassName } =
259
+ getChatWidgetMessagesLayoutClasses();
260
+ const renderedMessages = React.useMemo(
261
+ () => foldStopConfirmationMessages(messages),
262
+ [messages],
263
+ );
264
+
265
+ if (messages.length === 0 && !isTyping) {
266
+ if (status === "error") {
267
+ return (
268
+ <div className="flex h-full items-center justify-center px-6 py-8 text-center text-sm text-destructive">
269
+ {errorMessage ?? "Something went wrong."}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ return (
275
+ <div className="flex min-h-full flex-1 items-center justify-center px-6 py-8">
276
+ <ConversationEmptyState className="w-full max-w-[280px] gap-4">
277
+ <Avatar
278
+ className="size-16 ring-4 ring-background"
279
+ style={{
280
+ boxShadow: "0 6px 18px rgba(0, 132, 255, 0.25)",
281
+ }}
282
+ >
283
+ {agentAvatarUrl ? (
284
+ <AvatarImage src={agentAvatarUrl} alt={agentName} />
285
+ ) : null}
286
+ <AvatarFallback
287
+ className="text-white text-base font-semibold"
288
+ style={{ backgroundColor: MESSENGER_BRAND_COLOR }}
289
+ >
290
+ {agentName.slice(0, 2).toUpperCase()}
291
+ </AvatarFallback>
292
+ </Avatar>
293
+ <div>
294
+ <p className="text-[15px] font-semibold leading-tight text-foreground">
295
+ {agentName}
296
+ </p>
297
+ <p className="mt-1.5 text-[13px] leading-snug text-muted-foreground">
298
+ Typically replies in a few minutes.
299
+ </p>
300
+ </div>
301
+ {emptyAction && (
302
+ <Button
303
+ size="lg"
304
+ disabled={emptyAction.disabled}
305
+ onClick={emptyAction.onClick}
306
+ className="rounded-full px-6 text-white shadow-[0_4px_12px_rgba(0,132,255,0.35)] hover:opacity-95 disabled:opacity-60"
307
+ style={{ backgroundColor: MESSENGER_BRAND_COLOR }}
308
+ >
309
+ {emptyAction.label}
310
+ </Button>
311
+ )}
312
+ </ConversationEmptyState>
313
+ </div>
314
+ );
315
+ }
316
+
317
+ return (
318
+ <Conversation className="relative size-full">
319
+ <ConversationContent
320
+ scrollClassName={scrollClassName}
321
+ className={cn(contentClassName, "gap-1.5 px-3 py-3")}
322
+ >
323
+ {renderedMessages.map((item) => {
324
+ if (item.type === "confirmation") {
325
+ return (
326
+ <Message key={item.message.id} from="assistant">
327
+ <div className="flex w-fit max-w-[80%] shrink-0 flex-col gap-2">
328
+ <MessageResponse
329
+ confirmation={item.latestEnvelope}
330
+ confirmationPrompt={item.envelope.prompt}
331
+ confirmationActionsDisabled={status === "sending"}
332
+ onConfirmationDecision={onConfirmationDecision}
333
+ />
334
+ </div>
335
+ </Message>
336
+ );
337
+ }
338
+
339
+ const m = item.message;
340
+ const isUser = m.role === "user";
341
+ return (
342
+ <Message key={m.id} from={m.role}>
343
+ <div className="flex w-fit max-w-[80%] shrink-0 flex-col gap-1.5">
344
+ <MessageContent
345
+ className={cn(
346
+ "whitespace-pre-wrap rounded-[18px] px-3.5 py-2 text-[14px] leading-snug",
347
+ isUser
348
+ ? "text-white"
349
+ : "bg-secondary text-secondary-foreground",
350
+ )}
351
+ style={
352
+ isUser ? { background: MESSENGER_GRADIENT } : undefined
353
+ }
354
+ >
355
+ {isUser ? (
356
+ m.content
357
+ ) : (
358
+ <MessageResponse onLinkClick={onLinkClick}>
359
+ {m.content}
360
+ </MessageResponse>
361
+ )}
362
+ </MessageContent>
363
+ {m.actions && m.actions.length > 0 && (
364
+ <div className="flex flex-wrap gap-2">
365
+ {m.actions.map((action, i) => (
366
+ <Button
367
+ key={i}
368
+ size="sm"
369
+ variant={action.variant ?? "outline"}
370
+ onClick={action.onClick}
371
+ className="rounded-full"
372
+ >
373
+ {action.label}
374
+ </Button>
375
+ ))}
376
+ </div>
377
+ )}
378
+ </div>
379
+ </Message>
380
+ );
381
+ })}
382
+ {isTyping && (
383
+ <motion.div
384
+ initial={{ opacity: 0, y: 6 }}
385
+ animate={{ opacity: 1, y: 0 }}
386
+ transition={{ duration: 0.2, ease: "easeOut" }}
387
+ className="flex w-full justify-start"
388
+ >
389
+ <div className="flex max-w-[80%] items-center rounded-[18px] bg-secondary px-3.5 py-2 text-secondary-foreground">
390
+ <ChatWidgetTyping className="bg-transparent! p-0!" />
391
+ </div>
392
+ </motion.div>
393
+ )}
394
+ </ConversationContent>
395
+ <ConversationScrollButton />
396
+ </Conversation>
397
+ );
398
+ }
399
+
222
400
  function MessengerHeader({
223
401
  agentName,
224
402
  agentAvatarUrl,
@@ -237,7 +415,7 @@ function MessengerHeader({
237
415
  return (
238
416
  <div
239
417
  className="flex shrink-0 items-center gap-3 px-4 py-3 text-white"
240
- style={{ background: MESSENGER_GRADIENT }}
418
+ style={{ backgroundColor: MESSENGER_BRAND_COLOR }}
241
419
  >
242
420
  <div className="relative">
243
421
  <Avatar size="lg" className="ring-2 ring-white/30">
@@ -287,11 +465,11 @@ function MessengerHeader({
287
465
  )}
288
466
  <button
289
467
  type="button"
290
- aria-label="Close"
468
+ aria-label="Minimize"
291
469
  onClick={onClose}
292
470
  className="ml-0.5 flex size-8 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white"
293
471
  >
294
- <X className="size-4.5" strokeWidth={2.4} />
472
+ <ChevronDown className="size-5" strokeWidth={2.4} />
295
473
  </button>
296
474
  </div>
297
475
  </div>
@@ -326,49 +504,47 @@ function MessengerComposer({
326
504
  submit();
327
505
  };
328
506
 
329
- const trimmedValue = value.trim();
330
- const showSend = trimmedValue.length > 0;
507
+ const hasText = value.trim().length > 0;
508
+ const sendDisabled = disabled || !onSubmit || !hasText;
331
509
 
332
510
  return (
333
- <div className="shrink-0 border-t border-black/5 bg-background px-3 py-2.5">
334
- <div className="flex items-end gap-2">
335
- <div className="flex flex-1 items-end rounded-3xl bg-[#F0F0F2] px-3 dark:bg-muted">
511
+ <div className="shrink-0 border-t border-border bg-background px-2 py-2">
512
+ <div className="flex items-end gap-0.5">
513
+ {/* Tool icons (Plus / Camera / Image / Mic) temporarily disabled. */}
514
+ <div className="flex flex-1 items-end rounded-3xl bg-secondary px-3.5">
336
515
  <Textarea
337
516
  ref={textareaRef}
338
- placeholder={DEFAULT_COMPOSER_PLACEHOLDER}
517
+ placeholder="Aa"
339
518
  value={value}
340
519
  onChange={(e) => onChange(e.target.value)}
341
520
  onKeyDown={handleKeyDown}
342
521
  rows={1}
343
522
  disabled={disabled}
344
523
  className={cn(
345
- "min-h-9 max-h-32 flex-1 resize-none border-0 bg-transparent py-2 text-[14px] shadow-none",
524
+ "min-h-9 max-h-32 flex-1 resize-none border-0 bg-transparent py-2 text-[14px] leading-snug shadow-none",
346
525
  "focus-visible:ring-0 focus-visible:border-0",
347
526
  )}
348
527
  />
349
528
  </div>
350
- {showSend ? (
351
- <Button
352
- type="button"
353
- size="icon"
354
- aria-label="Send"
355
- onClick={submit}
356
- disabled={disabled || !onSubmit}
357
- className="h-9 w-9 shrink-0 rounded-full text-white hover:opacity-90 disabled:opacity-60"
358
- style={{ background: MESSENGER_GRADIENT }}
359
- >
360
- <SendHorizonal className="size-4" />
361
- </Button>
362
- ) : (
363
- <button
364
- type="button"
365
- aria-label="Like"
366
- disabled
367
- className="flex size-9 shrink-0 items-center justify-center rounded-full text-[#0084FF] disabled:opacity-100"
368
- >
369
- <ThumbsUp className="size-5" strokeWidth={2.2} />
370
- </button>
371
- )}
529
+ <button
530
+ type="button"
531
+ aria-label="Send"
532
+ onClick={submit}
533
+ disabled={sendDisabled}
534
+ className={cn(
535
+ "ml-1 flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
536
+ "hover:bg-secondary disabled:hover:bg-transparent",
537
+ )}
538
+ style={{ color: MESSENGER_BRAND_COLOR }}
539
+ >
540
+ <SendHorizonal
541
+ className={cn(
542
+ "size-5 transition-opacity",
543
+ sendDisabled && "opacity-40",
544
+ )}
545
+ strokeWidth={2.2}
546
+ />
547
+ </button>
372
548
  </div>
373
549
  </div>
374
550
  );
@@ -10,14 +10,54 @@ import { getWidgetApiBase } from "./api-origin";
10
10
  const ANALYTICS_ENDPOINT = "/api/widget/events/batch";
11
11
  const DEFAULT_BATCH_SIZE = 10;
12
12
  const DEFAULT_FLUSH_INTERVAL_MS = 2500;
13
+ const MAX_FLUSH_INTERVAL_MS = 120_000;
14
+ const MAX_QUEUE_SIZE = 200;
15
+ const MAX_EVENT_ATTEMPTS = 5;
16
+ const MAX_EVENT_AGE_MS = 10 * 60 * 1000;
13
17
  const UNIQUE_SCRIPT_LOADED_KEY_PREFIX =
14
18
  "usereq-widget:unique-script-loaded:";
15
19
 
16
20
  type QueuedAnalyticsEvent = {
17
21
  event: WidgetAnalyticsBatchEvent;
18
22
  sessionToken: string;
23
+ attempts: number;
24
+ enqueuedAt: number;
19
25
  };
20
26
 
27
+ type SendOutcome =
28
+ | { outcome: "ok" }
29
+ | { outcome: "drop" }
30
+ | { outcome: "retry"; retryAfterMs?: number };
31
+
32
+ function parseRetryAfter(headerValue: string | null): number | undefined {
33
+ if (!headerValue) return undefined;
34
+ const trimmed = headerValue.trim();
35
+ if (!trimmed) return undefined;
36
+ const seconds = Number(trimmed);
37
+ if (Number.isFinite(seconds) && seconds >= 0) {
38
+ return Math.min(MAX_FLUSH_INTERVAL_MS, seconds * 1000);
39
+ }
40
+ const dateMs = Date.parse(trimmed);
41
+ if (!Number.isNaN(dateMs)) {
42
+ const delta = dateMs - Date.now();
43
+ if (delta > 0) return Math.min(MAX_FLUSH_INTERVAL_MS, delta);
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ function classifyResponse(response: Response): SendOutcome {
49
+ if (response.ok) return { outcome: "ok" };
50
+ const status = response.status;
51
+ if (status === 408 || status === 425 || status === 429 || status >= 500) {
52
+ return {
53
+ outcome: "retry",
54
+ retryAfterMs: parseRetryAfter(response.headers.get("retry-after")),
55
+ };
56
+ }
57
+ if (status >= 400 && status < 500) return { outcome: "drop" };
58
+ return { outcome: "retry" };
59
+ }
60
+
21
61
  export type WidgetAnalyticsTrackerOptions = {
22
62
  mode: "embed" | "preview";
23
63
  agentId: string;
@@ -38,6 +78,8 @@ export class WidgetAnalyticsTracker {
38
78
  private flushTimer: number | null = null;
39
79
  private queue: QueuedAnalyticsEvent[] = [];
40
80
  private disposed = false;
81
+ private consecutiveFailures = 0;
82
+ private nextAllowedFlushAt = 0;
41
83
 
42
84
  constructor(options: WidgetAnalyticsTrackerOptions) {
43
85
  this.mode = options.mode;
@@ -134,9 +176,11 @@ export class WidgetAnalyticsTracker {
134
176
  ...(linkUrl ? { linkUrl } : {}),
135
177
  };
136
178
 
137
- this.queue.push({
179
+ this.enqueue({
138
180
  event: queuedEvent,
139
181
  sessionToken,
182
+ attempts: 0,
183
+ enqueuedAt: Date.now(),
140
184
  });
141
185
 
142
186
  if (this.queue.length >= this.batchSize) {
@@ -147,55 +191,108 @@ export class WidgetAnalyticsTracker {
147
191
  this.scheduleFlush();
148
192
  }
149
193
 
194
+ private enqueue(entry: QueuedAnalyticsEvent) {
195
+ this.queue.push(entry);
196
+ if (this.queue.length > MAX_QUEUE_SIZE) {
197
+ this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE);
198
+ }
199
+ }
200
+
201
+ private pruneStale(now: number) {
202
+ if (this.queue.length === 0) return;
203
+ this.queue = this.queue.filter(
204
+ (entry) => now - entry.enqueuedAt < MAX_EVENT_AGE_MS,
205
+ );
206
+ }
207
+
150
208
  async flush(options?: {
151
209
  transport?: "default" | "beacon";
152
210
  }): Promise<void> {
153
211
  if (this.disposed) return;
154
- if (this.queue.length === 0) return;
212
+
213
+ const now = Date.now();
214
+ this.pruneStale(now);
215
+ if (this.queue.length === 0) {
216
+ this.clearFlushTimer();
217
+ return;
218
+ }
155
219
 
156
220
  this.clearFlushTimer();
157
221
  const transport = options?.transport ?? "default";
158
222
  const snapshot = this.queue;
159
223
  this.queue = [];
160
224
 
161
- const grouped = new Map<string, WidgetAnalyticsBatchEvent[]>();
225
+ const grouped = new Map<string, QueuedAnalyticsEvent[]>();
162
226
  for (const entry of snapshot) {
163
227
  const group = grouped.get(entry.sessionToken);
164
228
  if (group) {
165
- group.push(entry.event);
229
+ group.push(entry);
166
230
  } else {
167
- grouped.set(entry.sessionToken, [entry.event]);
231
+ grouped.set(entry.sessionToken, [entry]);
168
232
  }
169
233
  }
170
234
 
171
235
  const failed: QueuedAnalyticsEvent[] = [];
236
+ let maxRetryAfterMs = 0;
237
+ let anyRetry = false;
238
+ let anySuccess = false;
172
239
 
173
- for (const [sessionToken, events] of grouped.entries()) {
174
- const sent = await this.sendBatch({
175
- events,
240
+ for (const [sessionToken, entries] of grouped.entries()) {
241
+ const result = await this.sendBatch({
242
+ events: entries.map((entry) => entry.event),
176
243
  sessionToken,
177
244
  transport,
178
245
  });
179
246
 
180
- if (!sent) {
181
- for (const event of events) {
182
- failed.push({ event, sessionToken });
183
- }
247
+ if (result.outcome === "ok") {
248
+ anySuccess = true;
249
+ continue;
250
+ }
251
+ if (result.outcome === "drop") {
252
+ continue;
253
+ }
254
+ anyRetry = true;
255
+ if (result.retryAfterMs && result.retryAfterMs > maxRetryAfterMs) {
256
+ maxRetryAfterMs = result.retryAfterMs;
184
257
  }
258
+ for (const entry of entries) {
259
+ const attempts = entry.attempts + 1;
260
+ if (attempts >= MAX_EVENT_ATTEMPTS) continue;
261
+ failed.push({ ...entry, attempts });
262
+ }
263
+ }
264
+
265
+ if (anySuccess && !anyRetry) {
266
+ this.consecutiveFailures = 0;
267
+ this.nextAllowedFlushAt = 0;
268
+ } else if (anyRetry) {
269
+ this.consecutiveFailures += 1;
185
270
  }
186
271
 
187
272
  if (failed.length > 0) {
188
- this.queue = [...failed, ...this.queue];
189
- this.scheduleFlush();
273
+ for (const entry of failed) {
274
+ this.enqueue(entry);
275
+ }
276
+ const backoff = this.computeBackoffDelay();
277
+ const delay = Math.max(backoff, maxRetryAfterMs);
278
+ this.nextAllowedFlushAt = Date.now() + delay;
279
+ this.scheduleFlush(delay);
190
280
  }
191
281
  }
192
282
 
283
+ private computeBackoffDelay(): number {
284
+ const exponent = Math.max(0, this.consecutiveFailures - 1);
285
+ const base = this.flushIntervalMs * Math.pow(2, exponent);
286
+ const jitter = base * 0.2 * Math.random();
287
+ return Math.min(MAX_FLUSH_INTERVAL_MS, base + jitter);
288
+ }
289
+
193
290
  private async sendBatch(input: {
194
291
  events: WidgetAnalyticsBatchEvent[];
195
292
  sessionToken: string;
196
293
  transport: "default" | "beacon";
197
- }): Promise<boolean> {
198
- if (input.events.length === 0) return true;
294
+ }): Promise<SendOutcome> {
295
+ if (input.events.length === 0) return { outcome: "ok" };
199
296
 
200
297
  const url = new URL(ANALYTICS_ENDPOINT, `${getWidgetApiBase()}/`).toString();
201
298
  const sanitizedEvents = input.events.map((event) => {
@@ -220,7 +317,7 @@ export class WidgetAnalyticsTracker {
220
317
  type: "application/json",
221
318
  });
222
319
  if (navigator.sendBeacon(url, body)) {
223
- return true;
320
+ return { outcome: "ok" };
224
321
  }
225
322
  }
226
323
 
@@ -235,18 +332,19 @@ export class WidgetAnalyticsTracker {
235
332
  credentials: "omit",
236
333
  keepalive: input.transport === "beacon",
237
334
  });
238
- return response.ok;
335
+ return classifyResponse(response);
239
336
  } catch {
240
- return false;
337
+ return { outcome: "retry" };
241
338
  }
242
339
  }
243
340
 
244
- private scheduleFlush() {
341
+ private scheduleFlush(delayMs?: number) {
245
342
  if (this.flushTimer != null) return;
343
+ const delay = Math.max(0, delayMs ?? this.flushIntervalMs);
246
344
  this.flushTimer = window.setTimeout(() => {
247
345
  this.flushTimer = null;
248
346
  void this.flush();
249
- }, this.flushIntervalMs);
347
+ }, delay);
250
348
  }
251
349
 
252
350
  private clearFlushTimer() {