@usereq/widget 0.1.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.
- package/README.md +166 -0
- package/dist/widget.css +2 -0
- package/dist/widget.js +3569 -0
- package/package.json +98 -0
- package/src/chat-widget/chat-widget-appearance.ts +55 -0
- package/src/chat-widget/chat-widget-defaults.tsx +45 -0
- package/src/chat-widget/components/chat-widget-frame.tsx +56 -0
- package/src/chat-widget/components/chat-widget-layout.ts +6 -0
- package/src/chat-widget/components/chat-widget-primitives.tsx +720 -0
- package/src/chat-widget/components/confirmation.tsx +155 -0
- package/src/chat-widget/components/conversation.tsx +110 -0
- package/src/chat-widget/components/message.tsx +325 -0
- package/src/chat-widget/index.ts +9 -0
- package/src/chat-widget/stop-confirmation.ts +288 -0
- package/src/chat-widget/styles/chat-widget-box.tsx +258 -0
- package/src/chat-widget/styles/chat-widget-bubble.tsx +238 -0
- package/src/chat-widget/styles/chat-widget-chatbar.tsx +248 -0
- package/src/custom-element/agent-widget-element.tsx +584 -0
- package/src/embed.ts +8 -0
- package/src/index.ts +10 -0
- package/src/register.ts +15 -0
- package/src/renderer/index.ts +6 -0
- package/src/renderer/widget-runtime.tsx +205 -0
- package/src/runtime/analytics.ts +327 -0
- package/src/runtime/api-origin.ts +29 -0
- package/src/runtime/api.ts +151 -0
- package/src/runtime/bootstrap.ts +309 -0
- package/src/runtime/messages.ts +12 -0
- package/src/runtime/session-storage.ts +32 -0
- package/src/runtime/token-renewal.ts +13 -0
- package/src/runtime/trigger-rule.ts +182 -0
- package/src/shared/analytics.ts +54 -0
- package/src/shared/shadow-css.ts +15 -0
- package/src/shared/shadow-theme.ts +85 -0
- package/src/shared/stop-confirmation.ts +20 -0
- package/src/shared/widget-config.ts +104 -0
- package/src/styles/widget.css.ts +27 -0
- package/src/types.ts +85 -0
- package/src/vite-env.d.ts +4 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
ChatWidgetBubble,
|
|
4
|
+
ChatWidgetBox,
|
|
5
|
+
ChatWidgetChatBar,
|
|
6
|
+
} from "../chat-widget";
|
|
7
|
+
import type { WidgetMessage } from "../types";
|
|
8
|
+
import {
|
|
9
|
+
splitWidgetPlacement,
|
|
10
|
+
type WidgetAppearance,
|
|
11
|
+
type WidgetPlacement,
|
|
12
|
+
type WidgetVariant,
|
|
13
|
+
} from "../shared/widget-config";
|
|
14
|
+
import type { WidgetTriggerRule } from "../shared/widget-config";
|
|
15
|
+
import { getPendingStopConfirmationId } from "../shared/stop-confirmation";
|
|
16
|
+
import { widgetCss } from "../styles/widget.css";
|
|
17
|
+
import { useWidgetTriggerGate } from "../runtime/trigger-rule";
|
|
18
|
+
import type { WidgetTriggerRuleType } from "../shared/analytics";
|
|
19
|
+
|
|
20
|
+
export type WidgetRuntimeMode = "embed" | "preview";
|
|
21
|
+
|
|
22
|
+
export type WidgetRuntimeStatus = "idle" | "booting" | "ready" | "sending" | "error";
|
|
23
|
+
|
|
24
|
+
export type WidgetRuntimeProps = {
|
|
25
|
+
mode: WidgetRuntimeMode;
|
|
26
|
+
agentId: string;
|
|
27
|
+
variant: WidgetVariant;
|
|
28
|
+
placement: WidgetPlacement;
|
|
29
|
+
appearance: WidgetAppearance;
|
|
30
|
+
agentAvatarUrl: string | null;
|
|
31
|
+
widgetTitle: string;
|
|
32
|
+
welcomeMessage: string | null;
|
|
33
|
+
spotlights: string[];
|
|
34
|
+
open: boolean;
|
|
35
|
+
status: WidgetRuntimeStatus;
|
|
36
|
+
messages: WidgetMessage[];
|
|
37
|
+
errorMessage: string | null;
|
|
38
|
+
triggerRule?: WidgetTriggerRule | null;
|
|
39
|
+
emptyAction?: { label: string; onClick: () => void; disabled?: boolean };
|
|
40
|
+
sendDisabled?: boolean;
|
|
41
|
+
onOpenChange: (open: boolean) => void;
|
|
42
|
+
onSendMessage: (content: string) => void;
|
|
43
|
+
onStopConversation?: () => void;
|
|
44
|
+
onConfirmationDecision?: (
|
|
45
|
+
confirmationId: string,
|
|
46
|
+
accepted: boolean,
|
|
47
|
+
) => void | Promise<void>;
|
|
48
|
+
portalContainer?: HTMLElement | DocumentFragment | null;
|
|
49
|
+
onWidgetVisible?: (triggerRuleType: WidgetTriggerRuleType | null) => void;
|
|
50
|
+
onLinkClick?: (linkUrl: string) => void;
|
|
51
|
+
onLauncherClick?: () => void;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const VARIANT_COMPONENTS = {
|
|
55
|
+
default: ChatWidgetBubble,
|
|
56
|
+
box: ChatWidgetBox,
|
|
57
|
+
chatbar: ChatWidgetChatBar,
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
export function WidgetRuntime({
|
|
61
|
+
mode,
|
|
62
|
+
agentId,
|
|
63
|
+
variant,
|
|
64
|
+
placement,
|
|
65
|
+
appearance,
|
|
66
|
+
agentAvatarUrl,
|
|
67
|
+
widgetTitle,
|
|
68
|
+
spotlights,
|
|
69
|
+
open,
|
|
70
|
+
status,
|
|
71
|
+
messages,
|
|
72
|
+
errorMessage,
|
|
73
|
+
triggerRule,
|
|
74
|
+
emptyAction,
|
|
75
|
+
sendDisabled = false,
|
|
76
|
+
onOpenChange,
|
|
77
|
+
onSendMessage,
|
|
78
|
+
onStopConversation,
|
|
79
|
+
onConfirmationDecision,
|
|
80
|
+
portalContainer,
|
|
81
|
+
onWidgetVisible,
|
|
82
|
+
onLinkClick,
|
|
83
|
+
onLauncherClick,
|
|
84
|
+
}: WidgetRuntimeProps) {
|
|
85
|
+
const { ready } = useWidgetTriggerGate(triggerRule);
|
|
86
|
+
const { horizontal, vertical } = splitWidgetPlacement(placement);
|
|
87
|
+
const Component = VARIANT_COMPONENTS[variant] ?? ChatWidgetBubble;
|
|
88
|
+
const shellStyle = getWidgetShellStyle(mode, placement);
|
|
89
|
+
const resolvedAvatarUrl = agentAvatarUrl?.trim() || undefined;
|
|
90
|
+
const pendingConfirmationId = getPendingStopConfirmationId(messages);
|
|
91
|
+
const confirmationPending = pendingConfirmationId != null;
|
|
92
|
+
const stopDisabledResolved = confirmationPending || status === "booting" || status === "sending";
|
|
93
|
+
const sendDisabledResolved =
|
|
94
|
+
sendDisabled || confirmationPending || status === "booting";
|
|
95
|
+
const viewTrackedRef = React.useRef(false);
|
|
96
|
+
|
|
97
|
+
React.useEffect(() => {
|
|
98
|
+
if (mode !== "embed" || !ready || viewTrackedRef.current) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
viewTrackedRef.current = true;
|
|
102
|
+
onWidgetVisible?.(triggerRule?.triggerRuleType ?? null);
|
|
103
|
+
}, [mode, onWidgetVisible, ready, triggerRule]);
|
|
104
|
+
|
|
105
|
+
if (!ready) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<>
|
|
111
|
+
<style>{widgetCss}</style>
|
|
112
|
+
<div
|
|
113
|
+
data-agent-id={agentId}
|
|
114
|
+
data-mode={mode}
|
|
115
|
+
data-horizontal={horizontal}
|
|
116
|
+
data-vertical={vertical}
|
|
117
|
+
data-variant={variant}
|
|
118
|
+
data-open={String(open)}
|
|
119
|
+
data-status={status}
|
|
120
|
+
style={shellStyle}
|
|
121
|
+
className="widget-shell"
|
|
122
|
+
>
|
|
123
|
+
<div style={{ pointerEvents: "auto" }}>
|
|
124
|
+
<Component
|
|
125
|
+
agentName={widgetTitle}
|
|
126
|
+
agentAvatarUrl={resolvedAvatarUrl}
|
|
127
|
+
appearance={appearance}
|
|
128
|
+
spotlightMessages={spotlights}
|
|
129
|
+
messages={messages}
|
|
130
|
+
status={status}
|
|
131
|
+
errorMessage={errorMessage}
|
|
132
|
+
emptyAction={emptyAction}
|
|
133
|
+
sendDisabled={sendDisabledResolved}
|
|
134
|
+
onSendMessage={onSendMessage}
|
|
135
|
+
open={open}
|
|
136
|
+
onOpenChange={onOpenChange}
|
|
137
|
+
onStopConversation={onStopConversation}
|
|
138
|
+
stopDisabled={stopDisabledResolved}
|
|
139
|
+
onConfirmationDecision={onConfirmationDecision}
|
|
140
|
+
onLauncherClick={onLauncherClick}
|
|
141
|
+
onLinkClick={onLinkClick}
|
|
142
|
+
portalContainer={portalContainer}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getWidgetShellStyle(
|
|
151
|
+
mode: WidgetRuntimeMode,
|
|
152
|
+
placement: WidgetPlacement,
|
|
153
|
+
): React.CSSProperties {
|
|
154
|
+
const style: React.CSSProperties = {
|
|
155
|
+
pointerEvents: "none",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (mode === "preview") {
|
|
159
|
+
const { horizontal, vertical } = splitWidgetPlacement(placement);
|
|
160
|
+
Object.assign(style, {
|
|
161
|
+
position: "relative",
|
|
162
|
+
display: "flex",
|
|
163
|
+
width: "100%",
|
|
164
|
+
minHeight: "720px",
|
|
165
|
+
padding: "24px",
|
|
166
|
+
overflow: "hidden",
|
|
167
|
+
alignItems: vertical === "top" ? "flex-start" : "flex-end",
|
|
168
|
+
justifyContent:
|
|
169
|
+
horizontal === "left"
|
|
170
|
+
? "flex-start"
|
|
171
|
+
: horizontal === "center"
|
|
172
|
+
? "center"
|
|
173
|
+
: "flex-end",
|
|
174
|
+
});
|
|
175
|
+
return style;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
Object.assign(style, {
|
|
179
|
+
position: "fixed",
|
|
180
|
+
zIndex: 2147483646,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
switch (placement) {
|
|
184
|
+
case "top-left":
|
|
185
|
+
Object.assign(style, { left: "24px", top: "24px" });
|
|
186
|
+
break;
|
|
187
|
+
case "top-center":
|
|
188
|
+
Object.assign(style, { left: "50%", top: "24px", transform: "translateX(-50%)" });
|
|
189
|
+
break;
|
|
190
|
+
case "top-right":
|
|
191
|
+
Object.assign(style, { right: "24px", top: "24px" });
|
|
192
|
+
break;
|
|
193
|
+
case "bottom-left":
|
|
194
|
+
Object.assign(style, { left: "24px", bottom: "24px" });
|
|
195
|
+
break;
|
|
196
|
+
case "bottom-center":
|
|
197
|
+
Object.assign(style, { left: "50%", bottom: "24px", transform: "translateX(-50%)" });
|
|
198
|
+
break;
|
|
199
|
+
default:
|
|
200
|
+
Object.assign(style, { right: "24px", bottom: "24px" });
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return style;
|
|
205
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
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_ENDPOINT = "/api/widget/events/batch";
|
|
11
|
+
const DEFAULT_BATCH_SIZE = 10;
|
|
12
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 2500;
|
|
13
|
+
const UNIQUE_SCRIPT_LOADED_KEY_PREFIX =
|
|
14
|
+
"usereq-widget:unique-script-loaded:";
|
|
15
|
+
|
|
16
|
+
type QueuedAnalyticsEvent = {
|
|
17
|
+
event: WidgetAnalyticsBatchEvent;
|
|
18
|
+
sessionToken: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type WidgetAnalyticsTrackerOptions = {
|
|
22
|
+
mode: "embed" | "preview";
|
|
23
|
+
agentId: string;
|
|
24
|
+
getSession: () => WidgetSessionState | null;
|
|
25
|
+
getConversationId: () => string | undefined;
|
|
26
|
+
batchSize?: number;
|
|
27
|
+
flushIntervalMs?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export class WidgetAnalyticsTracker {
|
|
31
|
+
private readonly mode: "embed" | "preview";
|
|
32
|
+
private readonly agentId: string;
|
|
33
|
+
private readonly getSession: () => WidgetSessionState | null;
|
|
34
|
+
private readonly getConversationId: () => string | undefined;
|
|
35
|
+
private readonly batchSize: number;
|
|
36
|
+
private readonly flushIntervalMs: number;
|
|
37
|
+
private readonly uniqueScriptSeen = new Set<string>();
|
|
38
|
+
private flushTimer: number | null = null;
|
|
39
|
+
private queue: QueuedAnalyticsEvent[] = [];
|
|
40
|
+
private disposed = false;
|
|
41
|
+
|
|
42
|
+
constructor(options: WidgetAnalyticsTrackerOptions) {
|
|
43
|
+
this.mode = options.mode;
|
|
44
|
+
this.agentId = options.agentId;
|
|
45
|
+
this.getSession = options.getSession;
|
|
46
|
+
this.getConversationId = options.getConversationId;
|
|
47
|
+
this.batchSize = Math.max(1, options.batchSize ?? DEFAULT_BATCH_SIZE);
|
|
48
|
+
this.flushIntervalMs = Math.max(
|
|
49
|
+
200,
|
|
50
|
+
options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (this.mode === "embed") {
|
|
54
|
+
window.addEventListener("pagehide", this.handlePageHide, {
|
|
55
|
+
capture: true,
|
|
56
|
+
});
|
|
57
|
+
document.addEventListener(
|
|
58
|
+
"visibilitychange",
|
|
59
|
+
this.handleVisibilityChange,
|
|
60
|
+
true,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
dispose() {
|
|
66
|
+
if (this.disposed) return;
|
|
67
|
+
this.disposed = true;
|
|
68
|
+
this.clearFlushTimer();
|
|
69
|
+
window.removeEventListener("pagehide", this.handlePageHide, true);
|
|
70
|
+
document.removeEventListener(
|
|
71
|
+
"visibilitychange",
|
|
72
|
+
this.handleVisibilityChange,
|
|
73
|
+
true,
|
|
74
|
+
);
|
|
75
|
+
void this.flush({ transport: "beacon" });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
trackScriptLoaded() {
|
|
79
|
+
this.track({ eventType: "SCRIPT_LOADED" });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
trackUniqueScriptLoaded() {
|
|
83
|
+
const sessionToken = this.getSession()?.sessionToken?.trim();
|
|
84
|
+
if (!sessionToken) return;
|
|
85
|
+
const uniqueKey = `${this.agentId}:${sessionToken}`;
|
|
86
|
+
|
|
87
|
+
if (this.uniqueScriptSeen.has(uniqueKey)) return;
|
|
88
|
+
if (this.hasSessionUniqueScriptLoaded(uniqueKey)) {
|
|
89
|
+
this.uniqueScriptSeen.add(uniqueKey);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.uniqueScriptSeen.add(uniqueKey);
|
|
94
|
+
this.setSessionUniqueScriptLoaded(uniqueKey);
|
|
95
|
+
this.track({ eventType: "UNIQUE_SCRIPT_LOADED" });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
trackView(triggerRuleType: WidgetTriggerRuleType | null) {
|
|
99
|
+
this.track({ eventType: "VIEW", triggerRuleType });
|
|
100
|
+
if (triggerRuleType === "element_displayed") {
|
|
101
|
+
this.track({
|
|
102
|
+
eventType: "DISPLAY_AFTER_ELEMENT_SHOWED",
|
|
103
|
+
triggerRuleType,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
trackClick() {
|
|
109
|
+
this.track({ eventType: "CLICK" });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
trackLinkClicked(linkUrl: string) {
|
|
113
|
+
const raw = linkUrl.trim();
|
|
114
|
+
if (!raw) return;
|
|
115
|
+
const resolvedUrl = this.normalizeLinkUrl(raw);
|
|
116
|
+
this.track({ eventType: "LINK_CLICKED", linkUrl: resolvedUrl });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
track(event: Omit<WidgetAnalyticsBatchEvent, "occurredAt"> & {
|
|
120
|
+
occurredAt?: string;
|
|
121
|
+
}) {
|
|
122
|
+
if (this.disposed || this.mode !== "embed") return;
|
|
123
|
+
|
|
124
|
+
const sessionToken = this.getSession()?.sessionToken?.trim();
|
|
125
|
+
if (!sessionToken) return;
|
|
126
|
+
|
|
127
|
+
const { occurredAt, conversationId, triggerRuleType, linkUrl, ...rest } = event;
|
|
128
|
+
const resolvedConversationId = conversationId ?? this.getConversationId();
|
|
129
|
+
const queuedEvent: WidgetAnalyticsBatchEvent = {
|
|
130
|
+
...rest,
|
|
131
|
+
occurredAt: occurredAt ?? new Date().toISOString(),
|
|
132
|
+
...(resolvedConversationId ? { conversationId: resolvedConversationId } : {}),
|
|
133
|
+
...(triggerRuleType ? { triggerRuleType } : {}),
|
|
134
|
+
...(linkUrl ? { linkUrl } : {}),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
this.queue.push({
|
|
138
|
+
event: queuedEvent,
|
|
139
|
+
sessionToken,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (this.queue.length >= this.batchSize) {
|
|
143
|
+
void this.flush();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.scheduleFlush();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async flush(options?: {
|
|
151
|
+
transport?: "default" | "beacon";
|
|
152
|
+
}): Promise<void> {
|
|
153
|
+
if (this.disposed) return;
|
|
154
|
+
if (this.queue.length === 0) return;
|
|
155
|
+
|
|
156
|
+
this.clearFlushTimer();
|
|
157
|
+
const transport = options?.transport ?? "default";
|
|
158
|
+
const snapshot = this.queue;
|
|
159
|
+
this.queue = [];
|
|
160
|
+
|
|
161
|
+
const grouped = new Map<string, WidgetAnalyticsBatchEvent[]>();
|
|
162
|
+
for (const entry of snapshot) {
|
|
163
|
+
const group = grouped.get(entry.sessionToken);
|
|
164
|
+
if (group) {
|
|
165
|
+
group.push(entry.event);
|
|
166
|
+
} else {
|
|
167
|
+
grouped.set(entry.sessionToken, [entry.event]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const failed: QueuedAnalyticsEvent[] = [];
|
|
172
|
+
|
|
173
|
+
for (const [sessionToken, events] of grouped.entries()) {
|
|
174
|
+
const sent = await this.sendBatch({
|
|
175
|
+
events,
|
|
176
|
+
sessionToken,
|
|
177
|
+
transport,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (!sent) {
|
|
181
|
+
for (const event of events) {
|
|
182
|
+
failed.push({ event, sessionToken });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (failed.length > 0) {
|
|
188
|
+
this.queue = [...failed, ...this.queue];
|
|
189
|
+
this.scheduleFlush();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private async sendBatch(input: {
|
|
194
|
+
events: WidgetAnalyticsBatchEvent[];
|
|
195
|
+
sessionToken: string;
|
|
196
|
+
transport: "default" | "beacon";
|
|
197
|
+
}): Promise<boolean> {
|
|
198
|
+
if (input.events.length === 0) return true;
|
|
199
|
+
|
|
200
|
+
const url = new URL(ANALYTICS_ENDPOINT, `${getWidgetApiBase()}/`).toString();
|
|
201
|
+
const sanitizedEvents = input.events.map((event) => {
|
|
202
|
+
const { conversationId, triggerRuleType, linkUrl, ...rest } = event;
|
|
203
|
+
return {
|
|
204
|
+
...rest,
|
|
205
|
+
...(conversationId ? { conversationId } : {}),
|
|
206
|
+
...(triggerRuleType ? { triggerRuleType } : {}),
|
|
207
|
+
...(linkUrl ? { linkUrl } : {}),
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
const payload: WidgetAnalyticsBatchRequest = {
|
|
211
|
+
events: sanitizedEvents,
|
|
212
|
+
pageUrl: window.location.href,
|
|
213
|
+
referrer: document.referrer || undefined,
|
|
214
|
+
// sendBeacon cannot set Authorization header, so include token in body as fallback.
|
|
215
|
+
sessionToken: input.sessionToken,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (input.transport === "beacon" && typeof navigator.sendBeacon === "function") {
|
|
219
|
+
const body = new Blob([JSON.stringify(payload)], {
|
|
220
|
+
type: "application/json",
|
|
221
|
+
});
|
|
222
|
+
if (navigator.sendBeacon(url, body)) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const response = await fetch(url, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: {
|
|
231
|
+
Authorization: `Bearer ${input.sessionToken}`,
|
|
232
|
+
"Content-Type": "application/json",
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify(payload),
|
|
235
|
+
credentials: "omit",
|
|
236
|
+
keepalive: input.transport === "beacon",
|
|
237
|
+
});
|
|
238
|
+
return response.ok;
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private scheduleFlush() {
|
|
245
|
+
if (this.flushTimer != null) return;
|
|
246
|
+
this.flushTimer = window.setTimeout(() => {
|
|
247
|
+
this.flushTimer = null;
|
|
248
|
+
void this.flush();
|
|
249
|
+
}, this.flushIntervalMs);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private clearFlushTimer() {
|
|
253
|
+
if (this.flushTimer == null) return;
|
|
254
|
+
window.clearTimeout(this.flushTimer);
|
|
255
|
+
this.flushTimer = null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private hasSessionUniqueScriptLoaded(uniqueKey: string): boolean {
|
|
259
|
+
try {
|
|
260
|
+
return (
|
|
261
|
+
sessionStorage.getItem(`${UNIQUE_SCRIPT_LOADED_KEY_PREFIX}${uniqueKey}`) ===
|
|
262
|
+
"1"
|
|
263
|
+
);
|
|
264
|
+
} catch {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private setSessionUniqueScriptLoaded(uniqueKey: string) {
|
|
270
|
+
try {
|
|
271
|
+
sessionStorage.setItem(
|
|
272
|
+
`${UNIQUE_SCRIPT_LOADED_KEY_PREFIX}${uniqueKey}`,
|
|
273
|
+
"1",
|
|
274
|
+
);
|
|
275
|
+
} catch {
|
|
276
|
+
// no-op
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private readonly handlePageHide = () => {
|
|
281
|
+
void this.flush({ transport: "beacon" });
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
private readonly handleVisibilityChange = () => {
|
|
285
|
+
if (document.visibilityState === "hidden") {
|
|
286
|
+
void this.flush({ transport: "beacon" });
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
private normalizeLinkUrl(value: string): string {
|
|
291
|
+
try {
|
|
292
|
+
return new URL(value, window.location.href).toString();
|
|
293
|
+
} catch {
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function createWidgetAnalyticsTracker(
|
|
300
|
+
options: WidgetAnalyticsTrackerOptions,
|
|
301
|
+
): WidgetAnalyticsTracker {
|
|
302
|
+
return new WidgetAnalyticsTracker(options);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function isWidgetTriggerRuleType(
|
|
306
|
+
value: string | null | undefined,
|
|
307
|
+
): value is WidgetTriggerRuleType {
|
|
308
|
+
return (
|
|
309
|
+
value === "time_delay" ||
|
|
310
|
+
value === "scroll_to_bottom" ||
|
|
311
|
+
value === "element_clicked" ||
|
|
312
|
+
value === "element_displayed"
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function isWidgetClientAnalyticsEventType(
|
|
317
|
+
value: string | null | undefined,
|
|
318
|
+
): value is WidgetClientAnalyticsEventType {
|
|
319
|
+
return (
|
|
320
|
+
value === "SCRIPT_LOADED" ||
|
|
321
|
+
value === "UNIQUE_SCRIPT_LOADED" ||
|
|
322
|
+
value === "VIEW" ||
|
|
323
|
+
value === "CLICK" ||
|
|
324
|
+
value === "DISPLAY_AFTER_ELEMENT_SHOWED" ||
|
|
325
|
+
value === "LINK_CLICKED"
|
|
326
|
+
);
|
|
327
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
declare const __USEREQ_WIDGET_API_BASE__:
|
|
2
|
+
| string
|
|
3
|
+
| undefined;
|
|
4
|
+
|
|
5
|
+
type WidgetRuntimeGlobal = typeof globalThis & {
|
|
6
|
+
__USEREQ_WIDGET_API_BASE__?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function normalizeApiBase(value: string | undefined): string | null {
|
|
10
|
+
const trimmed = value?.trim();
|
|
11
|
+
if (!trimmed) return null;
|
|
12
|
+
|
|
13
|
+
return trimmed.replace(/\/+$/, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getWidgetApiBase(): string {
|
|
17
|
+
const compileTimeApiBase =
|
|
18
|
+
typeof __USEREQ_WIDGET_API_BASE__ !== "undefined"
|
|
19
|
+
? __USEREQ_WIDGET_API_BASE__
|
|
20
|
+
: undefined;
|
|
21
|
+
const runtimeApiBase = (globalThis as WidgetRuntimeGlobal)
|
|
22
|
+
.__USEREQ_WIDGET_API_BASE__;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
normalizeApiBase(compileTimeApiBase) ??
|
|
26
|
+
normalizeApiBase(runtimeApiBase) ??
|
|
27
|
+
"http://localhost:4000"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WidgetAnalyticsBatchRequest,
|
|
3
|
+
WidgetConversation,
|
|
4
|
+
WidgetMessage,
|
|
5
|
+
WidgetPublicChatSendChatResponse,
|
|
6
|
+
WidgetPublicChatSendStopConfirmationResponse,
|
|
7
|
+
WidgetSessionConfig,
|
|
8
|
+
} from "../types";
|
|
9
|
+
import { getWidgetApiBase } from "./api-origin";
|
|
10
|
+
|
|
11
|
+
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
|
12
|
+
const response = await fetch(new URL(path, `${getWidgetApiBase()}/`).toString(), {
|
|
13
|
+
...init,
|
|
14
|
+
headers: {
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
...(init.headers ?? {}),
|
|
17
|
+
},
|
|
18
|
+
credentials: "omit",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const text = await response.text();
|
|
22
|
+
const payload = text ? JSON.parse(text) : null;
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
typeof payload === "object" && payload && "error" in payload
|
|
26
|
+
? String((payload as { error?: string }).error ?? response.statusText)
|
|
27
|
+
: response.statusText
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return payload as T;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function mintWidgetSession(input: {
|
|
35
|
+
agentId: string;
|
|
36
|
+
origin: string;
|
|
37
|
+
pageUrl?: string;
|
|
38
|
+
referrer?: string;
|
|
39
|
+
}): Promise<WidgetSessionConfig> {
|
|
40
|
+
return requestJson<WidgetSessionConfig>("/api/widget/sessions", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
body: JSON.stringify(input),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function startPublicChat(input: {
|
|
47
|
+
agentId: string;
|
|
48
|
+
sessionToken: string;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
conversation: WidgetConversation;
|
|
51
|
+
welcomeMessage: WidgetMessage;
|
|
52
|
+
}> {
|
|
53
|
+
return requestJson("/api/conversations/public/start", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { Authorization: `Bearer ${input.sessionToken}` },
|
|
56
|
+
body: JSON.stringify({ agentId: input.agentId }),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function sendPublicChatMessage(input: {
|
|
61
|
+
conversationId: string;
|
|
62
|
+
sessionToken: string;
|
|
63
|
+
content: string;
|
|
64
|
+
}): Promise<WidgetPublicChatSendChatResponse> {
|
|
65
|
+
return requestJson(
|
|
66
|
+
`/api/conversations/public/${encodeURIComponent(input.conversationId)}/messages`,
|
|
67
|
+
{
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { Authorization: `Bearer ${input.sessionToken}` },
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
content: input.content,
|
|
72
|
+
}),
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function sendPublicChatConfirmation(input: {
|
|
78
|
+
conversationId: string;
|
|
79
|
+
sessionToken: string;
|
|
80
|
+
content: string;
|
|
81
|
+
}): Promise<WidgetPublicChatSendStopConfirmationResponse> {
|
|
82
|
+
return requestJson(
|
|
83
|
+
`/api/conversations/public/${encodeURIComponent(input.conversationId)}/messages`,
|
|
84
|
+
{
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { Authorization: `Bearer ${input.sessionToken}` },
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
content: input.content,
|
|
89
|
+
}),
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
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
|
+
export async function getCurrentConversation(sessionToken: string): Promise<{
|
|
122
|
+
conversation: WidgetConversation | null;
|
|
123
|
+
}> {
|
|
124
|
+
return requestJson<{ conversation: WidgetConversation | null }>(
|
|
125
|
+
"/api/conversations/public/current",
|
|
126
|
+
{
|
|
127
|
+
method: "GET",
|
|
128
|
+
headers: { Authorization: `Bearer ${sessionToken}` },
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function listConversationMessages(input: {
|
|
134
|
+
conversationId: string;
|
|
135
|
+
sessionToken: string;
|
|
136
|
+
cursor?: string | null;
|
|
137
|
+
}): Promise<{
|
|
138
|
+
data: WidgetMessage[];
|
|
139
|
+
nextCursor: string | null;
|
|
140
|
+
hasMore: boolean;
|
|
141
|
+
}> {
|
|
142
|
+
const params = new URLSearchParams();
|
|
143
|
+
if (input.cursor) params.set("cursor", input.cursor);
|
|
144
|
+
return requestJson(
|
|
145
|
+
`/api/conversations/public/${encodeURIComponent(input.conversationId)}/messages?${params.toString()}`,
|
|
146
|
+
{
|
|
147
|
+
method: "GET",
|
|
148
|
+
headers: { Authorization: `Bearer ${input.sessionToken}` },
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
}
|