@smooai/chat-widget 0.7.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 +11564 -3
- package/dist/chat-widget.global.js.map +1 -1
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +123 -3
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/element.ts +119 -4
- package/src/styles.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smooai/chat-widget",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Embeddable AI chat as a framework-light web component — the Aurora Glass design, streaming replies, grounded sources, and per-brand theming. Speaks the smooth-operator WebSocket protocol.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "SmooAI",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@smooai/smooth-operator": "^1.8.0",
|
|
62
|
+
"libphonenumber-js": "^1.13.7",
|
|
62
63
|
"zustand": "^5.0.14"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
package/src/element.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
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
20
|
import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt } from './conversation.js';
|
|
@@ -23,6 +24,31 @@ 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
|
/**
|
|
@@ -270,8 +296,10 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
270
296
|
// Phone is collected by default (optional unless requirePhone). Consent
|
|
271
297
|
// checkboxes default to shown, explicit + unchecked (ADR-048 §a/§3).
|
|
272
298
|
const showPhone = resolved.requirePhone || resolved.collectPhone;
|
|
273
|
-
const field = (name: string, type: string, label: string, autocomplete: string, required: boolean) =>
|
|
274
|
-
`<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? ' required' : ''}
|
|
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>`;
|
|
275
303
|
const consentBox = (name: string, label: string) =>
|
|
276
304
|
`<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
|
|
277
305
|
const consentHtml = resolved.collectConsent
|
|
@@ -289,7 +317,7 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
289
317
|
<form class="pc-form" novalidate>
|
|
290
318
|
${resolved.requireName ? field('name', 'text', 'Name', 'name', true) : ''}
|
|
291
319
|
${resolved.requireEmail ? field('email', 'email', 'Email', 'email', true) : ''}
|
|
292
|
-
${showPhone ? field('phone', 'tel', 'Phone', 'tel', resolved.requirePhone) : ''}
|
|
320
|
+
${showPhone ? field('phone', 'tel', 'Phone', 'tel', resolved.requirePhone, true) : ''}
|
|
293
321
|
${consentHtml}
|
|
294
322
|
<button type="submit" class="pc-submit">Start chat</button>
|
|
295
323
|
</form>
|
|
@@ -351,6 +379,12 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
351
379
|
this.handlePrechatSubmit(pcForm as HTMLFormElement);
|
|
352
380
|
});
|
|
353
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
|
+
|
|
354
388
|
// Cross-device "Restore my chats": open the panel + start the email entry.
|
|
355
389
|
// AWAIT connect() before showing the email step so a `sessionId` exists by
|
|
356
390
|
// the time the visitor submits — otherwise request-otp could go out with no
|
|
@@ -651,16 +685,97 @@ export class SmoothAgentChatElement extends HTMLElement {
|
|
|
651
685
|
}
|
|
652
686
|
}
|
|
653
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
|
+
|
|
654
749
|
/** Collect identity + consent from the pre-chat form, then drop into the chat view. */
|
|
655
750
|
private handlePrechatSubmit(form: HTMLFormElement): void {
|
|
656
751
|
if (!form.reportValidity()) return;
|
|
657
752
|
const data = new FormData(form);
|
|
658
753
|
const val = (k: string) => ((data.get(k) as string | null)?.trim() || undefined);
|
|
659
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
|
+
|
|
660
775
|
this.controller?.setUserInfo({
|
|
661
776
|
name: val('name'),
|
|
662
777
|
email: val('email'),
|
|
663
|
-
phone
|
|
778
|
+
phone,
|
|
664
779
|
consent: { emailOptIn: checked('emailOptIn'), smsOptIn: checked('smsOptIn') },
|
|
665
780
|
});
|
|
666
781
|
this.userInfoSatisfied = true;
|
package/src/styles.ts
CHANGED
|
@@ -515,6 +515,24 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
|
|
|
515
515
|
border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
|
|
516
516
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
|
|
517
517
|
}
|
|
518
|
+
/* Inline phone validity — subtle, themed. Empty stays neutral (optional field). */
|
|
519
|
+
.pc-field.valid input {
|
|
520
|
+
border-color: color-mix(in srgb, var(--sac-primary) 55%, #2faa6a 45%);
|
|
521
|
+
}
|
|
522
|
+
.pc-field.invalid input {
|
|
523
|
+
border-color: color-mix(in srgb, #e2566b 62%, var(--sac-border) 38%);
|
|
524
|
+
}
|
|
525
|
+
.pc-field.invalid input:focus {
|
|
526
|
+
box-shadow: 0 0 0 4px color-mix(in srgb, #e2566b 16%, transparent);
|
|
527
|
+
}
|
|
528
|
+
.pc-field .pc-hint {
|
|
529
|
+
min-height: 13px;
|
|
530
|
+
margin-top: 1px;
|
|
531
|
+
font-size: 11.5px;
|
|
532
|
+
font-weight: 500;
|
|
533
|
+
line-height: 1.2;
|
|
534
|
+
color: color-mix(in srgb, #e2566b 78%, var(--sac-text) 22%);
|
|
535
|
+
}
|
|
518
536
|
.pc-submit {
|
|
519
537
|
margin-top: 4px;
|
|
520
538
|
border: none;
|