Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/phone-validation-pre-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@smooai/chat-widget': minor
---

Pre-chat phone field now formats and validates as you type (libphonenumber-js, US default region).

- **As-you-type formatting** via `AsYouType('US')` — appended digits are formatted live (e.g. `(213) 373-4253`). The formatter only rewrites when the caret is at the end and never while deleting, so backspacing the formatting characters works naturally.
- **Inline validity hint** driven by `isValidPhoneNumber(value, 'US')` — a subtle, themed valid/invalid state on the field plus a small hint span. An empty field stays neutral (the field is optional unless `requirePhone`).
- **On submit**: when `requirePhone` is set and the number is invalid, submission is blocked and the hint is shown; when optional, submission proceeds. A valid number is sent as canonical **E.164** (`parsePhoneNumber(value, 'US').number`), falling back to the raw value when it does not parse (the backend re-parses and normalizes/nulls authoritatively — SMOODEV-2153).
- Autofill is preserved: `type="tel"`, `autocomplete="tel"`, and the implicit `<label>` are unchanged, and the value is also reformatted/validated on `change` so a browser-autofilled number gets handled too.

The standalone IIFE bundle now inlines `libphonenumber-js/min` (added to `deps.alwaysBundle`) so a plain `<script>` embed has no undefined-global reference at load.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@smooai/chat-widget",
"version": "0.7.0",
"version": "0.8.0",
"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.",
"license": "MIT",
"author": "SmooAI",
Expand Down Expand Up @@ -71,6 +71,7 @@
},
"dependencies": {
"@smooai/smooth-operator": "^1.8.0",
"libphonenumber-js": "^1.13.7",
"zustand": "^5.0.14"
},
"devDependencies": {
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

134 changes: 134 additions & 0 deletions src/element.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,138 @@ describe('<smooth-agent-chat> render', () => {
expect(overlay?.classList.contains('hidden')).toBe(true);
expect(overlay?.childElementCount).toBe(0);
});

describe('pre-chat phone field (libphonenumber-js, US default)', () => {
// Mount with the phone field present (requireName forces the gate; phone
// shows by default via collectPhone). Returns the input + its field wrapper.
function mountPhone(cfg: Record<string, unknown> = {}): {
el: HTMLElement;
sr: ShadowRoot;
input: HTMLInputElement;
field: HTMLElement;
hint: HTMLElement;
} {
const el = mountCfg({ requireName: true, ...cfg });
const sr = el.shadowRoot!;
const input = sr.querySelector('input[name="phone"]') as HTMLInputElement;
const field = input.closest('.pc-field') as HTMLElement;
const hint = field.querySelector('.pc-hint') as HTMLElement;
return { el, sr, input, field, hint };
}

// jsdom doesn't synthesize InputEvent.inputType; pass it explicitly.
function type(input: HTMLInputElement, value: string, inputType = 'insertText'): void {
input.value = value;
input.setSelectionRange(value.length, value.length);
input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
}

// Stub the controller seam so submit captures setUserInfo without a real
// WebSocket. `disconnect` is needed for the afterEach teardown (the element's
// disconnectedCallback calls it).
function stubController(el: HTMLElement): Array<Record<string, unknown>> {
const captured: Array<Record<string, unknown>> = [];
(el as unknown as { controller: unknown }).controller = {
setUserInfo: (i: Record<string, unknown>) => captured.push(i),
connect: () => Promise.resolve(),
disconnect: () => {},
};
return captured;
}

it('preserves autofill-critical attributes: type=tel, autocomplete=tel, and the implicit <label>', () => {
const { input, field } = mountPhone();
expect(input.type).toBe('tel');
expect(input.getAttribute('autocomplete')).toBe('tel');
// Implicit label association: the input is nested inside its <label>.
expect(field.tagName).toBe('LABEL');
expect(field.querySelector('span')?.textContent).toBe('Phone');
expect(field.contains(input)).toBe(true);
});

it('formats a US number as-you-type when typing at the end', () => {
const { input } = mountPhone();
type(input, '2133734253');
// libphonenumber-js AsYouType('US') → "(213) 373-4253".
expect(input.value).toBe('(213) 373-4253');
});

it('flags a garbage number invalid and a good number valid via the inline hint', () => {
const { input, field, hint } = mountPhone();
type(input, '12'); // too short → invalid
expect(field.classList.contains('invalid')).toBe(true);
expect(field.classList.contains('valid')).toBe(false);
expect(hint.textContent).toBeTruthy();

type(input, '2133734253'); // valid US number
expect(field.classList.contains('valid')).toBe(true);
expect(field.classList.contains('invalid')).toBe(false);
expect(hint.textContent).toBe('');
});

it('empty phone is neutral (no valid/invalid) — the field is optional', () => {
const { input, field } = mountPhone();
type(input, '2133734253');
type(input, '', 'deleteContentBackward');
expect(field.classList.contains('valid')).toBe(false);
expect(field.classList.contains('invalid')).toBe(false);
});

it('does not reformat while the user is deleting characters', () => {
const { input } = mountPhone();
type(input, '2133734253'); // → "(213) 373-4253"
// Simulate a backspace that removed the trailing digit.
type(input, '(213) 373-425', 'deleteContentBackward');
// Value is left as the user typed it — we don't re-add formatting.
expect(input.value).toBe('(213) 373-425');
});

it('reformats + validates a browser-autofilled value on `change`', () => {
const { input, field } = mountPhone();
input.value = '2133734253';
input.dispatchEvent(new Event('change', { bubbles: true }));
expect(input.value).toBe('(213) 373-4253');
expect(field.classList.contains('valid')).toBe(true);
});

it('sends E.164 on submit when the (formatted) number is valid', () => {
const { el, sr, input } = mountPhone();
const captured = stubController(el);
(sr.querySelector('input[name="name"]') as HTMLInputElement).value = 'Ada';
type(input, '(213) 373-4253');
(sr.querySelector('.pc-form') as HTMLFormElement).dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
expect(captured).toHaveLength(1);
expect(captured[0]?.phone).toBe('+12133734253');
});

it('blocks submit on an invalid number when requirePhone is set', () => {
const { el, sr, input, field } = mountPhone({ requirePhone: true });
const captured = stubController(el);
(sr.querySelector('input[name="name"]') as HTMLInputElement).value = 'Ada';
type(input, '12'); // invalid
const form = sr.querySelector('.pc-form') as HTMLFormElement;
// reportValidity is unavailable in jsdom for this path; stub to true so we
// exercise our own phone gate rather than the native required check.
form.reportValidity = () => true;
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
expect(captured).toHaveLength(0);
expect(field.classList.contains('invalid')).toBe(true);
});

it('allows submit with the raw value when phone is optional + unparseable', () => {
const { el, sr, input } = mountPhone();
const captured = stubController(el);
(sr.querySelector('input[name="name"]') as HTMLInputElement).value = 'Ada';
type(input, '12'); // invalid but optional — formatter renders it "1 2"
const form = sr.querySelector('.pc-form') as HTMLFormElement;
form.reportValidity = () => true;
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
// Optional → submit proceeds; the unparseable value is forwarded as-is
// (the field's current, as-you-type-formatted text) so the backend
// normalizes/nulls authoritatively.
expect(captured).toHaveLength(1);
expect(captured[0]?.phone).toBe(input.value);
expect(captured[0]?.phone).not.toMatch(/^\+/); // not E.164 — it didn't parse
});
});
});
123 changes: 119 additions & 4 deletions src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* <smooth-agent-chat endpoint="ws://localhost:8787/ws" agent-id="…"></smooth-agent-chat>
* or programmatically via {@link mountChatWidget}.
*/
import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js/min';
import type { ChatWidgetConfig, ChatWidgetMode, ChatWidgetTheme } from './config.js';
import { needsUserInfo, resolveConfig } from './config.js';
import { type ChatMessage, type Citation, type ConnectionStatus, ConversationController, type IdentityRestore, type Interrupt } from './conversation.js';
Expand All @@ -23,6 +24,31 @@ import { buildStyles } from './styles.js';

export const ELEMENT_TAG = 'smooth-agent-chat';

/**
* Default region for phone parsing/formatting on the pre-chat form. The widget
* is US-first; the backend does the authoritative E.164 normalization (SMOODEV-2153),
* so this only governs the as-you-type display + the inline validity hint and the
* best-effort E.164 we send when the number already parses as valid.
*/
const PHONE_DEFAULT_REGION = 'US' as const;

/**
* Best-effort E.164 for an as-typed phone number. Returns the canonical
* `+1…` form when the value parses to a valid number in {@link PHONE_DEFAULT_REGION},
* otherwise `null` (caller falls back to sending the raw value — the backend
* re-parses and normalizes/nulls authoritatively).
*/
function phoneToE164(value: string): string | null {
const v = value.trim();
if (!v) return null;
try {
if (!isValidPhoneNumber(v, PHONE_DEFAULT_REGION)) return null;
return parsePhoneNumber(v, PHONE_DEFAULT_REGION).number;
} catch {
return null;
}
}

const OBSERVED = ['endpoint', 'agent-id', 'agent-name', 'placeholder', 'greeting', 'start-open', 'mode'] as const;

/**
Expand Down Expand Up @@ -270,8 +296,10 @@ export class SmoothAgentChatElement extends HTMLElement {
// Phone is collected by default (optional unless requirePhone). Consent
// checkboxes default to shown, explicit + unchecked (ADR-048 §a/§3).
const showPhone = resolved.requirePhone || resolved.collectPhone;
const field = (name: string, type: string, label: string, autocomplete: string, required: boolean) =>
`<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? ' required' : ''} /></label>`;
const field = (name: string, type: string, label: string, autocomplete: string, required: boolean, hint = false) =>
`<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}"${required ? ' required' : ''} />${
hint ? '<span class="pc-hint" aria-live="polite"></span>' : ''
}</label>`;
const consentBox = (name: string, label: string) =>
`<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
const consentHtml = resolved.collectConsent
Expand All @@ -289,7 +317,7 @@ export class SmoothAgentChatElement extends HTMLElement {
<form class="pc-form" novalidate>
${resolved.requireName ? field('name', 'text', 'Name', 'name', true) : ''}
${resolved.requireEmail ? field('email', 'email', 'Email', 'email', true) : ''}
${showPhone ? field('phone', 'tel', 'Phone', 'tel', resolved.requirePhone) : ''}
${showPhone ? field('phone', 'tel', 'Phone', 'tel', resolved.requirePhone, true) : ''}
${consentHtml}
<button type="submit" class="pc-submit">Start chat</button>
</form>
Expand Down Expand Up @@ -351,6 +379,12 @@ export class SmoothAgentChatElement extends HTMLElement {
this.handlePrechatSubmit(pcForm as HTMLFormElement);
});

// Live phone formatting + validity hint (libphonenumber-js, US default).
// The implicit <label>, type="tel", and autocomplete="tel" from field()
// are preserved — autofill keeps working — and we also reformat on
// `change` so a browser-autofilled value gets formatted/validated too.
this.wirePhoneField(pcForm as HTMLFormElement | null);

// Cross-device "Restore my chats": open the panel + start the email entry.
// AWAIT connect() before showing the email step so a `sessionId` exists by
// the time the visitor submits — otherwise request-otp could go out with no
Expand Down Expand Up @@ -651,16 +685,97 @@ export class SmoothAgentChatElement extends HTMLElement {
}
}

/**
* Wire as-you-type formatting + an inline validity hint onto the pre-chat
* phone input (libphonenumber-js, US default region). Autofill is preserved:
* the input keeps its `type="tel"` + `autocomplete="tel"` + implicit <label>,
* and we also reformat on `change` so a browser-autofilled value gets
* formatted/validated too.
*
* As-you-type caret note: `AsYouType` reformats the entire string, which
* moves the caret to the end on a mid-string edit. To avoid fighting the
* user, we only rewrite the value when the caret is at the end (the typical
* append-a-digit case) and never on a deletion — so backspacing the
* formatting characters works naturally.
*/
private wirePhoneField(form: HTMLFormElement | null): void {
const input = form?.querySelector('input[name="phone"]') as HTMLInputElement | null;
if (!input) return;
const hint = input.parentElement?.querySelector('.pc-hint') as HTMLElement | null;

const updateHint = () => {
const v = input.value.trim();
const field = input.closest('.pc-field');
if (!v) {
// Empty is neutral — the field is optional unless requirePhone.
field?.classList.remove('valid', 'invalid');
if (hint) hint.textContent = '';
return;
}
const ok = isValidPhoneNumber(v, PHONE_DEFAULT_REGION);
field?.classList.toggle('valid', ok);
field?.classList.toggle('invalid', !ok);
if (hint) hint.textContent = ok ? '' : 'Enter a valid phone number';
};

const reformat = () => {
const atEnd = input.selectionStart === input.value.length && input.selectionEnd === input.value.length;
// Only reformat when appending at the end; never fight a mid-string
// edit or a deletion (see the caret note above).
if (atEnd) {
const formatted = new AsYouType(PHONE_DEFAULT_REGION).input(input.value);
// Avoid clobbering when the user is deleting: only grow/normalize,
// not when the formatter would re-add a character they just removed.
if (formatted.length >= input.value.length) {
input.value = formatted;
}
}
updateHint();
};

input.addEventListener('input', (ev) => {
const ie = ev as InputEvent;
// Don't reformat while deleting — let the user clear characters freely.
if (typeof ie.inputType === 'string' && ie.inputType.startsWith('delete')) {
updateHint();
return;
}
reformat();
});
// Browser autofill / paste-then-blur lands here; format + validate it too.
input.addEventListener('change', reformat);
}

/** Collect identity + consent from the pre-chat form, then drop into the chat view. */
private handlePrechatSubmit(form: HTMLFormElement): void {
if (!form.reportValidity()) return;
const data = new FormData(form);
const val = (k: string) => ((data.get(k) as string | null)?.trim() || undefined);
const checked = (k: string) => data.get(k) === 'on';

// Phone: when required, block on an invalid number and surface the hint.
// When optional, allow submit — the backend normalizes/nulls authoritatively.
const rawPhone = val('phone');
const phoneInput = form.querySelector('input[name="phone"]') as HTMLInputElement | null;
if (rawPhone && phoneInput && !isValidPhoneNumber(rawPhone, PHONE_DEFAULT_REGION)) {
const required = phoneInput.hasAttribute('required');
const field = phoneInput.closest('.pc-field');
field?.classList.add('invalid');
const hint = field?.querySelector('.pc-hint');
if (hint) hint.textContent = 'Enter a valid phone number';
if (required) {
phoneInput.focus();
return;
}
}
// Prefer canonical E.164 when it parses; fall back to the raw value
// otherwise (the backend re-parses + normalizes either way).
const phone = rawPhone ? (phoneToE164(rawPhone) ?? rawPhone) : undefined;

this.controller?.setUserInfo({
name: val('name'),
email: val('email'),
phone: val('phone'),
phone,
consent: { emailOptIn: checked('emailOptIn'), smsOptIn: checked('smsOptIn') },
});
this.userInfoSatisfied = true;
Expand Down
Loading
Loading