@smooai/chat-widget 0.6.0 → 0.7.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/dist/chat-widget.global.js +1160 -31
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +331 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1005 -32
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/config.ts +31 -1
- package/src/conversation.ts +531 -18
- package/src/element.ts +247 -10
- package/src/fingerprint.ts +122 -0
- package/src/index.ts +14 -0
- package/src/persistence.ts +271 -0
- package/src/styles.ts +66 -0
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { ProtocolError, SmoothAgentClient } from "@smooai/smooth-operator";
|
|
2
|
+
import { createStore } from "zustand/vanilla";
|
|
3
|
+
import { persist } from "zustand/middleware";
|
|
2
4
|
//#region src/config.ts
|
|
3
5
|
/** Resolve a partial config against the built-in defaults. */
|
|
4
6
|
function resolveConfig(config) {
|
|
@@ -17,6 +19,7 @@ function resolveConfig(config) {
|
|
|
17
19
|
userName: config.userName,
|
|
18
20
|
userEmail: config.userEmail,
|
|
19
21
|
userPhone: config.userPhone,
|
|
22
|
+
authContext: config.authContext,
|
|
20
23
|
placeholder: config.placeholder ?? "Type a message…",
|
|
21
24
|
greeting: config.greeting ?? "Hi! How can I help you today?",
|
|
22
25
|
connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
|
|
@@ -25,6 +28,9 @@ function resolveConfig(config) {
|
|
|
25
28
|
requireName: config.requireName ?? false,
|
|
26
29
|
requireEmail: config.requireEmail ?? false,
|
|
27
30
|
requirePhone: config.requirePhone ?? false,
|
|
31
|
+
collectPhone: config.collectPhone ?? true,
|
|
32
|
+
collectConsent: config.collectConsent ?? true,
|
|
33
|
+
allowChatRestore: config.allowChatRestore ?? true,
|
|
28
34
|
allowAnonymous: config.allowAnonymous ?? false,
|
|
29
35
|
theme: {
|
|
30
36
|
text: theme.text ?? "#f8fafc",
|
|
@@ -48,6 +54,293 @@ function needsUserInfo(resolved) {
|
|
|
48
54
|
return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
|
|
49
55
|
}
|
|
50
56
|
//#endregion
|
|
57
|
+
//#region src/fingerprint.ts
|
|
58
|
+
/**
|
|
59
|
+
* Browser fingerprint — a stable, privacy-light identifier used by the
|
|
60
|
+
* smooth-operator server to correlate an anonymous visitor's sessions across
|
|
61
|
+
* page loads (and, server-side, to match an anonymous fingerprint to a known
|
|
62
|
+
* CRM contact). It rides every `create_conversation_session` as
|
|
63
|
+
* `browserFingerprint` (see ADR-048).
|
|
64
|
+
*
|
|
65
|
+
* ## Why not ThumbmarkJS
|
|
66
|
+
*
|
|
67
|
+
* The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
|
|
68
|
+
* that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
|
|
69
|
+
* full build pulls in extensive device-detection tables and async
|
|
70
|
+
* component collection, adding tens of kilobytes (and async surface) to a
|
|
71
|
+
* bundle whose whole selling point is staying out of the host page's
|
|
72
|
+
* LCP/TBT budget. The fingerprint here is a few hundred bytes.
|
|
73
|
+
*
|
|
74
|
+
* ## What this is (and the tradeoff)
|
|
75
|
+
*
|
|
76
|
+
* The correlation that actually matters — "is this the same browser as last
|
|
77
|
+
* time?" — is carried by a **persisted random UUID** (the `browserFingerprint`
|
|
78
|
+
* field in the persisted store). That UUID is generated once and reused for
|
|
79
|
+
* the life of the localStorage entry, so same-browser resume is exact and
|
|
80
|
+
* deterministic. The signal hash below is a best-effort entropy *supplement*
|
|
81
|
+
* mixed into that UUID's derivation seed on first generation — it gives the
|
|
82
|
+
* server-side resolver a soft signal for the (rare) cross-storage case where
|
|
83
|
+
* the UUID was cleared but the device is unchanged. It is intentionally NOT a
|
|
84
|
+
* high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
|
|
85
|
+
* enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
|
|
86
|
+
* cross-storage matching in exchange for a tiny, transparent, no-network,
|
|
87
|
+
* XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
|
|
88
|
+
* of truth for any fuzzy matching; the client just supplies a stable token.
|
|
89
|
+
*/
|
|
90
|
+
/** Collect a small set of stable, non-invasive browser signals. */
|
|
91
|
+
function collectSignals() {
|
|
92
|
+
const parts = [];
|
|
93
|
+
try {
|
|
94
|
+
const nav = typeof navigator !== "undefined" ? navigator : void 0;
|
|
95
|
+
if (nav) {
|
|
96
|
+
parts.push(`ua:${nav.userAgent ?? ""}`);
|
|
97
|
+
parts.push(`lang:${nav.language ?? ""}`);
|
|
98
|
+
parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(",") : ""}`);
|
|
99
|
+
parts.push(`plat:${nav.platform ?? ""}`);
|
|
100
|
+
parts.push(`hc:${nav.hardwareConcurrency ?? ""}`);
|
|
101
|
+
parts.push(`dm:${nav.deviceMemory ?? ""}`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof screen !== "undefined") parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
|
|
104
|
+
if (typeof Intl !== "undefined") try {
|
|
105
|
+
parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""}`);
|
|
106
|
+
} catch {}
|
|
107
|
+
parts.push(`tzo:${(/* @__PURE__ */ new Date()).getTimezoneOffset()}`);
|
|
108
|
+
} catch {}
|
|
109
|
+
return parts.join("|");
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
|
|
113
|
+
* an unsigned hex string. Stable across runs for the same input. Not used for
|
|
114
|
+
* any security decision — only to derive a deterministic seed from the signal
|
|
115
|
+
* string above.
|
|
116
|
+
*/
|
|
117
|
+
function fnv1a(input) {
|
|
118
|
+
let h = 2166136261;
|
|
119
|
+
for (let i = 0; i < input.length; i++) {
|
|
120
|
+
h ^= input.charCodeAt(i);
|
|
121
|
+
h = Math.imul(h, 16777619);
|
|
122
|
+
}
|
|
123
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
124
|
+
}
|
|
125
|
+
/** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
|
|
126
|
+
function generateUuid() {
|
|
127
|
+
try {
|
|
128
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
129
|
+
} catch {}
|
|
130
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
131
|
+
const r = Math.random() * 16 | 0;
|
|
132
|
+
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Compute a fresh fingerprint token: a stable random UUID, suffixed with the
|
|
137
|
+
* FNV hash of the device signals so the server can recover a soft device
|
|
138
|
+
* signal even if it only has the token. Called ONCE per browser (the result is
|
|
139
|
+
* persisted and reused) — see {@link getOrCreateFingerprint}.
|
|
140
|
+
*/
|
|
141
|
+
function computeFingerprint() {
|
|
142
|
+
const signalHash = fnv1a(collectSignals());
|
|
143
|
+
return `${generateUuid()}.${signalHash}`;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Return the cached fingerprint if one was already computed for this browser,
|
|
147
|
+
* otherwise compute + persist a new one via the provided accessors. The
|
|
148
|
+
* persistence layer owns storage; this function owns the "compute once" policy.
|
|
149
|
+
*/
|
|
150
|
+
function getOrCreateFingerprint(get, set) {
|
|
151
|
+
const existing = get();
|
|
152
|
+
if (existing) return existing;
|
|
153
|
+
const fp = computeFingerprint();
|
|
154
|
+
set(fp);
|
|
155
|
+
return fp;
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region src/persistence.ts
|
|
159
|
+
/**
|
|
160
|
+
* Persisted widget state — the identity / consent / session-pointer client layer
|
|
161
|
+
* (ADR-048, SMOODEV-2129e).
|
|
162
|
+
*
|
|
163
|
+
* Built on Zustand's framework-agnostic `vanilla` store + the `persist`
|
|
164
|
+
* middleware (the user explicitly chose Zustand; it's ~1KB and has no React
|
|
165
|
+
* dependency, so it works inside the web component). The store is keyed per
|
|
166
|
+
* agent — localStorage key `smoo-chat-widget:<agentId>` — so two agents embedded
|
|
167
|
+
* on the same origin don't clobber each other.
|
|
168
|
+
*
|
|
169
|
+
* ## What persists (and, deliberately, what does NOT)
|
|
170
|
+
*
|
|
171
|
+
* Only a **pointer** to the conversation plus the visitor's identity + marketing
|
|
172
|
+
* consent + verified email + browser fingerprint. The transcript is **never**
|
|
173
|
+
* persisted: the smooth-operator server is the source of truth and the widget
|
|
174
|
+
* re-hydrates history via `getMessages` on resume. This keeps localStorage small
|
|
175
|
+
* and avoids stale/divergent transcripts.
|
|
176
|
+
*
|
|
177
|
+
* `version` drives `persist.migrate` so future shape changes can upgrade old
|
|
178
|
+
* blobs in place instead of silently dropping them.
|
|
179
|
+
*/
|
|
180
|
+
/** Current persisted-shape version. Bump when the shape below changes incompatibly. */
|
|
181
|
+
const PERSIST_VERSION = 1;
|
|
182
|
+
const EMPTY_CONSENT = {
|
|
183
|
+
emailOptIn: false,
|
|
184
|
+
smsOptIn: false
|
|
185
|
+
};
|
|
186
|
+
function initialPersisted() {
|
|
187
|
+
return {
|
|
188
|
+
version: 1,
|
|
189
|
+
sessionId: null,
|
|
190
|
+
identity: {},
|
|
191
|
+
consent: { ...EMPTY_CONSENT },
|
|
192
|
+
verifiedEmail: null,
|
|
193
|
+
verifiedEmailSessionId: null,
|
|
194
|
+
browserFingerprint: null
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/** localStorage key for an agent's persisted widget state. */
|
|
198
|
+
function storageKey(agentId) {
|
|
199
|
+
return `smoo-chat-widget:${agentId}`;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* An explicit in-memory (Map-backed) `PersistStorage`. Used as the fallback when
|
|
203
|
+
* real localStorage is unavailable.
|
|
204
|
+
*
|
|
205
|
+
* IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
|
|
206
|
+
* zustand v5's `persist` treats a missing `storage` option by falling back to its
|
|
207
|
+
* OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
|
|
208
|
+
* localStorage the guard was trying to avoid (throwing again in privacy mode).
|
|
209
|
+
* Handing it this no-op storage keeps the store working purely in memory and
|
|
210
|
+
* guarantees the fallback can't touch real localStorage.
|
|
211
|
+
*/
|
|
212
|
+
function memoryStorage() {
|
|
213
|
+
const mem = /* @__PURE__ */ new Map();
|
|
214
|
+
return {
|
|
215
|
+
getItem: (name) => {
|
|
216
|
+
const raw = mem.get(name);
|
|
217
|
+
if (!raw) return null;
|
|
218
|
+
try {
|
|
219
|
+
return JSON.parse(raw);
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
setItem: (name, value) => {
|
|
225
|
+
mem.set(name, JSON.stringify(value));
|
|
226
|
+
},
|
|
227
|
+
removeItem: (name) => {
|
|
228
|
+
mem.delete(name);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* A `persist` storage adapter that tolerates the *absence* of localStorage
|
|
234
|
+
* (SSR, privacy-mode throwing on access, sandboxed iframes). When storage is
|
|
235
|
+
* unavailable the store still works in-memory; nothing is persisted, but the
|
|
236
|
+
* widget never throws on boot.
|
|
237
|
+
*/
|
|
238
|
+
function safeStorage() {
|
|
239
|
+
let ls = null;
|
|
240
|
+
try {
|
|
241
|
+
ls = typeof localStorage !== "undefined" ? localStorage : null;
|
|
242
|
+
if (ls) {
|
|
243
|
+
const probe = "__smoo_probe__";
|
|
244
|
+
ls.setItem(probe, "1");
|
|
245
|
+
ls.removeItem(probe);
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
ls = null;
|
|
249
|
+
}
|
|
250
|
+
if (!ls) return memoryStorage();
|
|
251
|
+
const storage = ls;
|
|
252
|
+
return {
|
|
253
|
+
getItem: (name) => {
|
|
254
|
+
try {
|
|
255
|
+
const raw = storage.getItem(name);
|
|
256
|
+
return raw ? JSON.parse(raw) : null;
|
|
257
|
+
} catch {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
setItem: (name, value) => {
|
|
262
|
+
try {
|
|
263
|
+
storage.setItem(name, JSON.stringify(value));
|
|
264
|
+
} catch {}
|
|
265
|
+
},
|
|
266
|
+
removeItem: (name) => {
|
|
267
|
+
try {
|
|
268
|
+
storage.removeItem(name);
|
|
269
|
+
} catch {}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Migrate a persisted blob from an older `version` to the current shape. Today
|
|
275
|
+
* v1 is the only version, so this just backfills any missing fields onto an
|
|
276
|
+
* unknown old blob; future versions add `case` branches here.
|
|
277
|
+
*/
|
|
278
|
+
function migrate(persisted) {
|
|
279
|
+
const base = initialPersisted();
|
|
280
|
+
if (!persisted || typeof persisted !== "object") return base;
|
|
281
|
+
const p = persisted;
|
|
282
|
+
return {
|
|
283
|
+
version: 1,
|
|
284
|
+
sessionId: typeof p.sessionId === "string" ? p.sessionId : null,
|
|
285
|
+
identity: {
|
|
286
|
+
name: typeof p.identity?.name === "string" ? p.identity.name : void 0,
|
|
287
|
+
email: typeof p.identity?.email === "string" ? p.identity.email : void 0,
|
|
288
|
+
phone: typeof p.identity?.phone === "string" ? p.identity.phone : void 0
|
|
289
|
+
},
|
|
290
|
+
consent: {
|
|
291
|
+
emailOptIn: p.consent?.emailOptIn === true,
|
|
292
|
+
smsOptIn: p.consent?.smsOptIn === true,
|
|
293
|
+
consentSource: typeof p.consent?.consentSource === "string" ? p.consent.consentSource : void 0,
|
|
294
|
+
consentAt: typeof p.consent?.consentAt === "string" ? p.consent.consentAt : void 0
|
|
295
|
+
},
|
|
296
|
+
verifiedEmail: typeof p.verifiedEmail === "string" ? p.verifiedEmail : null,
|
|
297
|
+
verifiedEmailSessionId: typeof p.verifiedEmailSessionId === "string" ? p.verifiedEmailSessionId : null,
|
|
298
|
+
browserFingerprint: typeof p.browserFingerprint === "string" ? p.browserFingerprint : null
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Create the per-agent persisted Zustand store. The `partialize` step ensures
|
|
303
|
+
* only the persisted shape (never any future transient action/UI state) is
|
|
304
|
+
* written to localStorage.
|
|
305
|
+
*/
|
|
306
|
+
function createWidgetStore(agentId) {
|
|
307
|
+
return createStore()(persist((set) => ({
|
|
308
|
+
...initialPersisted(),
|
|
309
|
+
setSessionId: (sessionId) => set({ sessionId }),
|
|
310
|
+
mergeIdentity: (identity) => set((state) => ({ identity: {
|
|
311
|
+
...state.identity,
|
|
312
|
+
...identity.name !== void 0 ? { name: identity.name } : {},
|
|
313
|
+
...identity.email !== void 0 ? { email: identity.email } : {},
|
|
314
|
+
...identity.phone !== void 0 ? { phone: identity.phone } : {}
|
|
315
|
+
} })),
|
|
316
|
+
setConsent: (consent) => set({ consent: { ...consent } }),
|
|
317
|
+
setVerifiedEmail: (verifiedEmail, sessionId) => set((state) => ({
|
|
318
|
+
verifiedEmail,
|
|
319
|
+
verifiedEmailSessionId: verifiedEmail === null ? null : sessionId ?? state.sessionId
|
|
320
|
+
})),
|
|
321
|
+
setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
|
|
322
|
+
clearSession: () => set({
|
|
323
|
+
sessionId: null,
|
|
324
|
+
verifiedEmail: null,
|
|
325
|
+
verifiedEmailSessionId: null
|
|
326
|
+
})
|
|
327
|
+
}), {
|
|
328
|
+
name: storageKey(agentId),
|
|
329
|
+
version: 1,
|
|
330
|
+
storage: safeStorage(),
|
|
331
|
+
migrate,
|
|
332
|
+
partialize: (state) => ({
|
|
333
|
+
version: 1,
|
|
334
|
+
sessionId: state.sessionId,
|
|
335
|
+
identity: state.identity,
|
|
336
|
+
consent: state.consent,
|
|
337
|
+
verifiedEmail: state.verifiedEmail,
|
|
338
|
+
verifiedEmailSessionId: state.verifiedEmailSessionId,
|
|
339
|
+
browserFingerprint: state.browserFingerprint
|
|
340
|
+
})
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
51
344
|
//#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
|
|
52
345
|
function _typeof(o) {
|
|
53
346
|
"@babel/helpers - typeof";
|
|
@@ -97,13 +390,41 @@ function _defineProperty(e, r, t) {
|
|
|
97
390
|
* so the swap is purely at the client-library boundary.
|
|
98
391
|
*
|
|
99
392
|
* Flow:
|
|
100
|
-
* 1. `connect()` → opens the WebSocket transport and `create_conversation_session
|
|
393
|
+
* 1. `connect()` → opens the WebSocket transport and `create_conversation_session`
|
|
394
|
+
* (or RESUMES a persisted session via `get_session`/`get_messages`).
|
|
101
395
|
* 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
|
|
102
396
|
* in-progress assistant message, then the terminal
|
|
103
397
|
* `eventual_response`.
|
|
104
398
|
*
|
|
399
|
+
* 0.7.0 (SMOODEV-2129e) adds the identity / persistence / consent client layer:
|
|
400
|
+
* - `browserFingerprint` computed once + sent on every `create_conversation_session`.
|
|
401
|
+
* - identity + marketing consent threaded into the session `metadata`.
|
|
402
|
+
* - same-session RESUME on load (no engine change — `get_session` + `get_messages`).
|
|
403
|
+
* - returning-visitor RESUME via `POST /internal/resume-by-fingerprint`, and
|
|
404
|
+
* cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
|
|
405
|
+
* verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
|
|
406
|
+
* 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
|
|
407
|
+
* `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
|
|
408
|
+
* NOT WS frames.
|
|
409
|
+
*
|
|
105
410
|
* The controller is UI-agnostic: it emits typed events and the view renders them.
|
|
106
411
|
*/
|
|
412
|
+
/**
|
|
413
|
+
* Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
|
|
414
|
+
* the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
|
|
415
|
+
* (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
|
|
416
|
+
* the cross-device identity flow + fingerprint resume are HTTP POST routes on the
|
|
417
|
+
* wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames.
|
|
418
|
+
*/
|
|
419
|
+
function httpBaseFromWsEndpoint(endpoint) {
|
|
420
|
+
try {
|
|
421
|
+
const u = new URL(endpoint);
|
|
422
|
+
u.protocol = u.protocol === "ws:" ? "http:" : u.protocol === "wss:" ? "https:" : u.protocol;
|
|
423
|
+
return `${u.protocol}//${u.host}`;
|
|
424
|
+
} catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
107
428
|
/** Pull the final assistant text out of an `eventual_response` data payload. */
|
|
108
429
|
function extractFinalText(response) {
|
|
109
430
|
if (!response || typeof response !== "object") return null;
|
|
@@ -144,40 +465,89 @@ function extractCitations(inner) {
|
|
|
144
465
|
}
|
|
145
466
|
return out;
|
|
146
467
|
}
|
|
468
|
+
/** Convert a server message row into a finalized {@link ChatMessage}. */
|
|
469
|
+
function wireMessageToChat(m, idx) {
|
|
470
|
+
const text = typeof m.content?.text === "string" ? m.content.text : "";
|
|
471
|
+
if (!text) return null;
|
|
472
|
+
const role = m.direction === "outbound" ? "assistant" : "user";
|
|
473
|
+
return {
|
|
474
|
+
id: typeof m.id === "string" ? m.id : `hist-${idx}`,
|
|
475
|
+
role,
|
|
476
|
+
text,
|
|
477
|
+
streaming: false
|
|
478
|
+
};
|
|
479
|
+
}
|
|
147
480
|
var ConversationController = class {
|
|
148
|
-
constructor(config, events) {
|
|
481
|
+
constructor(config, events, store) {
|
|
149
482
|
_defineProperty(this, "config", void 0);
|
|
150
483
|
_defineProperty(this, "events", void 0);
|
|
484
|
+
_defineProperty(this, "store", void 0);
|
|
151
485
|
_defineProperty(this, "client", null);
|
|
152
486
|
_defineProperty(this, "sessionId", null);
|
|
153
487
|
_defineProperty(this, "messages", []);
|
|
154
488
|
_defineProperty(this, "status", "idle");
|
|
155
489
|
_defineProperty(this, "seq", 0);
|
|
156
|
-
_defineProperty(this, "identity", void 0);
|
|
157
490
|
_defineProperty(this, "activeRequestId", null);
|
|
158
491
|
_defineProperty(this, "interrupt", null);
|
|
492
|
+
_defineProperty(this, "identityRestore", { phase: "idle" });
|
|
493
|
+
_defineProperty(this, "resumeAttempted", false);
|
|
494
|
+
_defineProperty(this, "httpBase", void 0);
|
|
159
495
|
this.config = config;
|
|
160
496
|
this.events = events;
|
|
161
|
-
this.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
497
|
+
this.httpBase = httpBaseFromWsEndpoint(config.endpoint);
|
|
498
|
+
if (this.httpBase === null) queueMicrotask(() => this.setStatus("error", `Invalid chat endpoint: ${config.endpoint}`));
|
|
499
|
+
this.store = store ?? createWidgetStore(config.agentId);
|
|
500
|
+
const seed = {};
|
|
501
|
+
if (config.userName) seed.name = config.userName;
|
|
502
|
+
if (config.userEmail) seed.email = config.userEmail;
|
|
503
|
+
if (config.userPhone) seed.phone = config.userPhone;
|
|
504
|
+
if (Object.keys(seed).length > 0) this.store.getState().mergeIdentity(seed);
|
|
166
505
|
}
|
|
167
506
|
get connectionStatus() {
|
|
168
507
|
return this.status;
|
|
169
508
|
}
|
|
170
|
-
/**
|
|
509
|
+
/** The persisted store, exposed so the view can read identity for the pre-chat gate. */
|
|
510
|
+
getStore() {
|
|
511
|
+
return this.store;
|
|
512
|
+
}
|
|
513
|
+
/** True when a persisted session pointer exists (drives the resume path). */
|
|
514
|
+
hasPersistedSession() {
|
|
515
|
+
return !!this.store.getState().sessionId;
|
|
516
|
+
}
|
|
517
|
+
/** True when persisted identity exists (lets the view skip the pre-chat form). */
|
|
518
|
+
hasPersistedIdentity() {
|
|
519
|
+
const id = this.store.getState().identity;
|
|
520
|
+
return !!(id.name || id.email || id.phone);
|
|
521
|
+
}
|
|
522
|
+
/** Merge in visitor identity + consent (from the pre-chat form). Applied on next connect. */
|
|
171
523
|
setUserInfo(info) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
524
|
+
const { name, email, phone, consent } = info;
|
|
525
|
+
this.store.getState().mergeIdentity({
|
|
526
|
+
name,
|
|
527
|
+
email,
|
|
528
|
+
phone
|
|
529
|
+
});
|
|
530
|
+
if (consent) {
|
|
531
|
+
const consentAt = consent.emailOptIn || consent.smsOptIn ? (/* @__PURE__ */ new Date()).toISOString() : void 0;
|
|
532
|
+
this.store.getState().setConsent({
|
|
533
|
+
emailOptIn: consent.emailOptIn,
|
|
534
|
+
smsOptIn: consent.smsOptIn,
|
|
535
|
+
consentSource: "chat-widget-prechat",
|
|
536
|
+
consentAt
|
|
537
|
+
});
|
|
538
|
+
}
|
|
176
539
|
}
|
|
177
540
|
setInterrupt(interrupt) {
|
|
178
541
|
this.interrupt = interrupt;
|
|
179
542
|
this.events.onInterrupt?.(interrupt);
|
|
180
543
|
}
|
|
544
|
+
setIdentityRestore(state) {
|
|
545
|
+
this.identityRestore = state;
|
|
546
|
+
this.events.onIdentityRestore?.(state);
|
|
547
|
+
}
|
|
548
|
+
get currentIdentityRestore() {
|
|
549
|
+
return this.identityRestore;
|
|
550
|
+
}
|
|
181
551
|
/** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
|
|
182
552
|
verifyOtp(code) {
|
|
183
553
|
if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
|
|
@@ -208,20 +578,95 @@ var ConversationController = class {
|
|
|
208
578
|
emitMessages() {
|
|
209
579
|
this.events.onMessages(this.messages.map((m) => ({ ...m })));
|
|
210
580
|
}
|
|
211
|
-
/**
|
|
581
|
+
/** Compute (once) + return the persisted browser fingerprint. */
|
|
582
|
+
fingerprint() {
|
|
583
|
+
const state = this.store.getState();
|
|
584
|
+
return getOrCreateFingerprint(() => state.browserFingerprint, (fp) => this.store.getState().setBrowserFingerprint(fp));
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Build the `metadata` payload threaded into `create_conversation_session`:
|
|
588
|
+
* phone (no first-class engine field) and consent.
|
|
589
|
+
*
|
|
590
|
+
* NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
|
|
591
|
+
* OTP proof bound to the session it was verified against
|
|
592
|
+
* (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
|
|
593
|
+
* as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
|
|
594
|
+
* every brand-new `create_conversation_session` would mislabel a fresh
|
|
595
|
+
* visitor's session with a prior (possibly different) visitor's email on a
|
|
596
|
+
* shared browser. The verified email is only used when RESUMING the exact
|
|
597
|
+
* session it was proven for — see {@link verifiedEmailForSession}.
|
|
598
|
+
*/
|
|
599
|
+
sessionMetadata() {
|
|
600
|
+
const state = this.store.getState();
|
|
601
|
+
const meta = {};
|
|
602
|
+
if (state.identity.phone) meta.userPhone = state.identity.phone;
|
|
603
|
+
const consent = state.consent;
|
|
604
|
+
if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
|
|
605
|
+
const c = {
|
|
606
|
+
emailOptIn: consent.emailOptIn,
|
|
607
|
+
smsOptIn: consent.smsOptIn,
|
|
608
|
+
consentSource: consent.consentSource ?? "chat-widget-prechat"
|
|
609
|
+
};
|
|
610
|
+
if (consent.consentAt) c.consentAt = consent.consentAt;
|
|
611
|
+
meta.consent = c;
|
|
612
|
+
}
|
|
613
|
+
return Object.keys(meta).length > 0 ? meta : void 0;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* The verified-email hint, but ONLY when the OTP proof is bound to the session
|
|
617
|
+
* being resumed (`verifiedEmailSessionId === sessionId`). Returns null
|
|
618
|
+
* otherwise so a stale/cross-visitor proof is never threaded onto a session it
|
|
619
|
+
* wasn't proven for.
|
|
620
|
+
*/
|
|
621
|
+
verifiedEmailForSession(sessionId) {
|
|
622
|
+
const state = this.store.getState();
|
|
623
|
+
if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) return state.verifiedEmail;
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
/** Lazily open the WS client (default transport). Idempotent within a connect. */
|
|
627
|
+
async ensureClient() {
|
|
628
|
+
if (this.client) return;
|
|
629
|
+
this.client = new SmoothAgentClient({ url: this.config.endpoint });
|
|
630
|
+
await this.client.connect();
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Open the connection and either RESUME or create a session.
|
|
634
|
+
*
|
|
635
|
+
* 1. Persisted pointer (ADR-048 §b): `get_session` → if not `ended`, reuse +
|
|
636
|
+
* hydrate from `get_messages` (newest-first, reversed). On ended/404 clear
|
|
637
|
+
* ONLY the pointer (identity/consent survive).
|
|
638
|
+
* 2. No persisted pointer: POST `/internal/resume-by-fingerprint` FIRST; if
|
|
639
|
+
* `resumable`, adopt the returned session (the wrapper has primed the
|
|
640
|
+
* operator registry), reuse the sessionId, and hydrate via get_session/
|
|
641
|
+
* get_messages — rather than relying on createConversationSession to resume.
|
|
642
|
+
* 3. Otherwise create a fresh session.
|
|
643
|
+
*/
|
|
212
644
|
async connect() {
|
|
213
645
|
if (this.status === "connecting" || this.status === "ready") return;
|
|
214
646
|
this.setStatus("connecting");
|
|
215
647
|
try {
|
|
216
|
-
this.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
648
|
+
await this.ensureClient();
|
|
649
|
+
if (!this.resumeAttempted) {
|
|
650
|
+
this.resumeAttempted = true;
|
|
651
|
+
const persistedSessionId = this.store.getState().sessionId;
|
|
652
|
+
if (persistedSessionId) {
|
|
653
|
+
if (await this.tryResume(persistedSessionId)) {
|
|
654
|
+
this.setStatus("ready");
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
this.store.getState().clearSession();
|
|
658
|
+
} else {
|
|
659
|
+
const fpSessionId = await this.resumeByFingerprint();
|
|
660
|
+
if (fpSessionId) {
|
|
661
|
+
if (await this.tryResume(fpSessionId)) {
|
|
662
|
+
this.store.getState().setSessionId(fpSessionId);
|
|
663
|
+
this.setStatus("ready");
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
await this.createSession();
|
|
225
670
|
this.setStatus("ready");
|
|
226
671
|
} catch (err) {
|
|
227
672
|
this.setStatus("error", err instanceof Error ? err.message : String(err));
|
|
@@ -229,6 +674,105 @@ var ConversationController = class {
|
|
|
229
674
|
}
|
|
230
675
|
}
|
|
231
676
|
/**
|
|
677
|
+
* Build the auth fields every `/internal/*` route shares: `agentId` (required
|
|
678
|
+
* for the agent-policy lookup), `agentName` (used as the OTP email sender), and
|
|
679
|
+
* the optional pre-auth `authContext` the host page may have configured. The
|
|
680
|
+
* `Origin` header is sent automatically by the browser and checked server-side.
|
|
681
|
+
*/
|
|
682
|
+
authBody() {
|
|
683
|
+
const body = { agentId: this.config.agentId };
|
|
684
|
+
if (this.config.agentName) body.agentName = this.config.agentName;
|
|
685
|
+
if (this.config.authContext) body.authContext = this.config.authContext;
|
|
686
|
+
return body;
|
|
687
|
+
}
|
|
688
|
+
/** POST JSON to a `/internal/*` route; returns the parsed body (or throws). */
|
|
689
|
+
async postInternal(path, payload) {
|
|
690
|
+
if (this.httpBase === null) throw new Error(`Cannot reach ${path}: the chat endpoint is not an absolute URL.`);
|
|
691
|
+
const res = await fetch(`${this.httpBase}${path}`, {
|
|
692
|
+
method: "POST",
|
|
693
|
+
headers: { "content-type": "application/json" },
|
|
694
|
+
credentials: "omit",
|
|
695
|
+
body: JSON.stringify({
|
|
696
|
+
...this.authBody(),
|
|
697
|
+
...payload
|
|
698
|
+
})
|
|
699
|
+
});
|
|
700
|
+
let json = {};
|
|
701
|
+
try {
|
|
702
|
+
json = await res.json();
|
|
703
|
+
} catch {
|
|
704
|
+
json = {};
|
|
705
|
+
}
|
|
706
|
+
if (!res.ok) {
|
|
707
|
+
const err = json.error;
|
|
708
|
+
throw new Error(err?.message ?? `${path} failed (${res.status})`);
|
|
709
|
+
}
|
|
710
|
+
return json;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* POST `/internal/resume-by-fingerprint`. Returns the resumable sessionId when
|
|
714
|
+
* the wrapper found (and primed) a recent session for this fingerprint, else
|
|
715
|
+
* null. Network/route failures are swallowed → null (fall through to create).
|
|
716
|
+
*/
|
|
717
|
+
async resumeByFingerprint() {
|
|
718
|
+
try {
|
|
719
|
+
const json = await this.postInternal("/internal/resume-by-fingerprint", { browserFingerprint: this.fingerprint() });
|
|
720
|
+
if (json.resumable === true && typeof json.sessionId === "string") return json.sessionId;
|
|
721
|
+
} catch {}
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
/** `create_conversation_session` with fingerprint + identity + consent metadata. */
|
|
725
|
+
async createSession() {
|
|
726
|
+
if (!this.client) throw new Error("Conversation is not connected");
|
|
727
|
+
const state = this.store.getState();
|
|
728
|
+
const metadata = this.sessionMetadata();
|
|
729
|
+
const session = await this.client.createConversationSession({
|
|
730
|
+
agentId: this.config.agentId,
|
|
731
|
+
userName: state.identity.name,
|
|
732
|
+
userEmail: state.identity.email,
|
|
733
|
+
browserFingerprint: this.fingerprint(),
|
|
734
|
+
...metadata ? { metadata } : {}
|
|
735
|
+
});
|
|
736
|
+
this.sessionId = session.sessionId;
|
|
737
|
+
this.store.getState().setSessionId(session.sessionId);
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Attempt to resume `sessionId`: returns true and hydrates the transcript when
|
|
741
|
+
* the session is live, false when it has ended / can't be fetched.
|
|
742
|
+
*/
|
|
743
|
+
async tryResume(sessionId) {
|
|
744
|
+
if (!this.client) return false;
|
|
745
|
+
let snap;
|
|
746
|
+
try {
|
|
747
|
+
snap = await this.client.getSession({ sessionId });
|
|
748
|
+
} catch {
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
if (snap.status === "ended") return false;
|
|
752
|
+
this.sessionId = sessionId;
|
|
753
|
+
await this.hydrateHistory(sessionId);
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
/** Page recent history (newest-first), reverse to chronological, and render. */
|
|
757
|
+
async hydrateHistory(sessionId) {
|
|
758
|
+
if (!this.client) return;
|
|
759
|
+
try {
|
|
760
|
+
const page = await this.client.getMessages({
|
|
761
|
+
sessionId,
|
|
762
|
+
limit: 50
|
|
763
|
+
});
|
|
764
|
+
const chronological = [...Array.isArray(page.messages) ? page.messages : []].reverse();
|
|
765
|
+
const hydrated = [];
|
|
766
|
+
chronological.forEach((m, i) => {
|
|
767
|
+
const chat = wireMessageToChat(m, i);
|
|
768
|
+
if (chat) hydrated.push(chat);
|
|
769
|
+
});
|
|
770
|
+
this.messages.length = 0;
|
|
771
|
+
this.messages.push(...hydrated);
|
|
772
|
+
this.emitMessages();
|
|
773
|
+
} catch {}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
232
776
|
* Submit a user message. Appends the user bubble immediately, then streams the
|
|
233
777
|
* assistant reply token-by-token, finalizing on `eventual_response`.
|
|
234
778
|
*/
|
|
@@ -330,12 +874,170 @@ var ConversationController = class {
|
|
|
330
874
|
default: break;
|
|
331
875
|
}
|
|
332
876
|
}
|
|
877
|
+
/**
|
|
878
|
+
* Begin the cross-device restore: POST `/internal/identity/request-otp` for
|
|
879
|
+
* `email` over `channel`. The view collects the email via an explicit affordance.
|
|
880
|
+
*/
|
|
881
|
+
async requestIdentityOtp(email, channel = "email") {
|
|
882
|
+
const trimmed = email.trim();
|
|
883
|
+
if (!trimmed) return;
|
|
884
|
+
this.setIdentityRestore({
|
|
885
|
+
phase: "requesting",
|
|
886
|
+
email: trimmed,
|
|
887
|
+
channel
|
|
888
|
+
});
|
|
889
|
+
if (!this.sessionId) try {
|
|
890
|
+
await this.connect();
|
|
891
|
+
} catch {}
|
|
892
|
+
if (!this.sessionId) {
|
|
893
|
+
this.setIdentityRestore({
|
|
894
|
+
phase: "error",
|
|
895
|
+
message: "No active session to verify against."
|
|
896
|
+
});
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
try {
|
|
900
|
+
const json = await this.postInternal("/internal/identity/request-otp", {
|
|
901
|
+
sessionId: this.sessionId,
|
|
902
|
+
email: trimmed,
|
|
903
|
+
channel
|
|
904
|
+
});
|
|
905
|
+
const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
|
|
906
|
+
this.setIdentityRestore({
|
|
907
|
+
phase: "awaiting_code",
|
|
908
|
+
email: trimmed,
|
|
909
|
+
channel,
|
|
910
|
+
maskedDestination: masked
|
|
911
|
+
});
|
|
912
|
+
} catch (err) {
|
|
913
|
+
this.setIdentityRestore({
|
|
914
|
+
phase: "error",
|
|
915
|
+
message: err instanceof Error ? err.message : "Could not send a verification code."
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
/** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
|
|
920
|
+
async verifyIdentityOtp(code) {
|
|
921
|
+
const state = this.identityRestore;
|
|
922
|
+
const trimmed = code.trim();
|
|
923
|
+
if (!trimmed || state.phase !== "awaiting_code") return;
|
|
924
|
+
const { email, channel } = state;
|
|
925
|
+
if (!this.sessionId) {
|
|
926
|
+
this.setIdentityRestore({
|
|
927
|
+
phase: "error",
|
|
928
|
+
message: "No active session to verify against."
|
|
929
|
+
});
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
this.setIdentityRestore({
|
|
933
|
+
phase: "verifying",
|
|
934
|
+
email,
|
|
935
|
+
channel
|
|
936
|
+
});
|
|
937
|
+
try {
|
|
938
|
+
const json = await this.postInternal("/internal/identity/verify-otp", {
|
|
939
|
+
sessionId: this.sessionId,
|
|
940
|
+
email,
|
|
941
|
+
code: trimmed
|
|
942
|
+
});
|
|
943
|
+
if (json.event === "otp_verified") {
|
|
944
|
+
this.store.getState().setVerifiedEmail(email, this.sessionId);
|
|
945
|
+
await this.resolveIdentity(email);
|
|
946
|
+
} else if (json.event === "otp_invalid") {
|
|
947
|
+
const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
|
|
948
|
+
this.setIdentityRestore({
|
|
949
|
+
phase: "awaiting_code",
|
|
950
|
+
email,
|
|
951
|
+
channel,
|
|
952
|
+
error: "That code was incorrect.",
|
|
953
|
+
attemptsRemaining: remaining
|
|
954
|
+
});
|
|
955
|
+
} else this.setIdentityRestore({
|
|
956
|
+
phase: "error",
|
|
957
|
+
message: "Verification failed."
|
|
958
|
+
});
|
|
959
|
+
} catch (err) {
|
|
960
|
+
this.setIdentityRestore({
|
|
961
|
+
phase: "error",
|
|
962
|
+
message: err instanceof Error ? err.message : "Verification failed."
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
/** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
|
|
967
|
+
async resolveIdentity(email) {
|
|
968
|
+
if (!this.sessionId) return;
|
|
969
|
+
this.setIdentityRestore({
|
|
970
|
+
phase: "resolving",
|
|
971
|
+
email
|
|
972
|
+
});
|
|
973
|
+
try {
|
|
974
|
+
const json = await this.postInternal("/internal/identity/resolve", {
|
|
975
|
+
sessionId: this.sessionId,
|
|
976
|
+
email
|
|
977
|
+
});
|
|
978
|
+
if (json.resolved !== true) {
|
|
979
|
+
this.setIdentityRestore({
|
|
980
|
+
phase: "resolved",
|
|
981
|
+
email,
|
|
982
|
+
conversations: []
|
|
983
|
+
});
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const raw = json.conversations;
|
|
987
|
+
const conversations = Array.isArray(raw) ? raw.map((c) => {
|
|
988
|
+
if (!c || typeof c !== "object") return null;
|
|
989
|
+
const o = c;
|
|
990
|
+
const conversationId = typeof o.conversationId === "string" ? o.conversationId : "";
|
|
991
|
+
const sessionId = typeof o.sessionId === "string" ? o.sessionId : "";
|
|
992
|
+
if (!sessionId) return null;
|
|
993
|
+
return {
|
|
994
|
+
conversationId,
|
|
995
|
+
sessionId,
|
|
996
|
+
lastActivityAt: typeof o.lastActivityAt === "string" ? o.lastActivityAt : void 0,
|
|
997
|
+
preview: typeof o.preview === "string" ? o.preview : void 0
|
|
998
|
+
};
|
|
999
|
+
}).filter((c) => c !== null) : [];
|
|
1000
|
+
this.setIdentityRestore({
|
|
1001
|
+
phase: "resolved",
|
|
1002
|
+
email,
|
|
1003
|
+
conversations
|
|
1004
|
+
});
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
this.setIdentityRestore({
|
|
1007
|
+
phase: "error",
|
|
1008
|
+
message: err instanceof Error ? err.message : "Could not load your chats."
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Replay a chosen restorable conversation: point the live session at its
|
|
1014
|
+
* sessionId, hydrate its transcript (get_session + get_messages), and persist
|
|
1015
|
+
* the new pointer so the next `sendMessage` continues it.
|
|
1016
|
+
*/
|
|
1017
|
+
async restoreConversation(sessionId) {
|
|
1018
|
+
if (!this.client) await this.ensureClient();
|
|
1019
|
+
const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
|
|
1020
|
+
if (await this.tryResume(sessionId)) {
|
|
1021
|
+
this.store.getState().setSessionId(sessionId);
|
|
1022
|
+
if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
|
|
1023
|
+
this.setIdentityRestore({ phase: "idle" });
|
|
1024
|
+
this.setStatus("ready");
|
|
1025
|
+
} else this.setIdentityRestore({
|
|
1026
|
+
phase: "error",
|
|
1027
|
+
message: "That conversation is no longer available."
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
/** Dismiss the cross-device restore panel. */
|
|
1031
|
+
cancelIdentityRestore() {
|
|
1032
|
+
this.setIdentityRestore({ phase: "idle" });
|
|
1033
|
+
}
|
|
333
1034
|
/** Tear down the underlying client. */
|
|
334
1035
|
disconnect() {
|
|
335
1036
|
this.client?.disconnect("widget closed");
|
|
336
1037
|
this.client = null;
|
|
337
1038
|
this.sessionId = null;
|
|
338
1039
|
this.activeRequestId = null;
|
|
1040
|
+
this.resumeAttempted = false;
|
|
339
1041
|
this.setInterrupt(null);
|
|
340
1042
|
this.setStatus("closed");
|
|
341
1043
|
}
|
|
@@ -1177,6 +1879,17 @@ function buildStyles(theme, mode = "popover") {
|
|
|
1177
1879
|
}
|
|
1178
1880
|
.pc-submit:hover { transform: translateY(-1px); }
|
|
1179
1881
|
.pc-submit:active { transform: scale(.98); }
|
|
1882
|
+
.pc-consents { display: flex; flex-direction: column; gap: 9px; margin-top: 2px; }
|
|
1883
|
+
.pc-consent { display: flex; align-items: flex-start; gap: 9px; cursor: pointer; }
|
|
1884
|
+
.pc-consent input {
|
|
1885
|
+
margin-top: 2px;
|
|
1886
|
+
width: 16px;
|
|
1887
|
+
height: 16px;
|
|
1888
|
+
flex: 0 0 auto;
|
|
1889
|
+
accent-color: var(--sac-primary);
|
|
1890
|
+
cursor: pointer;
|
|
1891
|
+
}
|
|
1892
|
+
.pc-consent span { font-size: 12px; line-height: 1.4; color: color-mix(in srgb, var(--sac-text) 72%, transparent); }
|
|
1180
1893
|
|
|
1181
1894
|
/* ─────────────────── Starter-prompt chips ─────────────────────────── */
|
|
1182
1895
|
.prompts { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 2px 35px; }
|
|
@@ -1255,6 +1968,61 @@ function buildStyles(theme, mode = "popover") {
|
|
|
1255
1968
|
.int-row .int-btn { flex: 1; }
|
|
1256
1969
|
.int-row .int-input + .int-btn { flex: 0 0 auto; }
|
|
1257
1970
|
.int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
|
|
1971
|
+
.int-card { position: relative; }
|
|
1972
|
+
.int-close {
|
|
1973
|
+
position: absolute;
|
|
1974
|
+
top: 8px;
|
|
1975
|
+
right: 9px;
|
|
1976
|
+
border: none;
|
|
1977
|
+
background: transparent;
|
|
1978
|
+
color: color-mix(in srgb, var(--sac-text) 55%, transparent);
|
|
1979
|
+
font-size: 18px;
|
|
1980
|
+
line-height: 1;
|
|
1981
|
+
cursor: pointer;
|
|
1982
|
+
padding: 2px 4px;
|
|
1983
|
+
border-radius: 6px;
|
|
1984
|
+
transition: color .2s ease, background .2s ease;
|
|
1985
|
+
}
|
|
1986
|
+
.int-close:hover { color: var(--sac-text); background: color-mix(in srgb, var(--sac-text) 8%, transparent); }
|
|
1987
|
+
|
|
1988
|
+
/* ─────────────── Cross-device "Restore my chats" ──────────────────── */
|
|
1989
|
+
.restore-link {
|
|
1990
|
+
border: none;
|
|
1991
|
+
background: none;
|
|
1992
|
+
padding: 0;
|
|
1993
|
+
font: inherit;
|
|
1994
|
+
font-size: 10.5px;
|
|
1995
|
+
letter-spacing: .04em;
|
|
1996
|
+
color: color-mix(in srgb, var(--sac-primary) 80%, var(--sac-text));
|
|
1997
|
+
cursor: pointer;
|
|
1998
|
+
text-decoration: underline;
|
|
1999
|
+
text-underline-offset: 2px;
|
|
2000
|
+
}
|
|
2001
|
+
.restore-link:hover { color: var(--sac-primary); }
|
|
2002
|
+
.restore-list { display: flex; flex-direction: column; gap: 7px; margin-top: 9px; }
|
|
2003
|
+
.restore-item {
|
|
2004
|
+
display: flex;
|
|
2005
|
+
align-items: center;
|
|
2006
|
+
justify-content: space-between;
|
|
2007
|
+
gap: 10px;
|
|
2008
|
+
text-align: left;
|
|
2009
|
+
border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
|
|
2010
|
+
background: var(--sac-bg);
|
|
2011
|
+
color: var(--sac-text);
|
|
2012
|
+
border-radius: 10px;
|
|
2013
|
+
padding: 9px 11px;
|
|
2014
|
+
font-family: inherit;
|
|
2015
|
+
font-size: 12.5px;
|
|
2016
|
+
cursor: pointer;
|
|
2017
|
+
transition: border-color .2s ease, background .2s ease, transform .2s ease;
|
|
2018
|
+
}
|
|
2019
|
+
.restore-item:hover {
|
|
2020
|
+
border-color: color-mix(in srgb, var(--sac-primary) 50%, transparent);
|
|
2021
|
+
background: color-mix(in srgb, var(--sac-primary) 8%, var(--sac-bg));
|
|
2022
|
+
transform: translateY(-1px);
|
|
2023
|
+
}
|
|
2024
|
+
.restore-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2025
|
+
.restore-when { flex: 0 0 auto; font-size: 11px; color: color-mix(in srgb, var(--sac-text) 55%, transparent); }
|
|
1258
2026
|
|
|
1259
2027
|
.hidden { display: none !important; }
|
|
1260
2028
|
|
|
@@ -1315,6 +2083,9 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1315
2083
|
_defineProperty(this, "greeting", "");
|
|
1316
2084
|
_defineProperty(this, "interrupt", null);
|
|
1317
2085
|
_defineProperty(this, "interruptEl", null);
|
|
2086
|
+
_defineProperty(this, "identityRestore", { phase: "idle" });
|
|
2087
|
+
_defineProperty(this, "allowChatRestore", true);
|
|
2088
|
+
_defineProperty(this, "gating", false);
|
|
1318
2089
|
_defineProperty(this, "panelEl", null);
|
|
1319
2090
|
_defineProperty(this, "launcherEl", null);
|
|
1320
2091
|
_defineProperty(this, "messagesEl", null);
|
|
@@ -1361,7 +2132,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1361
2132
|
openChat() {
|
|
1362
2133
|
this.open = true;
|
|
1363
2134
|
this.syncOpenState();
|
|
1364
|
-
this.controller?.connect().catch(() => {});
|
|
2135
|
+
if (!this.gating) this.controller?.connect().catch(() => {});
|
|
1365
2136
|
}
|
|
1366
2137
|
/** Collapse the chat panel back to the launcher. */
|
|
1367
2138
|
closeChat() {
|
|
@@ -1382,6 +2153,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1382
2153
|
userName: this.overrides.userName,
|
|
1383
2154
|
userEmail: this.overrides.userEmail,
|
|
1384
2155
|
userPhone: this.overrides.userPhone,
|
|
2156
|
+
authContext: this.overrides.authContext,
|
|
1385
2157
|
placeholder: this.overrides.placeholder ?? this.getAttribute("placeholder") ?? void 0,
|
|
1386
2158
|
greeting: this.overrides.greeting ?? this.getAttribute("greeting") ?? void 0,
|
|
1387
2159
|
connectionErrorMessage: this.overrides.connectionErrorMessage,
|
|
@@ -1390,6 +2162,9 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1390
2162
|
requireName: this.overrides.requireName,
|
|
1391
2163
|
requireEmail: this.overrides.requireEmail,
|
|
1392
2164
|
requirePhone: this.overrides.requirePhone,
|
|
2165
|
+
collectPhone: this.overrides.collectPhone,
|
|
2166
|
+
collectConsent: this.overrides.collectConsent,
|
|
2167
|
+
allowChatRestore: this.overrides.allowChatRestore,
|
|
1393
2168
|
allowAnonymous: this.overrides.allowAnonymous,
|
|
1394
2169
|
theme
|
|
1395
2170
|
};
|
|
@@ -1401,6 +2176,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1401
2176
|
return;
|
|
1402
2177
|
}
|
|
1403
2178
|
const resolved = resolveConfig(config);
|
|
2179
|
+
this.allowChatRestore = resolved.allowChatRestore;
|
|
1404
2180
|
if (!this.controller) {
|
|
1405
2181
|
this.controller = new ConversationController(config, {
|
|
1406
2182
|
onMessages: (messages) => {
|
|
@@ -1414,9 +2190,14 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1414
2190
|
onInterrupt: (interrupt) => {
|
|
1415
2191
|
this.interrupt = interrupt;
|
|
1416
2192
|
this.renderInterrupt();
|
|
2193
|
+
},
|
|
2194
|
+
onIdentityRestore: (state) => {
|
|
2195
|
+
this.identityRestore = state;
|
|
2196
|
+
this.renderInterrupt();
|
|
1417
2197
|
}
|
|
1418
2198
|
});
|
|
1419
2199
|
if (resolved.startOpen) this.open = true;
|
|
2200
|
+
if (this.controller.hasPersistedSession() || this.controller.hasPersistedIdentity()) this.userInfoSatisfied = true;
|
|
1420
2201
|
}
|
|
1421
2202
|
const fullpage = resolved.mode === "fullpage";
|
|
1422
2203
|
if (fullpage) this.open = true;
|
|
@@ -1441,7 +2222,14 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1441
2222
|
this.examplePrompts = resolved.examplePrompts;
|
|
1442
2223
|
this.greeting = resolved.greeting;
|
|
1443
2224
|
const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
|
|
1444
|
-
|
|
2225
|
+
this.gating = gating;
|
|
2226
|
+
const showPhone = resolved.requirePhone || resolved.collectPhone;
|
|
2227
|
+
const field = (name, type, label, autocomplete, required) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? " required" : ""} /></label>`;
|
|
2228
|
+
const consentBox = (name, label) => `<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
|
|
2229
|
+
const consentHtml = resolved.collectConsent ? `<div class="pc-consents">
|
|
2230
|
+
${consentBox("emailOptIn", "Email me product news and offers.")}
|
|
2231
|
+
${consentBox("smsOptIn", "Text me updates by SMS. Message/data rates may apply.")}
|
|
2232
|
+
</div>` : "";
|
|
1445
2233
|
const prechatHtml = `
|
|
1446
2234
|
<div class="prechat">
|
|
1447
2235
|
<div class="pc-head">
|
|
@@ -1449,12 +2237,14 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1449
2237
|
<div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
|
|
1450
2238
|
</div>
|
|
1451
2239
|
<form class="pc-form" novalidate>
|
|
1452
|
-
${resolved.requireName ? field("name", "text", "Name", "name") : ""}
|
|
1453
|
-
${resolved.requireEmail ? field("email", "email", "Email", "email") : ""}
|
|
1454
|
-
${
|
|
2240
|
+
${resolved.requireName ? field("name", "text", "Name", "name", true) : ""}
|
|
2241
|
+
${resolved.requireEmail ? field("email", "email", "Email", "email", true) : ""}
|
|
2242
|
+
${showPhone ? field("phone", "tel", "Phone", "tel", resolved.requirePhone) : ""}
|
|
2243
|
+
${consentHtml}
|
|
1455
2244
|
<button type="submit" class="pc-submit">Start chat</button>
|
|
1456
2245
|
</form>
|
|
1457
2246
|
</div>`;
|
|
2247
|
+
const restoreLink = this.allowChatRestore ? ` · <button type="button" class="restore-link">Restore my chats</button>` : "";
|
|
1458
2248
|
const chatHtml = `
|
|
1459
2249
|
<div class="messages"></div>
|
|
1460
2250
|
<div class="interrupt hidden"></div>
|
|
@@ -1463,7 +2253,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1463
2253
|
<textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
|
|
1464
2254
|
<button class="send" type="button" aria-label="Send message">${ICON.send}</button>
|
|
1465
2255
|
</div>
|
|
1466
|
-
<div class="footer">powered by <b>smooth‑operator</b
|
|
2256
|
+
<div class="footer">powered by <b>smooth‑operator</b>${restoreLink}</div>
|
|
1467
2257
|
</div>`;
|
|
1468
2258
|
const container = document.createElement("div");
|
|
1469
2259
|
container.innerHTML = `
|
|
@@ -1501,6 +2291,13 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1501
2291
|
ev.preventDefault();
|
|
1502
2292
|
this.handlePrechatSubmit(pcForm);
|
|
1503
2293
|
});
|
|
2294
|
+
container.querySelector(".restore-link")?.addEventListener("click", () => {
|
|
2295
|
+
(async () => {
|
|
2296
|
+
this.identityRestore = { phase: "awaiting_email" };
|
|
2297
|
+
this.renderInterrupt();
|
|
2298
|
+
await this.controller?.connect().catch(() => {});
|
|
2299
|
+
})();
|
|
2300
|
+
});
|
|
1504
2301
|
if (fullpage && !gating) this.controller?.connect().catch(() => {});
|
|
1505
2302
|
this.syncOpenState();
|
|
1506
2303
|
if (!gating) this.renderMessages();
|
|
@@ -1519,6 +2316,11 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1519
2316
|
el.replaceChildren();
|
|
1520
2317
|
const it = this.interrupt;
|
|
1521
2318
|
if (!it) {
|
|
2319
|
+
if (this.identityRestore.phase !== "idle") {
|
|
2320
|
+
el.classList.remove("hidden");
|
|
2321
|
+
el.appendChild(this.buildRestoreCard());
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
1522
2324
|
el.classList.add("hidden");
|
|
1523
2325
|
return;
|
|
1524
2326
|
}
|
|
@@ -1598,15 +2400,186 @@ var SmoothAgentChatElement = class extends HTMLElement {
|
|
|
1598
2400
|
}
|
|
1599
2401
|
el.appendChild(card);
|
|
1600
2402
|
}
|
|
1601
|
-
/**
|
|
2403
|
+
/**
|
|
2404
|
+
* Build the cross-device "Restore my chats" card (ADR-048 §c). Reuses the
|
|
2405
|
+
* same overlay slot + visual language as the OTP interrupt. All server-supplied
|
|
2406
|
+
* strings (masked destination, conversation previews) are set via `textContent`
|
|
2407
|
+
* — never innerHTML — so they can't inject markup; only the static lock icon
|
|
2408
|
+
* uses innerHTML.
|
|
2409
|
+
*/
|
|
2410
|
+
buildRestoreCard() {
|
|
2411
|
+
const state = this.identityRestore;
|
|
2412
|
+
const card = document.createElement("div");
|
|
2413
|
+
card.className = "int-card";
|
|
2414
|
+
const head = document.createElement("div");
|
|
2415
|
+
head.className = "int-head";
|
|
2416
|
+
const ico = document.createElement("span");
|
|
2417
|
+
ico.className = "int-ico";
|
|
2418
|
+
ico.innerHTML = ICON.lock;
|
|
2419
|
+
const title = document.createElement("span");
|
|
2420
|
+
title.className = "int-title";
|
|
2421
|
+
title.textContent = "Restore your chats";
|
|
2422
|
+
head.append(ico, title);
|
|
2423
|
+
card.appendChild(head);
|
|
2424
|
+
const close = document.createElement("button");
|
|
2425
|
+
close.className = "int-close";
|
|
2426
|
+
close.type = "button";
|
|
2427
|
+
close.setAttribute("aria-label", "Cancel");
|
|
2428
|
+
close.textContent = "×";
|
|
2429
|
+
close.addEventListener("click", () => {
|
|
2430
|
+
this.controller?.cancelIdentityRestore();
|
|
2431
|
+
this.identityRestore = { phase: "idle" };
|
|
2432
|
+
this.renderInterrupt();
|
|
2433
|
+
});
|
|
2434
|
+
card.appendChild(close);
|
|
2435
|
+
if (state.phase === "awaiting_email") {
|
|
2436
|
+
const desc = document.createElement("div");
|
|
2437
|
+
desc.className = "int-desc";
|
|
2438
|
+
desc.textContent = "Enter your email and we'll send a code to find your previous chats.";
|
|
2439
|
+
card.appendChild(desc);
|
|
2440
|
+
const row = document.createElement("div");
|
|
2441
|
+
row.className = "int-row";
|
|
2442
|
+
const input = document.createElement("input");
|
|
2443
|
+
input.className = "int-input";
|
|
2444
|
+
input.type = "email";
|
|
2445
|
+
input.autocomplete = "email";
|
|
2446
|
+
input.placeholder = "you@example.com";
|
|
2447
|
+
const go = () => {
|
|
2448
|
+
const email = input.value.trim();
|
|
2449
|
+
if (email) this.controller?.requestIdentityOtp(email, "email");
|
|
2450
|
+
};
|
|
2451
|
+
input.addEventListener("keydown", (ev) => {
|
|
2452
|
+
if (ev.key === "Enter") {
|
|
2453
|
+
ev.preventDefault();
|
|
2454
|
+
go();
|
|
2455
|
+
}
|
|
2456
|
+
});
|
|
2457
|
+
const send = document.createElement("button");
|
|
2458
|
+
send.className = "int-btn primary";
|
|
2459
|
+
send.type = "button";
|
|
2460
|
+
send.textContent = "Send code";
|
|
2461
|
+
send.addEventListener("click", go);
|
|
2462
|
+
row.append(input, send);
|
|
2463
|
+
card.appendChild(row);
|
|
2464
|
+
if (state.error) {
|
|
2465
|
+
const err = document.createElement("div");
|
|
2466
|
+
err.className = "int-error";
|
|
2467
|
+
err.textContent = state.error;
|
|
2468
|
+
card.appendChild(err);
|
|
2469
|
+
}
|
|
2470
|
+
queueMicrotask(() => input.focus());
|
|
2471
|
+
} else if (state.phase === "requesting" || state.phase === "verifying" || state.phase === "resolving") {
|
|
2472
|
+
const msg = document.createElement("div");
|
|
2473
|
+
msg.className = "int-sent";
|
|
2474
|
+
msg.textContent = state.phase === "requesting" ? "Sending a code…" : state.phase === "verifying" ? "Verifying…" : "Finding your chats…";
|
|
2475
|
+
card.appendChild(msg);
|
|
2476
|
+
} else if (state.phase === "awaiting_code") {
|
|
2477
|
+
if (state.maskedDestination) {
|
|
2478
|
+
const sent = document.createElement("div");
|
|
2479
|
+
sent.className = "int-sent";
|
|
2480
|
+
sent.textContent = `Code sent to ${state.maskedDestination}.`;
|
|
2481
|
+
card.appendChild(sent);
|
|
2482
|
+
}
|
|
2483
|
+
const row = document.createElement("div");
|
|
2484
|
+
row.className = "int-row";
|
|
2485
|
+
const input = document.createElement("input");
|
|
2486
|
+
input.className = "int-input";
|
|
2487
|
+
input.type = "text";
|
|
2488
|
+
input.inputMode = "numeric";
|
|
2489
|
+
input.autocomplete = "one-time-code";
|
|
2490
|
+
input.placeholder = "Enter code";
|
|
2491
|
+
const submit = () => {
|
|
2492
|
+
const code = input.value.trim();
|
|
2493
|
+
if (code) this.controller?.verifyIdentityOtp(code);
|
|
2494
|
+
};
|
|
2495
|
+
input.addEventListener("keydown", (ev) => {
|
|
2496
|
+
if (ev.key === "Enter") {
|
|
2497
|
+
ev.preventDefault();
|
|
2498
|
+
submit();
|
|
2499
|
+
}
|
|
2500
|
+
});
|
|
2501
|
+
const verify = document.createElement("button");
|
|
2502
|
+
verify.className = "int-btn primary";
|
|
2503
|
+
verify.type = "button";
|
|
2504
|
+
verify.textContent = "Verify";
|
|
2505
|
+
verify.addEventListener("click", submit);
|
|
2506
|
+
row.append(input, verify);
|
|
2507
|
+
card.appendChild(row);
|
|
2508
|
+
if (state.error) {
|
|
2509
|
+
const err = document.createElement("div");
|
|
2510
|
+
err.className = "int-error";
|
|
2511
|
+
err.textContent = state.attemptsRemaining != null ? `${state.error} (${state.attemptsRemaining} left)` : state.error;
|
|
2512
|
+
card.appendChild(err);
|
|
2513
|
+
}
|
|
2514
|
+
queueMicrotask(() => input.focus());
|
|
2515
|
+
} else if (state.phase === "resolved") if (state.conversations.length === 0) {
|
|
2516
|
+
const none = document.createElement("div");
|
|
2517
|
+
none.className = "int-desc";
|
|
2518
|
+
none.textContent = "No previous chats found for that email.";
|
|
2519
|
+
card.appendChild(none);
|
|
2520
|
+
} else {
|
|
2521
|
+
const pick = document.createElement("div");
|
|
2522
|
+
pick.className = "int-desc";
|
|
2523
|
+
pick.textContent = "Pick a conversation to continue:";
|
|
2524
|
+
card.appendChild(pick);
|
|
2525
|
+
const list = document.createElement("div");
|
|
2526
|
+
list.className = "restore-list";
|
|
2527
|
+
for (const conv of state.conversations) {
|
|
2528
|
+
const btn = document.createElement("button");
|
|
2529
|
+
btn.type = "button";
|
|
2530
|
+
btn.className = "restore-item";
|
|
2531
|
+
const preview = document.createElement("span");
|
|
2532
|
+
preview.className = "restore-preview";
|
|
2533
|
+
preview.textContent = conv.preview || "Conversation";
|
|
2534
|
+
btn.appendChild(preview);
|
|
2535
|
+
if (conv.lastActivityAt) {
|
|
2536
|
+
const when = document.createElement("span");
|
|
2537
|
+
when.className = "restore-when";
|
|
2538
|
+
when.textContent = this.formatWhen(conv.lastActivityAt);
|
|
2539
|
+
btn.appendChild(when);
|
|
2540
|
+
}
|
|
2541
|
+
btn.addEventListener("click", () => {
|
|
2542
|
+
this.controller?.restoreConversation(conv.sessionId);
|
|
2543
|
+
});
|
|
2544
|
+
list.appendChild(btn);
|
|
2545
|
+
}
|
|
2546
|
+
card.appendChild(list);
|
|
2547
|
+
}
|
|
2548
|
+
else if (state.phase === "error") {
|
|
2549
|
+
const err = document.createElement("div");
|
|
2550
|
+
err.className = "int-error";
|
|
2551
|
+
err.textContent = state.message;
|
|
2552
|
+
card.appendChild(err);
|
|
2553
|
+
}
|
|
2554
|
+
return card;
|
|
2555
|
+
}
|
|
2556
|
+
/** Format an ISO timestamp as a short, locale-aware label (best-effort). */
|
|
2557
|
+
formatWhen(iso) {
|
|
2558
|
+
const d = new Date(iso);
|
|
2559
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
2560
|
+
try {
|
|
2561
|
+
return d.toLocaleDateString(void 0, {
|
|
2562
|
+
month: "short",
|
|
2563
|
+
day: "numeric"
|
|
2564
|
+
});
|
|
2565
|
+
} catch {
|
|
2566
|
+
return "";
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
/** Collect identity + consent from the pre-chat form, then drop into the chat view. */
|
|
1602
2570
|
handlePrechatSubmit(form) {
|
|
1603
2571
|
if (!form.reportValidity()) return;
|
|
1604
2572
|
const data = new FormData(form);
|
|
1605
2573
|
const val = (k) => data.get(k)?.trim() || void 0;
|
|
2574
|
+
const checked = (k) => data.get(k) === "on";
|
|
1606
2575
|
this.controller?.setUserInfo({
|
|
1607
2576
|
name: val("name"),
|
|
1608
2577
|
email: val("email"),
|
|
1609
|
-
phone: val("phone")
|
|
2578
|
+
phone: val("phone"),
|
|
2579
|
+
consent: {
|
|
2580
|
+
emailOptIn: checked("emailOptIn"),
|
|
2581
|
+
smsOptIn: checked("smsOptIn")
|
|
2582
|
+
}
|
|
1610
2583
|
});
|
|
1611
2584
|
this.userInfoSatisfied = true;
|
|
1612
2585
|
this.render();
|
|
@@ -2053,6 +3026,6 @@ function initChatWidgetLoader() {
|
|
|
2053
3026
|
else window.addEventListener("load", schedule, { once: true });
|
|
2054
3027
|
}
|
|
2055
3028
|
//#endregion
|
|
2056
|
-
export { ConversationController, ELEMENT_TAG, SmoothAgentChatElement, defineChatWidget, initChatWidgetLoader, mountChatWidget, mountFullPageChat };
|
|
3029
|
+
export { ConversationController, ELEMENT_TAG, PERSIST_VERSION, SmoothAgentChatElement, computeFingerprint, createWidgetStore, defineChatWidget, getOrCreateFingerprint, httpBaseFromWsEndpoint, initChatWidgetLoader, mountChatWidget, mountFullPageChat, storageKey };
|
|
2057
3030
|
|
|
2058
3031
|
//# sourceMappingURL=index.js.map
|