@smooai/chat-widget 0.6.0 → 0.9.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 +12721 -31
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +345 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1125 -32
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/src/config.ts +31 -1
- package/src/conversation.ts +531 -18
- package/src/element.ts +362 -10
- package/src/fingerprint.ts +122 -0
- package/src/index.ts +14 -0
- package/src/persistence.ts +271 -0
- package/src/styles.ts +84 -0
package/src/element.ts
CHANGED
|
@@ -14,15 +14,41 @@
|
|
|
14
14
|
* <smooth-agent-chat endpoint="ws://localhost:8787/ws" agent-id="…"></smooth-agent-chat>
|
|
15
15
|
* or programmatically via {@link mountChatWidget}.
|
|
16
16
|
*/
|
|
17
|
+
import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js/min';
|
|
17
18
|
import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
|
|
18
19
|
import { needsUserInfo, resolveConfig } from './config.js';
|
|
19
|
-
import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type Interrupt } from './conversation.js';
|
|
20
|
+
import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt } from './conversation.js';
|
|
20
21
|
import { SMOOTH_LOGO_SVG } from './logo.js';
|
|
21
22
|
import { cleanCitationSnippet, escapeHtml, renderMarkdown, safeHttpUrl } from './markdown.js';
|
|
22
23
|
import { buildStyles } from './styles.js';
|
|
23
24
|
|
|
24
25
|
export const ELEMENT_TAG = 'smooth-agent-chat';
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Default region for phone parsing/formatting on the pre-chat form. The widget
|
|
29
|
+
* is US-first; the backend does the authoritative E.164 normalization (SMOODEV-2153),
|
|
30
|
+
* so this only governs the as-you-type display + the inline validity hint and the
|
|
31
|
+
* best-effort E.164 we send when the number already parses as valid.
|
|
32
|
+
*/
|
|
33
|
+
const PHONE_DEFAULT_REGION = 'US' as const;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Best-effort E.164 for an as-typed phone number. Returns the canonical
|
|
37
|
+
* `+1…` form when the value parses to a valid number in {@link PHONE_DEFAULT_REGION},
|
|
38
|
+
* otherwise `null` (caller falls back to sending the raw value — the backend
|
|
39
|
+
* re-parses and normalizes/nulls authoritatively).
|
|
40
|
+
*/
|
|
41
|
+
function phoneToE164(value: string): string | null {
|
|
42
|
+
const v = value.trim();
|
|
43
|
+
if (!v) return null;
|
|
44
|
+
try {
|
|
45
|
+
if (!isValidPhoneNumber(v, PHONE_DEFAULT_REGION)) return null;
|
|
46
|
+
return parsePhoneNumber(v, PHONE_DEFAULT_REGION).number;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'placeholder', 'greeting', 'start-open', 'mode'] as const;
|
|
27
53
|
|
|
28
54
|
/**
|
|
@@ -73,6 +99,12 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
73
99
|
/** Current mid-turn interrupt (OTP / tool-confirmation), or null. */
|
|
74
100
|
private interrupt: Interrupt | null = null;
|
|
75
101
|
private interruptEl: HTMLElement | null = null;
|
|
102
|
+
/** Cross-device "restore my chats" flow state (ADR-048 §c). */
|
|
103
|
+
private identityRestore: IdentityRestore = { phase: 'idle' };
|
|
104
|
+
/** Whether the cross-device restore affordance is offered (config). */
|
|
105
|
+
private allowChatRestore = true;
|
|
106
|
+
/** True while the pre-chat identity gate is showing (blocks premature connect). */
|
|
107
|
+
private gating = false;
|
|
76
108
|
|
|
77
109
|
// Cached DOM refs (populated in render()).
|
|
78
110
|
private panelEl: HTMLElement | null = null;
|
|
@@ -136,7 +168,10 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
136
168
|
openChat(): void {
|
|
137
169
|
this.open = true;
|
|
138
170
|
this.syncOpenState();
|
|
139
|
-
|
|
171
|
+
// Don't connect while the pre-chat identity gate is unsatisfied — connecting
|
|
172
|
+
// here would create a session BEFORE the visitor submits their name/email/
|
|
173
|
+
// consent, sending an empty identity. The form's submit handler connects.
|
|
174
|
+
if (!this.gating) void this.controller?.connect().catch(() => {});
|
|
140
175
|
}
|
|
141
176
|
|
|
142
177
|
/** Collapse the chat panel back to the launcher. */
|
|
@@ -163,6 +198,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
163
198
|
userName: this.overrides.userName,
|
|
164
199
|
userEmail: this.overrides.userEmail,
|
|
165
200
|
userPhone: this.overrides.userPhone,
|
|
201
|
+
authContext: this.overrides.authContext,
|
|
166
202
|
placeholder: this.overrides.placeholder ?? this.getAttribute('placeholder') ?? undefined,
|
|
167
203
|
greeting: this.overrides.greeting ?? this.getAttribute('greeting') ?? undefined,
|
|
168
204
|
connectionErrorMessage: this.overrides.connectionErrorMessage,
|
|
@@ -171,6 +207,9 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
171
207
|
requireName: this.overrides.requireName,
|
|
172
208
|
requireEmail: this.overrides.requireEmail,
|
|
173
209
|
requirePhone: this.overrides.requirePhone,
|
|
210
|
+
collectPhone: this.overrides.collectPhone,
|
|
211
|
+
collectConsent: this.overrides.collectConsent,
|
|
212
|
+
allowChatRestore: this.overrides.allowChatRestore,
|
|
174
213
|
allowAnonymous: this.overrides.allowAnonymous,
|
|
175
214
|
theme,
|
|
176
215
|
};
|
|
@@ -186,6 +225,8 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
186
225
|
}
|
|
187
226
|
const resolved = resolveConfig(config);
|
|
188
227
|
|
|
228
|
+
this.allowChatRestore = resolved.allowChatRestore;
|
|
229
|
+
|
|
189
230
|
// (Re)create the controller only when there isn't one yet. Attribute churn
|
|
190
231
|
// (e.g. theme tweaks) re-renders the view without dropping the session.
|
|
191
232
|
if (!this.controller) {
|
|
@@ -202,8 +243,17 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
202
243
|
this.interrupt = interrupt;
|
|
203
244
|
this.renderInterrupt();
|
|
204
245
|
},
|
|
246
|
+
onIdentityRestore: (state) => {
|
|
247
|
+
this.identityRestore = state;
|
|
248
|
+
this.renderInterrupt();
|
|
249
|
+
},
|
|
205
250
|
});
|
|
206
251
|
if (resolved.startOpen) this.open = true;
|
|
252
|
+
// Returning visitor: a persisted session or identity lets us skip the
|
|
253
|
+
// pre-chat gate and resume straight into the conversation (ADR-048 §b).
|
|
254
|
+
if (this.controller.hasPersistedSession() || this.controller.hasPersistedIdentity()) {
|
|
255
|
+
this.userInfoSatisfied = true;
|
|
256
|
+
}
|
|
207
257
|
}
|
|
208
258
|
|
|
209
259
|
const fullpage = resolved.mode === 'fullpage';
|
|
@@ -242,8 +292,22 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
242
292
|
|
|
243
293
|
// Gate the conversation behind a pre-chat identity form when required.
|
|
244
294
|
const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
|
|
245
|
-
|
|
246
|
-
|
|
295
|
+
this.gating = gating;
|
|
296
|
+
// Phone is collected by default (optional unless requirePhone). Consent
|
|
297
|
+
// checkboxes default to shown, explicit + unchecked (ADR-048 §a/§3).
|
|
298
|
+
const showPhone = resolved.requirePhone || resolved.collectPhone;
|
|
299
|
+
const field = (name: string, type: string, label: string, autocomplete: string, required: boolean, hint = false) =>
|
|
300
|
+
`<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? ' required' : ''} />${
|
|
301
|
+
hint ? '<span class="pc-hint" aria-live="polite"></span>' : ''
|
|
302
|
+
}</label>`;
|
|
303
|
+
const consentBox = (name: string, label: string) =>
|
|
304
|
+
`<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
|
|
305
|
+
const consentHtml = resolved.collectConsent
|
|
306
|
+
? `<div class="pc-consents">
|
|
307
|
+
${consentBox('emailOptIn', 'Email me product news and offers.')}
|
|
308
|
+
${consentBox('smsOptIn', 'Text me updates by SMS. Message/data rates may apply.')}
|
|
309
|
+
</div>`
|
|
310
|
+
: '';
|
|
247
311
|
const prechatHtml = `
|
|
248
312
|
<div class="prechat">
|
|
249
313
|
<div class="pc-head">
|
|
@@ -251,12 +315,14 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
251
315
|
<div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
|
|
252
316
|
</div>
|
|
253
317
|
<form class="pc-form" novalidate>
|
|
254
|
-
${resolved.requireName ? field('name', 'text', 'Name', 'name') : ''}
|
|
255
|
-
${resolved.requireEmail ? field('email', 'email', 'Email', 'email') : ''}
|
|
256
|
-
${
|
|
318
|
+
${resolved.requireName ? field('name', 'text', 'Name', 'name', true) : ''}
|
|
319
|
+
${resolved.requireEmail ? field('email', 'email', 'Email', 'email', true) : ''}
|
|
320
|
+
${showPhone ? field('phone', 'tel', 'Phone', 'tel', resolved.requirePhone, true) : ''}
|
|
321
|
+
${consentHtml}
|
|
257
322
|
<button type="submit" class="pc-submit">Start chat</button>
|
|
258
323
|
</form>
|
|
259
324
|
</div>`;
|
|
325
|
+
const restoreLink = this.allowChatRestore ? ` · <button type="button" class="restore-link">Restore my chats</button>` : '';
|
|
260
326
|
const chatHtml = `
|
|
261
327
|
<div class="messages"></div>
|
|
262
328
|
<div class="interrupt hidden"></div>
|
|
@@ -265,7 +331,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
265
331
|
<textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
|
|
266
332
|
<button class="send" type="button" aria-label="Send message">${ICON.send}</button>
|
|
267
333
|
</div>
|
|
268
|
-
<div class="footer">powered by <b>smooth‑operator</b
|
|
334
|
+
<div class="footer">powered by <b>smooth‑operator</b>${restoreLink}</div>
|
|
269
335
|
</div>`;
|
|
270
336
|
|
|
271
337
|
const container = document.createElement('div');
|
|
@@ -313,6 +379,27 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
313
379
|
this.handlePrechatSubmit(pcForm as HTMLFormElement);
|
|
314
380
|
});
|
|
315
381
|
|
|
382
|
+
// Live phone formatting + validity hint (libphonenumber-js, US default).
|
|
383
|
+
// The implicit <label>, type="tel", and autocomplete="tel" from field()
|
|
384
|
+
// are preserved — autofill keeps working — and we also reformat on
|
|
385
|
+
// `change` so a browser-autofilled value gets formatted/validated too.
|
|
386
|
+
this.wirePhoneField(pcForm as HTMLFormElement | null);
|
|
387
|
+
|
|
388
|
+
// Cross-device "Restore my chats": open the panel + start the email entry.
|
|
389
|
+
// AWAIT connect() before showing the email step so a `sessionId` exists by
|
|
390
|
+
// the time the visitor submits — otherwise request-otp could go out with no
|
|
391
|
+
// session and verify-otp would then hard-error "No active session." The
|
|
392
|
+
// request-otp/verify-otp paths in the controller also require a session, so
|
|
393
|
+
// gating here keeps the affordance race-free.
|
|
394
|
+
container.querySelector('.restore-link')?.addEventListener('click', () => {
|
|
395
|
+
void (async () => {
|
|
396
|
+
this.identityRestore = { phase: 'awaiting_email' };
|
|
397
|
+
this.renderInterrupt();
|
|
398
|
+
// Establish a live session before the email entry can fire request-otp.
|
|
399
|
+
await this.controller?.connect().catch(() => {});
|
|
400
|
+
})();
|
|
401
|
+
});
|
|
402
|
+
|
|
316
403
|
// Full-page mode connects eagerly (there's no launcher click to trigger it) —
|
|
317
404
|
// but only once any identity gate is cleared.
|
|
318
405
|
if (fullpage && !gating) void this.controller?.connect().catch(() => {});
|
|
@@ -335,6 +422,13 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
335
422
|
el.replaceChildren();
|
|
336
423
|
const it = this.interrupt;
|
|
337
424
|
if (!it) {
|
|
425
|
+
// No mid-turn interrupt — but the cross-device restore flow may be
|
|
426
|
+
// active, which reuses this same overlay slot.
|
|
427
|
+
if (this.identityRestore.phase !== 'idle') {
|
|
428
|
+
el.classList.remove('hidden');
|
|
429
|
+
el.appendChild(this.buildRestoreCard());
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
338
432
|
el.classList.add('hidden');
|
|
339
433
|
return;
|
|
340
434
|
}
|
|
@@ -420,12 +514,270 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
420
514
|
el.appendChild(card);
|
|
421
515
|
}
|
|
422
516
|
|
|
423
|
-
/**
|
|
517
|
+
/**
|
|
518
|
+
* Build the cross-device "Restore my chats" card (ADR-048 §c). Reuses the
|
|
519
|
+
* same overlay slot + visual language as the OTP interrupt. All server-supplied
|
|
520
|
+
* strings (masked destination, conversation previews) are set via `textContent`
|
|
521
|
+
* — never innerHTML — so they can't inject markup; only the static lock icon
|
|
522
|
+
* uses innerHTML.
|
|
523
|
+
*/
|
|
524
|
+
private buildRestoreCard(): HTMLElement {
|
|
525
|
+
const state = this.identityRestore;
|
|
526
|
+
const card = document.createElement('div');
|
|
527
|
+
card.className = 'int-card';
|
|
528
|
+
|
|
529
|
+
const head = document.createElement('div');
|
|
530
|
+
head.className = 'int-head';
|
|
531
|
+
const ico = document.createElement('span');
|
|
532
|
+
ico.className = 'int-ico';
|
|
533
|
+
ico.innerHTML = ICON.lock; // static, trusted
|
|
534
|
+
const title = document.createElement('span');
|
|
535
|
+
title.className = 'int-title';
|
|
536
|
+
title.textContent = 'Restore your chats';
|
|
537
|
+
head.append(ico, title);
|
|
538
|
+
card.appendChild(head);
|
|
539
|
+
|
|
540
|
+
const close = document.createElement('button');
|
|
541
|
+
close.className = 'int-close';
|
|
542
|
+
close.type = 'button';
|
|
543
|
+
close.setAttribute('aria-label', 'Cancel');
|
|
544
|
+
close.textContent = '×';
|
|
545
|
+
close.addEventListener('click', () => {
|
|
546
|
+
this.controller?.cancelIdentityRestore();
|
|
547
|
+
this.identityRestore = { phase: 'idle' };
|
|
548
|
+
this.renderInterrupt();
|
|
549
|
+
});
|
|
550
|
+
card.appendChild(close);
|
|
551
|
+
|
|
552
|
+
if (state.phase === 'awaiting_email') {
|
|
553
|
+
const desc = document.createElement('div');
|
|
554
|
+
desc.className = 'int-desc';
|
|
555
|
+
desc.textContent = "Enter your email and we'll send a code to find your previous chats.";
|
|
556
|
+
card.appendChild(desc);
|
|
557
|
+
|
|
558
|
+
const row = document.createElement('div');
|
|
559
|
+
row.className = 'int-row';
|
|
560
|
+
const input = document.createElement('input');
|
|
561
|
+
input.className = 'int-input';
|
|
562
|
+
input.type = 'email';
|
|
563
|
+
input.autocomplete = 'email';
|
|
564
|
+
input.placeholder = 'you@example.com';
|
|
565
|
+
const go = () => {
|
|
566
|
+
const email = input.value.trim();
|
|
567
|
+
if (email) void this.controller?.requestIdentityOtp(email, 'email');
|
|
568
|
+
};
|
|
569
|
+
input.addEventListener('keydown', (ev) => {
|
|
570
|
+
if (ev.key === 'Enter') {
|
|
571
|
+
ev.preventDefault();
|
|
572
|
+
go();
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
const send = document.createElement('button');
|
|
576
|
+
send.className = 'int-btn primary';
|
|
577
|
+
send.type = 'button';
|
|
578
|
+
send.textContent = 'Send code';
|
|
579
|
+
send.addEventListener('click', go);
|
|
580
|
+
row.append(input, send);
|
|
581
|
+
card.appendChild(row);
|
|
582
|
+
if (state.error) {
|
|
583
|
+
const err = document.createElement('div');
|
|
584
|
+
err.className = 'int-error';
|
|
585
|
+
err.textContent = state.error;
|
|
586
|
+
card.appendChild(err);
|
|
587
|
+
}
|
|
588
|
+
queueMicrotask(() => input.focus());
|
|
589
|
+
} else if (state.phase === 'requesting' || state.phase === 'verifying' || state.phase === 'resolving') {
|
|
590
|
+
const msg = document.createElement('div');
|
|
591
|
+
msg.className = 'int-sent';
|
|
592
|
+
msg.textContent = state.phase === 'requesting' ? 'Sending a code…' : state.phase === 'verifying' ? 'Verifying…' : 'Finding your chats…';
|
|
593
|
+
card.appendChild(msg);
|
|
594
|
+
} else if (state.phase === 'awaiting_code') {
|
|
595
|
+
if (state.maskedDestination) {
|
|
596
|
+
const sent = document.createElement('div');
|
|
597
|
+
sent.className = 'int-sent';
|
|
598
|
+
sent.textContent = `Code sent to ${state.maskedDestination}.`;
|
|
599
|
+
card.appendChild(sent);
|
|
600
|
+
}
|
|
601
|
+
const row = document.createElement('div');
|
|
602
|
+
row.className = 'int-row';
|
|
603
|
+
const input = document.createElement('input');
|
|
604
|
+
input.className = 'int-input';
|
|
605
|
+
input.type = 'text';
|
|
606
|
+
input.inputMode = 'numeric';
|
|
607
|
+
input.autocomplete = 'one-time-code';
|
|
608
|
+
input.placeholder = 'Enter code';
|
|
609
|
+
const submit = () => {
|
|
610
|
+
const code = input.value.trim();
|
|
611
|
+
if (code) void this.controller?.verifyIdentityOtp(code);
|
|
612
|
+
};
|
|
613
|
+
input.addEventListener('keydown', (ev) => {
|
|
614
|
+
if (ev.key === 'Enter') {
|
|
615
|
+
ev.preventDefault();
|
|
616
|
+
submit();
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
const verify = document.createElement('button');
|
|
620
|
+
verify.className = 'int-btn primary';
|
|
621
|
+
verify.type = 'button';
|
|
622
|
+
verify.textContent = 'Verify';
|
|
623
|
+
verify.addEventListener('click', submit);
|
|
624
|
+
row.append(input, verify);
|
|
625
|
+
card.appendChild(row);
|
|
626
|
+
if (state.error) {
|
|
627
|
+
const err = document.createElement('div');
|
|
628
|
+
err.className = 'int-error';
|
|
629
|
+
err.textContent = state.attemptsRemaining != null ? `${state.error} (${state.attemptsRemaining} left)` : state.error;
|
|
630
|
+
card.appendChild(err);
|
|
631
|
+
}
|
|
632
|
+
queueMicrotask(() => input.focus());
|
|
633
|
+
} else if (state.phase === 'resolved') {
|
|
634
|
+
if (state.conversations.length === 0) {
|
|
635
|
+
const none = document.createElement('div');
|
|
636
|
+
none.className = 'int-desc';
|
|
637
|
+
none.textContent = 'No previous chats found for that email.';
|
|
638
|
+
card.appendChild(none);
|
|
639
|
+
} else {
|
|
640
|
+
const pick = document.createElement('div');
|
|
641
|
+
pick.className = 'int-desc';
|
|
642
|
+
pick.textContent = 'Pick a conversation to continue:';
|
|
643
|
+
card.appendChild(pick);
|
|
644
|
+
const list = document.createElement('div');
|
|
645
|
+
list.className = 'restore-list';
|
|
646
|
+
for (const conv of state.conversations) {
|
|
647
|
+
const btn = document.createElement('button');
|
|
648
|
+
btn.type = 'button';
|
|
649
|
+
btn.className = 'restore-item';
|
|
650
|
+
const preview = document.createElement('span');
|
|
651
|
+
preview.className = 'restore-preview';
|
|
652
|
+
preview.textContent = conv.preview || 'Conversation';
|
|
653
|
+
btn.appendChild(preview);
|
|
654
|
+
if (conv.lastActivityAt) {
|
|
655
|
+
const when = document.createElement('span');
|
|
656
|
+
when.className = 'restore-when';
|
|
657
|
+
when.textContent = this.formatWhen(conv.lastActivityAt);
|
|
658
|
+
btn.appendChild(when);
|
|
659
|
+
}
|
|
660
|
+
btn.addEventListener('click', () => {
|
|
661
|
+
void this.controller?.restoreConversation(conv.sessionId);
|
|
662
|
+
});
|
|
663
|
+
list.appendChild(btn);
|
|
664
|
+
}
|
|
665
|
+
card.appendChild(list);
|
|
666
|
+
}
|
|
667
|
+
} else if (state.phase === 'error') {
|
|
668
|
+
const err = document.createElement('div');
|
|
669
|
+
err.className = 'int-error';
|
|
670
|
+
err.textContent = state.message;
|
|
671
|
+
card.appendChild(err);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return card;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Format an ISO timestamp as a short, locale-aware label (best-effort). */
|
|
678
|
+
private formatWhen(iso: string): string {
|
|
679
|
+
const d = new Date(iso);
|
|
680
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
681
|
+
try {
|
|
682
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
683
|
+
} catch {
|
|
684
|
+
return '';
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Wire as-you-type formatting + an inline validity hint onto the pre-chat
|
|
690
|
+
* phone input (libphonenumber-js, US default region). Autofill is preserved:
|
|
691
|
+
* the input keeps its `type="tel"` + `autocomplete="tel"` + implicit <label>,
|
|
692
|
+
* and we also reformat on `change` so a browser-autofilled value gets
|
|
693
|
+
* formatted/validated too.
|
|
694
|
+
*
|
|
695
|
+
* As-you-type caret note: `AsYouType` reformats the entire string, which
|
|
696
|
+
* moves the caret to the end on a mid-string edit. To avoid fighting the
|
|
697
|
+
* user, we only rewrite the value when the caret is at the end (the typical
|
|
698
|
+
* append-a-digit case) and never on a deletion — so backspacing the
|
|
699
|
+
* formatting characters works naturally.
|
|
700
|
+
*/
|
|
701
|
+
private wirePhoneField(form: HTMLFormElement | null): void {
|
|
702
|
+
const input = form?.querySelector('input[name="phone"]') as HTMLInputElement | null;
|
|
703
|
+
if (!input) return;
|
|
704
|
+
const hint = input.parentElement?.querySelector('.pc-hint') as HTMLElement | null;
|
|
705
|
+
|
|
706
|
+
const updateHint = () => {
|
|
707
|
+
const v = input.value.trim();
|
|
708
|
+
const field = input.closest('.pc-field');
|
|
709
|
+
if (!v) {
|
|
710
|
+
// Empty is neutral — the field is optional unless requirePhone.
|
|
711
|
+
field?.classList.remove('valid', 'invalid');
|
|
712
|
+
if (hint) hint.textContent = '';
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const ok = isValidPhoneNumber(v, PHONE_DEFAULT_REGION);
|
|
716
|
+
field?.classList.toggle('valid', ok);
|
|
717
|
+
field?.classList.toggle('invalid', !ok);
|
|
718
|
+
if (hint) hint.textContent = ok ? '' : 'Enter a valid phone number';
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const reformat = () => {
|
|
722
|
+
const atEnd = input.selectionStart === input.value.length && input.selectionEnd === input.value.length;
|
|
723
|
+
// Only reformat when appending at the end; never fight a mid-string
|
|
724
|
+
// edit or a deletion (see the caret note above).
|
|
725
|
+
if (atEnd) {
|
|
726
|
+
const formatted = new AsYouType(PHONE_DEFAULT_REGION).input(input.value);
|
|
727
|
+
// Avoid clobbering when the user is deleting: only grow/normalize,
|
|
728
|
+
// not when the formatter would re-add a character they just removed.
|
|
729
|
+
if (formatted.length >= input.value.length) {
|
|
730
|
+
input.value = formatted;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
updateHint();
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
input.addEventListener('input', (ev) => {
|
|
737
|
+
const ie = ev as InputEvent;
|
|
738
|
+
// Don't reformat while deleting — let the user clear characters freely.
|
|
739
|
+
if (typeof ie.inputType === 'string' && ie.inputType.startsWith('delete')) {
|
|
740
|
+
updateHint();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
reformat();
|
|
744
|
+
});
|
|
745
|
+
// Browser autofill / paste-then-blur lands here; format + validate it too.
|
|
746
|
+
input.addEventListener('change', reformat);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Collect identity + consent from the pre-chat form, then drop into the chat view. */
|
|
424
750
|
private handlePrechatSubmit(form: HTMLFormElement): void {
|
|
425
751
|
if (!form.reportValidity()) return;
|
|
426
752
|
const data = new FormData(form);
|
|
427
753
|
const val = (k: string) => ((data.get(k) as string | null)?.trim() || undefined);
|
|
428
|
-
|
|
754
|
+
const checked = (k: string) => data.get(k) === 'on';
|
|
755
|
+
|
|
756
|
+
// Phone: when required, block on an invalid number and surface the hint.
|
|
757
|
+
// When optional, allow submit — the backend normalizes/nulls authoritatively.
|
|
758
|
+
const rawPhone = val('phone');
|
|
759
|
+
const phoneInput = form.querySelector('input[name="phone"]') as HTMLInputElement | null;
|
|
760
|
+
if (rawPhone && phoneInput && !isValidPhoneNumber(rawPhone, PHONE_DEFAULT_REGION)) {
|
|
761
|
+
const required = phoneInput.hasAttribute('required');
|
|
762
|
+
const field = phoneInput.closest('.pc-field');
|
|
763
|
+
field?.classList.add('invalid');
|
|
764
|
+
const hint = field?.querySelector('.pc-hint');
|
|
765
|
+
if (hint) hint.textContent = 'Enter a valid phone number';
|
|
766
|
+
if (required) {
|
|
767
|
+
phoneInput.focus();
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
// Prefer canonical E.164 when it parses; fall back to the raw value
|
|
772
|
+
// otherwise (the backend re-parses + normalizes either way).
|
|
773
|
+
const phone = rawPhone ? (phoneToE164(rawPhone) ?? rawPhone) : undefined;
|
|
774
|
+
|
|
775
|
+
this.controller?.setUserInfo({
|
|
776
|
+
name: val('name'),
|
|
777
|
+
email: val('email'),
|
|
778
|
+
phone,
|
|
779
|
+
consent: { emailOptIn: checked('emailOptIn'), smsOptIn: checked('smsOptIn') },
|
|
780
|
+
});
|
|
429
781
|
this.userInfoSatisfied = true;
|
|
430
782
|
this.render();
|
|
431
783
|
void this.controller?.connect().catch(() => {});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser fingerprint — a stable, privacy-light identifier used by the
|
|
3
|
+
* smooth-operator server to correlate an anonymous visitor's sessions across
|
|
4
|
+
* page loads (and, server-side, to match an anonymous fingerprint to a known
|
|
5
|
+
* CRM contact). It rides every `create_conversation_session` as
|
|
6
|
+
* `browserFingerprint` (see ADR-048).
|
|
7
|
+
*
|
|
8
|
+
* ## Why not ThumbmarkJS
|
|
9
|
+
*
|
|
10
|
+
* The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
|
|
11
|
+
* that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
|
|
12
|
+
* full build pulls in extensive device-detection tables and async
|
|
13
|
+
* component collection, adding tens of kilobytes (and async surface) to a
|
|
14
|
+
* bundle whose whole selling point is staying out of the host page's
|
|
15
|
+
* LCP/TBT budget. The fingerprint here is a few hundred bytes.
|
|
16
|
+
*
|
|
17
|
+
* ## What this is (and the tradeoff)
|
|
18
|
+
*
|
|
19
|
+
* The correlation that actually matters — "is this the same browser as last
|
|
20
|
+
* time?" — is carried by a **persisted random UUID** (the `browserFingerprint`
|
|
21
|
+
* field in the persisted store). That UUID is generated once and reused for
|
|
22
|
+
* the life of the localStorage entry, so same-browser resume is exact and
|
|
23
|
+
* deterministic. The signal hash below is a best-effort entropy *supplement*
|
|
24
|
+
* mixed into that UUID's derivation seed on first generation — it gives the
|
|
25
|
+
* server-side resolver a soft signal for the (rare) cross-storage case where
|
|
26
|
+
* the UUID was cleared but the device is unchanged. It is intentionally NOT a
|
|
27
|
+
* high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
|
|
28
|
+
* enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
|
|
29
|
+
* cross-storage matching in exchange for a tiny, transparent, no-network,
|
|
30
|
+
* XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
|
|
31
|
+
* of truth for any fuzzy matching; the client just supplies a stable token.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Collect a small set of stable, non-invasive browser signals. */
|
|
35
|
+
function collectSignals(): string {
|
|
36
|
+
const parts: string[] = [];
|
|
37
|
+
try {
|
|
38
|
+
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
|
|
39
|
+
if (nav) {
|
|
40
|
+
parts.push(`ua:${nav.userAgent ?? ''}`);
|
|
41
|
+
parts.push(`lang:${nav.language ?? ''}`);
|
|
42
|
+
parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(',') : ''}`);
|
|
43
|
+
// `platform` is deprecated but still widely present and stable.
|
|
44
|
+
parts.push(`plat:${(nav as Navigator & { platform?: string }).platform ?? ''}`);
|
|
45
|
+
parts.push(`hc:${(nav as Navigator & { hardwareConcurrency?: number }).hardwareConcurrency ?? ''}`);
|
|
46
|
+
parts.push(`dm:${(nav as Navigator & { deviceMemory?: number }).deviceMemory ?? ''}`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof screen !== 'undefined') {
|
|
49
|
+
parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof Intl !== 'undefined') {
|
|
52
|
+
try {
|
|
53
|
+
parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''}`);
|
|
54
|
+
} catch {
|
|
55
|
+
/* resolvedOptions can throw in locked-down environments */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
parts.push(`tzo:${new Date().getTimezoneOffset()}`);
|
|
59
|
+
} catch {
|
|
60
|
+
/* a hostile/locked-down navigator must never break widget boot */
|
|
61
|
+
}
|
|
62
|
+
return parts.join('|');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
|
|
67
|
+
* an unsigned hex string. Stable across runs for the same input. Not used for
|
|
68
|
+
* any security decision — only to derive a deterministic seed from the signal
|
|
69
|
+
* string above.
|
|
70
|
+
*/
|
|
71
|
+
function fnv1a(input: string): string {
|
|
72
|
+
let h = 0x811c9dc5;
|
|
73
|
+
for (let i = 0; i < input.length; i++) {
|
|
74
|
+
h ^= input.charCodeAt(i);
|
|
75
|
+
// 32-bit FNV prime multiply via shifts to stay in int range.
|
|
76
|
+
h = Math.imul(h, 0x01000193);
|
|
77
|
+
}
|
|
78
|
+
// >>> 0 → unsigned; pad to 8 hex chars.
|
|
79
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
|
|
83
|
+
function generateUuid(): string {
|
|
84
|
+
try {
|
|
85
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
86
|
+
return crypto.randomUUID();
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
/* fall through to the manual generator */
|
|
90
|
+
}
|
|
91
|
+
// RFC 4122 v4 shape from Math.random — only reached when crypto.randomUUID
|
|
92
|
+
// is unavailable (very old engines). Sufficient for a correlation token.
|
|
93
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
94
|
+
const r = (Math.random() * 16) | 0;
|
|
95
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
96
|
+
return v.toString(16);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Compute a fresh fingerprint token: a stable random UUID, suffixed with the
|
|
102
|
+
* FNV hash of the device signals so the server can recover a soft device
|
|
103
|
+
* signal even if it only has the token. Called ONCE per browser (the result is
|
|
104
|
+
* persisted and reused) — see {@link getOrCreateFingerprint}.
|
|
105
|
+
*/
|
|
106
|
+
export function computeFingerprint(): string {
|
|
107
|
+
const signalHash = fnv1a(collectSignals());
|
|
108
|
+
return `${generateUuid()}.${signalHash}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Return the cached fingerprint if one was already computed for this browser,
|
|
113
|
+
* otherwise compute + persist a new one via the provided accessors. The
|
|
114
|
+
* persistence layer owns storage; this function owns the "compute once" policy.
|
|
115
|
+
*/
|
|
116
|
+
export function getOrCreateFingerprint(get: () => string | null, set: (fp: string) => void): string {
|
|
117
|
+
const existing = get();
|
|
118
|
+
if (existing) return existing;
|
|
119
|
+
const fp = computeFingerprint();
|
|
120
|
+
set(fp);
|
|
121
|
+
return fp;
|
|
122
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -33,9 +33,23 @@ export type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config
|
|
|
33
33
|
export { initChatWidgetLoader } from './loader-core.js';
|
|
34
34
|
export {
|
|
35
35
|
ConversationController,
|
|
36
|
+
httpBaseFromWsEndpoint,
|
|
36
37
|
type ChatMessage,
|
|
37
38
|
type Citation,
|
|
38
39
|
type ConnectionStatus,
|
|
39
40
|
type ConversationEvents,
|
|
41
|
+
type IdentityRestore,
|
|
42
|
+
type RestorableConversation,
|
|
40
43
|
type Role,
|
|
44
|
+
type UserInfo,
|
|
41
45
|
} from './conversation.js';
|
|
46
|
+
export {
|
|
47
|
+
createWidgetStore,
|
|
48
|
+
PERSIST_VERSION,
|
|
49
|
+
storageKey,
|
|
50
|
+
type ConsentState,
|
|
51
|
+
type IdentityState,
|
|
52
|
+
type PersistedWidgetState,
|
|
53
|
+
type WidgetStore,
|
|
54
|
+
} from './persistence.js';
|
|
55
|
+
export { computeFingerprint, getOrCreateFingerprint } from './fingerprint.js';
|