polymorph-sdk 0.2.4 → 0.2.6
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/dist/index.css +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.js +391 -247
- package/package.json +1 -1
- package/src/IdentityForm.tsx +27 -1
- package/src/PolymorphWidget.tsx +14 -1
- package/src/WidgetPanel.tsx +30 -4
- package/src/__tests__/IdentityForm.test.tsx +10 -1
- package/src/__tests__/PolymorphWidget.test.tsx +31 -7
- package/src/__tests__/usePolymorphSession.test.ts +6 -0
- package/src/styles.module.css +19 -0
- package/src/types.ts +24 -0
- package/src/usePolymorphSession.ts +181 -6
package/package.json
CHANGED
package/src/IdentityForm.tsx
CHANGED
|
@@ -7,6 +7,8 @@ interface IdentityFormProps {
|
|
|
7
7
|
collectPhone: FieldRequirement;
|
|
8
8
|
primaryColor: string;
|
|
9
9
|
onSubmit: (user: WidgetUser) => void;
|
|
10
|
+
enableTermsAndConditions?: boolean;
|
|
11
|
+
termsAndConditionsText?: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}$/;
|
|
@@ -17,10 +19,13 @@ export function IdentityForm({
|
|
|
17
19
|
collectPhone,
|
|
18
20
|
primaryColor,
|
|
19
21
|
onSubmit,
|
|
22
|
+
enableTermsAndConditions,
|
|
23
|
+
termsAndConditionsText,
|
|
20
24
|
}: IdentityFormProps) {
|
|
21
25
|
const [name, setName] = useState("");
|
|
22
26
|
const [email, setEmail] = useState("");
|
|
23
27
|
const [phone, setPhone] = useState("");
|
|
28
|
+
const [termsChecked, setTermsChecked] = useState(false);
|
|
24
29
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
25
30
|
|
|
26
31
|
const validate = useCallback(() => {
|
|
@@ -45,8 +50,18 @@ export function IdentityForm({
|
|
|
45
50
|
!phone.trim()
|
|
46
51
|
)
|
|
47
52
|
e.contact = "Please provide either an email or phone number";
|
|
53
|
+
if (enableTermsAndConditions && !termsChecked)
|
|
54
|
+
e.terms = "You must accept the terms and conditions";
|
|
48
55
|
return e;
|
|
49
|
-
}, [
|
|
56
|
+
}, [
|
|
57
|
+
name,
|
|
58
|
+
email,
|
|
59
|
+
phone,
|
|
60
|
+
collectEmail,
|
|
61
|
+
collectPhone,
|
|
62
|
+
enableTermsAndConditions,
|
|
63
|
+
termsChecked,
|
|
64
|
+
]);
|
|
50
65
|
|
|
51
66
|
const handleSubmit = useCallback(
|
|
52
67
|
(ev: React.FormEvent) => {
|
|
@@ -123,6 +138,17 @@ export function IdentityForm({
|
|
|
123
138
|
{errors.contact && (
|
|
124
139
|
<div className={styles.formError}>{errors.contact}</div>
|
|
125
140
|
)}
|
|
141
|
+
{enableTermsAndConditions && (
|
|
142
|
+
<label className={styles.termsLabel}>
|
|
143
|
+
<input
|
|
144
|
+
type="checkbox"
|
|
145
|
+
checked={termsChecked}
|
|
146
|
+
onChange={(ev) => setTermsChecked(ev.target.checked)}
|
|
147
|
+
/>
|
|
148
|
+
<span>{termsAndConditionsText}</span>
|
|
149
|
+
</label>
|
|
150
|
+
)}
|
|
151
|
+
{errors.terms && <div className={styles.formError}>{errors.terms}</div>}
|
|
126
152
|
<button
|
|
127
153
|
type="submit"
|
|
128
154
|
className={styles.formSubmitButton}
|
package/src/PolymorphWidget.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { LiveKitRoom } from "@livekit/components-react";
|
|
2
2
|
import { MantineProvider } from "@mantine/core";
|
|
3
3
|
import "@mantine/core/styles.css";
|
|
4
|
-
import {
|
|
4
|
+
import { MediaDeviceFailure } from "livekit-client";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
6
|
import { RoomHandler } from "./RoomHandler";
|
|
6
7
|
import styles from "./styles.module.css";
|
|
7
8
|
import { buildWidgetTheme } from "./theme";
|
|
@@ -69,6 +70,17 @@ export function PolymorphWidget(props: WidgetConfig) {
|
|
|
69
70
|
};
|
|
70
71
|
}, [session.disconnect]);
|
|
71
72
|
|
|
73
|
+
const handleMediaDeviceFailure = useCallback(
|
|
74
|
+
(failure?: MediaDeviceFailure) => {
|
|
75
|
+
if (failure === MediaDeviceFailure.PermissionDenied) {
|
|
76
|
+
session.setError(
|
|
77
|
+
"Microphone access denied. Please allow mic access in your browser settings.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
[session.setError],
|
|
82
|
+
);
|
|
83
|
+
|
|
72
84
|
const audio = useMemo(
|
|
73
85
|
() =>
|
|
74
86
|
session.isVoiceEnabled
|
|
@@ -125,6 +137,7 @@ export function PolymorphWidget(props: WidgetConfig) {
|
|
|
125
137
|
video={false}
|
|
126
138
|
style={{ display: "none" }}
|
|
127
139
|
onDisconnected={session.disconnect}
|
|
140
|
+
onMediaDeviceFailure={handleMediaDeviceFailure}
|
|
128
141
|
>
|
|
129
142
|
<RoomHandler
|
|
130
143
|
setRoom={session.setRoom}
|
package/src/WidgetPanel.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Room } from "livekit-client";
|
|
2
|
-
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import { type ChangeEvent, useCallback, useRef, useState } from "react";
|
|
3
3
|
import { ChatThread } from "./ChatThread";
|
|
4
4
|
import { IdentityForm } from "./IdentityForm";
|
|
5
5
|
import styles from "./styles.module.css";
|
|
@@ -122,6 +122,8 @@ interface WidgetPanelProps {
|
|
|
122
122
|
toggleScreenShare: () => void;
|
|
123
123
|
setRoom: (room: Room | null) => void;
|
|
124
124
|
setUser: (user: WidgetUser) => void;
|
|
125
|
+
needsTermsAcceptance: boolean;
|
|
126
|
+
acceptTerms: () => void;
|
|
125
127
|
};
|
|
126
128
|
onClose: () => void;
|
|
127
129
|
hidden?: boolean;
|
|
@@ -190,7 +192,12 @@ export function WidgetPanel({ session, onClose, hidden }: WidgetPanelProps) {
|
|
|
190
192
|
collectEmail={session.identityCollection.collectEmail}
|
|
191
193
|
collectPhone={session.identityCollection.collectPhone}
|
|
192
194
|
primaryColor={primaryColor}
|
|
193
|
-
onSubmit={
|
|
195
|
+
onSubmit={(user) => {
|
|
196
|
+
session.setUser(user);
|
|
197
|
+
if (rc?.enableTermsAndConditions) session.acceptTerms();
|
|
198
|
+
}}
|
|
199
|
+
enableTermsAndConditions={rc?.enableTermsAndConditions}
|
|
200
|
+
termsAndConditionsText={rc?.termsAndConditionsText}
|
|
194
201
|
/>
|
|
195
202
|
) : (
|
|
196
203
|
<>
|
|
@@ -213,6 +220,19 @@ export function WidgetPanel({ session, onClose, hidden }: WidgetPanelProps) {
|
|
|
213
220
|
{session.error && (
|
|
214
221
|
<div className={styles.errorText}>{session.error}</div>
|
|
215
222
|
)}
|
|
223
|
+
{session.needsTermsAcceptance && rc?.termsAndConditionsText && (
|
|
224
|
+
<div className={styles.termsBar}>
|
|
225
|
+
<label className={styles.termsLabel}>
|
|
226
|
+
<input
|
|
227
|
+
type="checkbox"
|
|
228
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
229
|
+
if (e.target.checked) session.acceptTerms();
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
<span>{rc.termsAndConditionsText}</span>
|
|
233
|
+
</label>
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
216
236
|
<div className={styles.inputBar}>
|
|
217
237
|
<textarea
|
|
218
238
|
ref={textareaRef}
|
|
@@ -226,14 +246,20 @@ export function WidgetPanel({ session, onClose, hidden }: WidgetPanelProps) {
|
|
|
226
246
|
onChange={(e) => setInputValue(e.target.value)}
|
|
227
247
|
onInput={handleInput}
|
|
228
248
|
onKeyDown={handleKeyDown}
|
|
229
|
-
disabled={
|
|
249
|
+
disabled={
|
|
250
|
+
session.status === "connecting" || session.needsTermsAcceptance
|
|
251
|
+
}
|
|
230
252
|
rows={1}
|
|
231
253
|
/>
|
|
232
254
|
<button
|
|
233
255
|
type="button"
|
|
234
256
|
className={styles.iconButton}
|
|
235
257
|
onClick={handleSend}
|
|
236
|
-
disabled={
|
|
258
|
+
disabled={
|
|
259
|
+
!inputValue.trim() ||
|
|
260
|
+
session.status === "connecting" ||
|
|
261
|
+
session.needsTermsAcceptance
|
|
262
|
+
}
|
|
237
263
|
>
|
|
238
264
|
<SendIcon />
|
|
239
265
|
</button>
|
|
@@ -25,6 +25,13 @@ function renderForm(
|
|
|
25
25
|
return { onSubmit };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function requireFormElement(element: HTMLFormElement | null): HTMLFormElement {
|
|
29
|
+
if (!element) {
|
|
30
|
+
throw new Error("Expected submit button to be inside a form");
|
|
31
|
+
}
|
|
32
|
+
return element;
|
|
33
|
+
}
|
|
34
|
+
|
|
28
35
|
describe("IdentityForm", () => {
|
|
29
36
|
// ── Rendering ──
|
|
30
37
|
|
|
@@ -88,7 +95,9 @@ describe("IdentityForm", () => {
|
|
|
88
95
|
});
|
|
89
96
|
// Use fireEvent.submit to bypass native <input type="email"> validation in jsdom
|
|
90
97
|
fireEvent.submit(
|
|
91
|
-
|
|
98
|
+
requireFormElement(
|
|
99
|
+
screen.getByRole("button", { name: "Start Chat" }).closest("form"),
|
|
100
|
+
),
|
|
92
101
|
);
|
|
93
102
|
expect(screen.getByText("Invalid email format")).toBeInTheDocument();
|
|
94
103
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
@@ -45,6 +45,13 @@ const RESOLVE_RESPONSE = {
|
|
|
45
45
|
|
|
46
46
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
47
47
|
|
|
48
|
+
function requireElement<T>(element: T | null | undefined, message: string): T {
|
|
49
|
+
if (!element) {
|
|
50
|
+
throw new Error(message);
|
|
51
|
+
}
|
|
52
|
+
return element;
|
|
53
|
+
}
|
|
54
|
+
|
|
48
55
|
beforeEach(() => {
|
|
49
56
|
fetchMock = vi.fn().mockImplementation((url: string) => {
|
|
50
57
|
if (url.includes("/widget-configs/resolve")) {
|
|
@@ -77,7 +84,10 @@ describe("PolymorphWidget", () => {
|
|
|
77
84
|
|
|
78
85
|
it("clicking FAB opens the panel", async () => {
|
|
79
86
|
render(<PolymorphWidget apiBaseUrl="http://test" apiKey="key" />);
|
|
80
|
-
const fab =
|
|
87
|
+
const fab = requireElement(
|
|
88
|
+
screen.getByTitle("Chat").closest("button"),
|
|
89
|
+
"Expected chat button",
|
|
90
|
+
);
|
|
81
91
|
fireEvent.click(fab);
|
|
82
92
|
await waitFor(() => {
|
|
83
93
|
expect(
|
|
@@ -88,7 +98,10 @@ describe("PolymorphWidget", () => {
|
|
|
88
98
|
|
|
89
99
|
it("clicking FAB again closes the panel", async () => {
|
|
90
100
|
render(<PolymorphWidget apiBaseUrl="http://test" apiKey="key" />);
|
|
91
|
-
const fab =
|
|
101
|
+
const fab = requireElement(
|
|
102
|
+
screen.getByTitle("Chat").closest("button"),
|
|
103
|
+
"Expected chat button",
|
|
104
|
+
);
|
|
92
105
|
|
|
93
106
|
// Open
|
|
94
107
|
fireEvent.click(fab);
|
|
@@ -102,8 +115,13 @@ describe("PolymorphWidget", () => {
|
|
|
102
115
|
// (not the panel header close button) by its CSS class
|
|
103
116
|
const fabButtons = screen
|
|
104
117
|
.getAllByTitle("Close")
|
|
105
|
-
.map((el) =>
|
|
106
|
-
|
|
118
|
+
.map((el) =>
|
|
119
|
+
requireElement(el.closest("button"), "Expected close button"),
|
|
120
|
+
);
|
|
121
|
+
const fabBtn = requireElement(
|
|
122
|
+
fabButtons.find((btn) => btn.className.includes("fab")),
|
|
123
|
+
"Expected floating close button",
|
|
124
|
+
);
|
|
107
125
|
fireEvent.click(fabBtn);
|
|
108
126
|
await waitFor(() => {
|
|
109
127
|
expect(
|
|
@@ -141,7 +159,10 @@ describe("PolymorphWidget", () => {
|
|
|
141
159
|
expect(screen.getByText("Welcome!")).toBeInTheDocument();
|
|
142
160
|
});
|
|
143
161
|
|
|
144
|
-
const root =
|
|
162
|
+
const root = requireElement(
|
|
163
|
+
document.querySelector(".polymorph-widget"),
|
|
164
|
+
"Expected widget root",
|
|
165
|
+
);
|
|
145
166
|
expect(root.className).toContain("bottomLeft");
|
|
146
167
|
});
|
|
147
168
|
|
|
@@ -167,7 +188,10 @@ describe("PolymorphWidget", () => {
|
|
|
167
188
|
expect(screen.getByText("Welcome!")).toBeInTheDocument();
|
|
168
189
|
});
|
|
169
190
|
|
|
170
|
-
const root =
|
|
171
|
-
|
|
191
|
+
const root = requireElement(
|
|
192
|
+
document.querySelector(".polymorph-widget"),
|
|
193
|
+
"Expected widget root",
|
|
194
|
+
);
|
|
195
|
+
expect(root.style.colorScheme).toBe("dark");
|
|
172
196
|
});
|
|
173
197
|
});
|
|
@@ -14,6 +14,9 @@ const MOCK_CONFIG: ResolvedWidgetConfig = {
|
|
|
14
14
|
greeting: "Hello!",
|
|
15
15
|
collectEmail: "hidden",
|
|
16
16
|
collectPhone: "hidden",
|
|
17
|
+
alwaysOn: false,
|
|
18
|
+
enableTermsAndConditions: false,
|
|
19
|
+
termsAndConditionsText: "",
|
|
17
20
|
};
|
|
18
21
|
|
|
19
22
|
function mockResolveResponse(cfg = MOCK_CONFIG) {
|
|
@@ -28,6 +31,9 @@ function mockResolveResponse(cfg = MOCK_CONFIG) {
|
|
|
28
31
|
greeting: cfg.greeting,
|
|
29
32
|
collect_email: cfg.collectEmail,
|
|
30
33
|
collect_phone: cfg.collectPhone,
|
|
34
|
+
always_on: cfg.alwaysOn,
|
|
35
|
+
enable_terms_and_conditions: cfg.enableTermsAndConditions,
|
|
36
|
+
terms_and_conditions_text: cfg.termsAndConditionsText,
|
|
31
37
|
};
|
|
32
38
|
}
|
|
33
39
|
|
package/src/styles.module.css
CHANGED
|
@@ -421,6 +421,25 @@
|
|
|
421
421
|
outline-offset: 2px;
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
+
.termsBar {
|
|
425
|
+
padding: 8px 16px;
|
|
426
|
+
border-top: 1px solid var(--mantine-color-default-border);
|
|
427
|
+
font-size: 12px;
|
|
428
|
+
color: var(--mantine-color-dimmed);
|
|
429
|
+
}
|
|
430
|
+
.termsLabel {
|
|
431
|
+
display: flex;
|
|
432
|
+
align-items: flex-start;
|
|
433
|
+
gap: 8px;
|
|
434
|
+
font-size: 12px;
|
|
435
|
+
line-height: 1.4;
|
|
436
|
+
cursor: pointer;
|
|
437
|
+
}
|
|
438
|
+
.termsLabel input[type="checkbox"] {
|
|
439
|
+
margin-top: 2px;
|
|
440
|
+
flex-shrink: 0;
|
|
441
|
+
}
|
|
442
|
+
|
|
424
443
|
/* Mobile: full-width panel on small screens */
|
|
425
444
|
@media (max-width: 420px) {
|
|
426
445
|
.bottomRight,
|
package/src/types.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface WidgetConfig {
|
|
|
14
14
|
fetchOptions?: RequestInit;
|
|
15
15
|
/** Called when a session starts with room info for constructing join URLs. */
|
|
16
16
|
onSessionStart?: (info: { roomId: string; joinToken: string }) => void;
|
|
17
|
+
/** Auto-capture behavioral events (page_view, idle, rage_click). Defaults to true. */
|
|
18
|
+
autoCapture?: boolean;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface WidgetUser {
|
|
@@ -37,6 +39,9 @@ export interface ResolvedWidgetConfig {
|
|
|
37
39
|
greeting: string;
|
|
38
40
|
collectEmail: FieldRequirement;
|
|
39
41
|
collectPhone: FieldRequirement;
|
|
42
|
+
alwaysOn: boolean;
|
|
43
|
+
enableTermsAndConditions: boolean;
|
|
44
|
+
termsAndConditionsText: string;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
export type FieldRequirement = "required" | "optional" | "hidden";
|
|
@@ -57,3 +62,22 @@ export interface ChatMessage {
|
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
export type SessionStatus = "idle" | "connecting" | "connected" | "error";
|
|
65
|
+
|
|
66
|
+
export type WidgetEventType =
|
|
67
|
+
| "page_view"
|
|
68
|
+
| "click"
|
|
69
|
+
| "form_start"
|
|
70
|
+
| "form_abandon"
|
|
71
|
+
| "error_seen"
|
|
72
|
+
| "idle"
|
|
73
|
+
| "rage_click"
|
|
74
|
+
| "scroll_depth"
|
|
75
|
+
| "custom";
|
|
76
|
+
|
|
77
|
+
export interface WidgetEvent {
|
|
78
|
+
type: WidgetEventType;
|
|
79
|
+
payload?: Record<string, unknown>;
|
|
80
|
+
url?: string;
|
|
81
|
+
element?: string;
|
|
82
|
+
timestamp: number;
|
|
83
|
+
}
|
|
@@ -6,6 +6,8 @@ import type {
|
|
|
6
6
|
ResolvedWidgetConfig,
|
|
7
7
|
SessionStatus,
|
|
8
8
|
WidgetConfig,
|
|
9
|
+
WidgetEvent,
|
|
10
|
+
WidgetEventType,
|
|
9
11
|
WidgetUser,
|
|
10
12
|
} from "./types";
|
|
11
13
|
|
|
@@ -21,6 +23,20 @@ function resolveApiBaseUrl(configured?: string): string {
|
|
|
21
23
|
return "https://api.usepolymorph.com";
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
function uuid(): string {
|
|
27
|
+
if (
|
|
28
|
+
typeof crypto !== "undefined" &&
|
|
29
|
+
typeof crypto.randomUUID === "function"
|
|
30
|
+
) {
|
|
31
|
+
// eslint-disable-next-line no-restricted-globals
|
|
32
|
+
return crypto.randomUUID();
|
|
33
|
+
}
|
|
34
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
35
|
+
const r = (Math.random() * 16) | 0;
|
|
36
|
+
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
function storageKey(base: string, scope?: string): string {
|
|
25
41
|
return scope ? `${base}:${scope}` : base;
|
|
26
42
|
}
|
|
@@ -37,6 +53,17 @@ function loadStoredIdentity(scope?: string): WidgetUser | undefined {
|
|
|
37
53
|
return undefined;
|
|
38
54
|
}
|
|
39
55
|
|
|
56
|
+
function loadTermsAccepted(scope?: string): boolean {
|
|
57
|
+
try {
|
|
58
|
+
return (
|
|
59
|
+
localStorage.getItem(storageKey("polymorph_tc_accepted", scope)) ===
|
|
60
|
+
"true"
|
|
61
|
+
);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
40
67
|
interface RoomConnection {
|
|
41
68
|
token: string;
|
|
42
69
|
livekitUrl: string;
|
|
@@ -87,6 +114,7 @@ function buildAuthHeaders(
|
|
|
87
114
|
|
|
88
115
|
export function usePolymorphSession(config: WidgetConfig) {
|
|
89
116
|
const apiBaseUrl = resolveApiBaseUrl(config.apiBaseUrl);
|
|
117
|
+
const autoCapture = config.autoCapture ?? true;
|
|
90
118
|
// Scope storage keys so multiple widget instances don't collide.
|
|
91
119
|
const scope = config.configId || config.apiKey;
|
|
92
120
|
const [status, setStatus] = useState<SessionStatus>("idle");
|
|
@@ -106,8 +134,10 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
106
134
|
const [resolvedUser, setResolvedUser] = useState<WidgetUser | undefined>(
|
|
107
135
|
config.user ?? loadStoredIdentity(scope),
|
|
108
136
|
);
|
|
137
|
+
const [termsAccepted, setTermsAccepted] = useState(loadTermsAccepted(scope));
|
|
109
138
|
const roomRef = useRef<Room | null>(null);
|
|
110
139
|
const pendingMessagesRef = useRef<string[]>([]);
|
|
140
|
+
const pendingEventsRef = useRef<WidgetEvent[]>([]);
|
|
111
141
|
const isPanelOpenRef = useRef<boolean>(false);
|
|
112
142
|
// Stable ref for fetchOptions to avoid infinite useEffect loops
|
|
113
143
|
// (inline objects like { credentials: "include" } create new refs each render)
|
|
@@ -154,6 +184,9 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
154
184
|
greeting: data.greeting,
|
|
155
185
|
collectEmail: data.collect_email,
|
|
156
186
|
collectPhone: data.collect_phone,
|
|
187
|
+
alwaysOn: data.always_on ?? false,
|
|
188
|
+
enableTermsAndConditions: data.enable_terms_and_conditions ?? false,
|
|
189
|
+
termsAndConditionsText: data.terms_and_conditions_text ?? "",
|
|
157
190
|
};
|
|
158
191
|
setResolvedConfig(cfg);
|
|
159
192
|
|
|
@@ -231,6 +264,44 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
231
264
|
[scope],
|
|
232
265
|
);
|
|
233
266
|
|
|
267
|
+
const acceptTerms = useCallback(() => {
|
|
268
|
+
setTermsAccepted(true);
|
|
269
|
+
try {
|
|
270
|
+
localStorage.setItem(storageKey("polymorph_tc_accepted", scope), "true");
|
|
271
|
+
} catch {
|
|
272
|
+
/* quota exceeded */
|
|
273
|
+
}
|
|
274
|
+
}, [scope]);
|
|
275
|
+
|
|
276
|
+
const needsTermsAcceptance =
|
|
277
|
+
resolvedConfig?.enableTermsAndConditions === true && !termsAccepted;
|
|
278
|
+
|
|
279
|
+
// Browser tab notification for unread messages
|
|
280
|
+
const originalTitleRef = useRef(
|
|
281
|
+
typeof document !== "undefined" ? document.title : "",
|
|
282
|
+
);
|
|
283
|
+
const [unreadCount, setUnreadCount] = useState(0);
|
|
284
|
+
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (unreadCount === 0) {
|
|
287
|
+
document.title = originalTitleRef.current;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const base = originalTitleRef.current;
|
|
291
|
+
let showNotification = true;
|
|
292
|
+
document.title = `(${unreadCount}) New message${unreadCount > 1 ? "s" : ""} - ${base}`;
|
|
293
|
+
const interval = setInterval(() => {
|
|
294
|
+
showNotification = !showNotification;
|
|
295
|
+
document.title = showNotification
|
|
296
|
+
? `(${unreadCount}) New message${unreadCount > 1 ? "s" : ""} - ${base}`
|
|
297
|
+
: base;
|
|
298
|
+
}, 1500);
|
|
299
|
+
return () => {
|
|
300
|
+
clearInterval(interval);
|
|
301
|
+
document.title = base;
|
|
302
|
+
};
|
|
303
|
+
}, [unreadCount]);
|
|
304
|
+
|
|
234
305
|
const addMessage = useCallback(
|
|
235
306
|
(
|
|
236
307
|
role: "user" | "agent",
|
|
@@ -242,11 +313,12 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
242
313
|
if (role === "agent" && !isPanelOpenRef.current) {
|
|
243
314
|
setHasUnread(true);
|
|
244
315
|
setWiggleKey((k) => k + 1);
|
|
316
|
+
setUnreadCount((c) => c + 1);
|
|
245
317
|
}
|
|
246
318
|
setMessages((prev) => [
|
|
247
319
|
...prev,
|
|
248
320
|
{
|
|
249
|
-
id:
|
|
321
|
+
id: uuid(),
|
|
250
322
|
role,
|
|
251
323
|
text,
|
|
252
324
|
source,
|
|
@@ -261,12 +333,14 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
261
333
|
|
|
262
334
|
const clearUnread = useCallback(() => {
|
|
263
335
|
setHasUnread(false);
|
|
336
|
+
setUnreadCount(0);
|
|
264
337
|
}, []);
|
|
265
338
|
|
|
266
339
|
const setPanelOpen = useCallback((open: boolean) => {
|
|
267
340
|
isPanelOpenRef.current = open;
|
|
268
341
|
if (open) {
|
|
269
342
|
setHasUnread(false);
|
|
343
|
+
setUnreadCount(0);
|
|
270
344
|
}
|
|
271
345
|
}, []);
|
|
272
346
|
|
|
@@ -292,7 +366,7 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
292
366
|
const sessionKey = storageKey("polymorph_widget_session", scope);
|
|
293
367
|
let externalUserId = localStorage.getItem(sessionKey) ?? undefined;
|
|
294
368
|
if (!externalUserId) {
|
|
295
|
-
externalUserId = `widget-session-${
|
|
369
|
+
externalUserId = `widget-session-${uuid().slice(0, 12)}`;
|
|
296
370
|
localStorage.setItem(sessionKey, externalUserId);
|
|
297
371
|
}
|
|
298
372
|
|
|
@@ -339,6 +413,13 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
339
413
|
}
|
|
340
414
|
}, [config, status, resolvedUser, resolvedConfig, apiBaseUrl, scope]);
|
|
341
415
|
|
|
416
|
+
// Auto-connect when always-on mode is enabled
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
if (resolvedConfig?.alwaysOn && status === "idle") {
|
|
419
|
+
void connect();
|
|
420
|
+
}
|
|
421
|
+
}, [resolvedConfig?.alwaysOn, status, connect]);
|
|
422
|
+
|
|
342
423
|
const disconnect = useCallback(() => {
|
|
343
424
|
roomRef.current?.disconnect();
|
|
344
425
|
roomRef.current = null;
|
|
@@ -380,8 +461,12 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
380
461
|
const newState = !room.localParticipant.isMicrophoneEnabled;
|
|
381
462
|
await room.localParticipant.setMicrophoneEnabled(newState);
|
|
382
463
|
setIsMicActive(newState);
|
|
383
|
-
} catch {
|
|
384
|
-
|
|
464
|
+
} catch (err) {
|
|
465
|
+
if (err instanceof DOMException && err.name === "NotAllowedError") {
|
|
466
|
+
setError(
|
|
467
|
+
"Microphone access denied. Please allow mic access in your browser settings.",
|
|
468
|
+
);
|
|
469
|
+
}
|
|
385
470
|
}
|
|
386
471
|
}, []);
|
|
387
472
|
|
|
@@ -406,8 +491,14 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
406
491
|
reliable: true,
|
|
407
492
|
topic: "voice_mode",
|
|
408
493
|
});
|
|
409
|
-
} catch {
|
|
410
|
-
|
|
494
|
+
} catch (err) {
|
|
495
|
+
if (err instanceof DOMException && err.name === "NotAllowedError") {
|
|
496
|
+
setError(
|
|
497
|
+
"Microphone access denied. Please allow mic access in your browser settings.",
|
|
498
|
+
);
|
|
499
|
+
setIsVoiceEnabled(false);
|
|
500
|
+
setIsMicActive(false);
|
|
501
|
+
}
|
|
411
502
|
}
|
|
412
503
|
}, [isVoiceEnabled, connect]);
|
|
413
504
|
|
|
@@ -423,6 +514,76 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
423
514
|
}
|
|
424
515
|
}, []);
|
|
425
516
|
|
|
517
|
+
const emitEvent = useCallback(
|
|
518
|
+
(type: WidgetEventType, payload?: Record<string, unknown>) => {
|
|
519
|
+
const event: WidgetEvent = {
|
|
520
|
+
type,
|
|
521
|
+
payload,
|
|
522
|
+
url: typeof window !== "undefined" ? window.location.href : undefined,
|
|
523
|
+
timestamp: Date.now(),
|
|
524
|
+
};
|
|
525
|
+
const room = roomRef.current;
|
|
526
|
+
if (room) {
|
|
527
|
+
const data = new TextEncoder().encode(JSON.stringify(event));
|
|
528
|
+
room.localParticipant.publishData(data, {
|
|
529
|
+
reliable: true,
|
|
530
|
+
topic: "widget_event",
|
|
531
|
+
});
|
|
532
|
+
} else {
|
|
533
|
+
pendingEventsRef.current.push(event);
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
[],
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
// Auto-capture: page_view on mount
|
|
540
|
+
useEffect(() => {
|
|
541
|
+
if (!autoCapture) return;
|
|
542
|
+
emitEvent("page_view", {
|
|
543
|
+
referrer: typeof document !== "undefined" ? document.referrer : "",
|
|
544
|
+
});
|
|
545
|
+
}, [autoCapture, emitEvent]);
|
|
546
|
+
|
|
547
|
+
// Auto-capture: idle detection (30s no interaction)
|
|
548
|
+
useEffect(() => {
|
|
549
|
+
if (!autoCapture) return;
|
|
550
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
551
|
+
const reset = () => {
|
|
552
|
+
clearTimeout(timer);
|
|
553
|
+
timer = setTimeout(() => emitEvent("idle"), 30_000);
|
|
554
|
+
};
|
|
555
|
+
const events = ["mousemove", "keydown", "click", "scroll", "touchstart"];
|
|
556
|
+
for (const e of events)
|
|
557
|
+
window.addEventListener(e, reset, { passive: true });
|
|
558
|
+
reset();
|
|
559
|
+
return () => {
|
|
560
|
+
clearTimeout(timer);
|
|
561
|
+
for (const e of events) window.removeEventListener(e, reset);
|
|
562
|
+
};
|
|
563
|
+
}, [autoCapture, emitEvent]);
|
|
564
|
+
|
|
565
|
+
// Auto-capture: rage click (3+ clicks on same target within 1s)
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
if (!autoCapture) return;
|
|
568
|
+
const clicks: { target: EventTarget | null; time: number }[] = [];
|
|
569
|
+
const onClick = (e: MouseEvent) => {
|
|
570
|
+
const now = Date.now();
|
|
571
|
+
clicks.push({ target: e.target, time: now });
|
|
572
|
+
while (clicks.length > 0 && now - clicks[0].time > 1000) clicks.shift();
|
|
573
|
+
const sameTarget = clicks.filter((c) => c.target === e.target);
|
|
574
|
+
if (sameTarget.length >= 3) {
|
|
575
|
+
const el = e.target as HTMLElement | null;
|
|
576
|
+
emitEvent("rage_click", {
|
|
577
|
+
element: el?.tagName?.toLowerCase(),
|
|
578
|
+
text: el?.textContent?.slice(0, 100),
|
|
579
|
+
});
|
|
580
|
+
clicks.length = 0;
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
window.addEventListener("click", onClick);
|
|
584
|
+
return () => window.removeEventListener("click", onClick);
|
|
585
|
+
}, [autoCapture, emitEvent]);
|
|
586
|
+
|
|
426
587
|
const setRoom = useCallback((room: Room | null) => {
|
|
427
588
|
roomRef.current = room;
|
|
428
589
|
if (room) {
|
|
@@ -439,6 +600,16 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
439
600
|
room.on(RoomEvent.ParticipantConnected, checkObserver);
|
|
440
601
|
room.on(RoomEvent.ParticipantDisconnected, checkObserver);
|
|
441
602
|
|
|
603
|
+
// Flush pending widget events
|
|
604
|
+
const pendingEvents = pendingEventsRef.current.splice(0);
|
|
605
|
+
for (const event of pendingEvents) {
|
|
606
|
+
const data = new TextEncoder().encode(JSON.stringify(event));
|
|
607
|
+
room.localParticipant.publishData(data, {
|
|
608
|
+
reliable: true,
|
|
609
|
+
topic: "widget_event",
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
442
613
|
// Flush pending messages once the agent participant has joined the room.
|
|
443
614
|
// LiveKit data messages are only delivered to participants currently in
|
|
444
615
|
// the room, so sending before the agent connects means messages are lost.
|
|
@@ -498,5 +669,9 @@ export function usePolymorphSession(config: WidgetConfig) {
|
|
|
498
669
|
setUser,
|
|
499
670
|
clearUnread,
|
|
500
671
|
setPanelOpen,
|
|
672
|
+
emitEvent,
|
|
673
|
+
setError,
|
|
674
|
+
needsTermsAcceptance,
|
|
675
|
+
acceptTerms,
|
|
501
676
|
};
|
|
502
677
|
}
|