@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/index.js CHANGED
@@ -1,4 +1,7 @@
1
+ import { AsYouType, isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js/min";
1
2
  import { ProtocolError, SmoothAgentClient } from "@smooai/smooth-operator";
3
+ import { createStore } from "zustand/vanilla";
4
+ import { persist } from "zustand/middleware";
2
5
  //#region src/config.ts
3
6
  /** Resolve a partial config against the built-in defaults. */
4
7
  function resolveConfig(config) {
@@ -17,6 +20,7 @@ function resolveConfig(config) {
17
20
  userName: config.userName,
18
21
  userEmail: config.userEmail,
19
22
  userPhone: config.userPhone,
23
+ authContext: config.authContext,
20
24
  placeholder: config.placeholder ?? "Type a message…",
21
25
  greeting: config.greeting ?? "Hi! How can I help you today?",
22
26
  connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.",
@@ -25,6 +29,9 @@ function resolveConfig(config) {
25
29
  requireName: config.requireName ?? false,
26
30
  requireEmail: config.requireEmail ?? false,
27
31
  requirePhone: config.requirePhone ?? false,
32
+ collectPhone: config.collectPhone ?? true,
33
+ collectConsent: config.collectConsent ?? true,
34
+ allowChatRestore: config.allowChatRestore ?? true,
28
35
  allowAnonymous: config.allowAnonymous ?? false,
29
36
  theme: {
30
37
  text: theme.text ?? "#f8fafc",
@@ -48,6 +55,293 @@ function needsUserInfo(resolved) {
48
55
  return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone);
49
56
  }
50
57
  //#endregion
58
+ //#region src/fingerprint.ts
59
+ /**
60
+ * Browser fingerprint — a stable, privacy-light identifier used by the
61
+ * smooth-operator server to correlate an anonymous visitor's sessions across
62
+ * page loads (and, server-side, to match an anonymous fingerprint to a known
63
+ * CRM contact). It rides every `create_conversation_session` as
64
+ * `browserFingerprint` (see ADR-048).
65
+ *
66
+ * ## Why not ThumbmarkJS
67
+ *
68
+ * The ADR floats ThumbmarkJS as the reference implementation. For an *embed*
69
+ * that is injected onto arbitrary host pages, ThumbmarkJS is too heavy: its
70
+ * full build pulls in extensive device-detection tables and async
71
+ * component collection, adding tens of kilobytes (and async surface) to a
72
+ * bundle whose whole selling point is staying out of the host page's
73
+ * LCP/TBT budget. The fingerprint here is a few hundred bytes.
74
+ *
75
+ * ## What this is (and the tradeoff)
76
+ *
77
+ * The correlation that actually matters — "is this the same browser as last
78
+ * time?" — is carried by a **persisted random UUID** (the `browserFingerprint`
79
+ * field in the persisted store). That UUID is generated once and reused for
80
+ * the life of the localStorage entry, so same-browser resume is exact and
81
+ * deterministic. The signal hash below is a best-effort entropy *supplement*
82
+ * mixed into that UUID's derivation seed on first generation — it gives the
83
+ * server-side resolver a soft signal for the (rare) cross-storage case where
84
+ * the UUID was cleared but the device is unchanged. It is intentionally NOT a
85
+ * high-entropy device fingerprint: no canvas/WebGL/audio probes, no font
86
+ * enumeration, nothing that reads as invasive tracking. The tradeoff is weaker
87
+ * cross-storage matching in exchange for a tiny, transparent, no-network,
88
+ * XSS-safe implementation. The server's resolver (SMOODEV-2129d) is the source
89
+ * of truth for any fuzzy matching; the client just supplies a stable token.
90
+ */
91
+ /** Collect a small set of stable, non-invasive browser signals. */
92
+ function collectSignals() {
93
+ const parts = [];
94
+ try {
95
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
96
+ if (nav) {
97
+ parts.push(`ua:${nav.userAgent ?? ""}`);
98
+ parts.push(`lang:${nav.language ?? ""}`);
99
+ parts.push(`langs:${Array.isArray(nav.languages) ? nav.languages.join(",") : ""}`);
100
+ parts.push(`plat:${nav.platform ?? ""}`);
101
+ parts.push(`hc:${nav.hardwareConcurrency ?? ""}`);
102
+ parts.push(`dm:${nav.deviceMemory ?? ""}`);
103
+ }
104
+ if (typeof screen !== "undefined") parts.push(`scr:${screen.width}x${screen.height}x${screen.colorDepth}`);
105
+ if (typeof Intl !== "undefined") try {
106
+ parts.push(`tz:${Intl.DateTimeFormat().resolvedOptions().timeZone ?? ""}`);
107
+ } catch {}
108
+ parts.push(`tzo:${(/* @__PURE__ */ new Date()).getTimezoneOffset()}`);
109
+ } catch {}
110
+ return parts.join("|");
111
+ }
112
+ /**
113
+ * A small, fast, non-cryptographic string hash (FNV-1a, 32-bit) rendered as
114
+ * an unsigned hex string. Stable across runs for the same input. Not used for
115
+ * any security decision — only to derive a deterministic seed from the signal
116
+ * string above.
117
+ */
118
+ function fnv1a(input) {
119
+ let h = 2166136261;
120
+ for (let i = 0; i < input.length; i++) {
121
+ h ^= input.charCodeAt(i);
122
+ h = Math.imul(h, 16777619);
123
+ }
124
+ return (h >>> 0).toString(16).padStart(8, "0");
125
+ }
126
+ /** Generate a UUID, preferring `crypto.randomUUID`, falling back to a v4-shaped string. */
127
+ function generateUuid() {
128
+ try {
129
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
130
+ } catch {}
131
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
132
+ const r = Math.random() * 16 | 0;
133
+ return (c === "x" ? r : r & 3 | 8).toString(16);
134
+ });
135
+ }
136
+ /**
137
+ * Compute a fresh fingerprint token: a stable random UUID, suffixed with the
138
+ * FNV hash of the device signals so the server can recover a soft device
139
+ * signal even if it only has the token. Called ONCE per browser (the result is
140
+ * persisted and reused) — see {@link getOrCreateFingerprint}.
141
+ */
142
+ function computeFingerprint() {
143
+ const signalHash = fnv1a(collectSignals());
144
+ return `${generateUuid()}.${signalHash}`;
145
+ }
146
+ /**
147
+ * Return the cached fingerprint if one was already computed for this browser,
148
+ * otherwise compute + persist a new one via the provided accessors. The
149
+ * persistence layer owns storage; this function owns the "compute once" policy.
150
+ */
151
+ function getOrCreateFingerprint(get, set) {
152
+ const existing = get();
153
+ if (existing) return existing;
154
+ const fp = computeFingerprint();
155
+ set(fp);
156
+ return fp;
157
+ }
158
+ //#endregion
159
+ //#region src/persistence.ts
160
+ /**
161
+ * Persisted widget state — the identity / consent / session-pointer client layer
162
+ * (ADR-048, SMOODEV-2129e).
163
+ *
164
+ * Built on Zustand's framework-agnostic `vanilla` store + the `persist`
165
+ * middleware (the user explicitly chose Zustand; it's ~1KB and has no React
166
+ * dependency, so it works inside the web component). The store is keyed per
167
+ * agent — localStorage key `smoo-chat-widget:<agentId>` — so two agents embedded
168
+ * on the same origin don't clobber each other.
169
+ *
170
+ * ## What persists (and, deliberately, what does NOT)
171
+ *
172
+ * Only a **pointer** to the conversation plus the visitor's identity + marketing
173
+ * consent + verified email + browser fingerprint. The transcript is **never**
174
+ * persisted: the smooth-operator server is the source of truth and the widget
175
+ * re-hydrates history via `getMessages` on resume. This keeps localStorage small
176
+ * and avoids stale/divergent transcripts.
177
+ *
178
+ * `version` drives `persist.migrate` so future shape changes can upgrade old
179
+ * blobs in place instead of silently dropping them.
180
+ */
181
+ /** Current persisted-shape version. Bump when the shape below changes incompatibly. */
182
+ const PERSIST_VERSION = 1;
183
+ const EMPTY_CONSENT = {
184
+ emailOptIn: false,
185
+ smsOptIn: false
186
+ };
187
+ function initialPersisted() {
188
+ return {
189
+ version: 1,
190
+ sessionId: null,
191
+ identity: {},
192
+ consent: { ...EMPTY_CONSENT },
193
+ verifiedEmail: null,
194
+ verifiedEmailSessionId: null,
195
+ browserFingerprint: null
196
+ };
197
+ }
198
+ /** localStorage key for an agent's persisted widget state. */
199
+ function storageKey(agentId) {
200
+ return `smoo-chat-widget:${agentId}`;
201
+ }
202
+ /**
203
+ * An explicit in-memory (Map-backed) `PersistStorage`. Used as the fallback when
204
+ * real localStorage is unavailable.
205
+ *
206
+ * IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
207
+ * zustand v5's `persist` treats a missing `storage` option by falling back to its
208
+ * OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
209
+ * localStorage the guard was trying to avoid (throwing again in privacy mode).
210
+ * Handing it this no-op storage keeps the store working purely in memory and
211
+ * guarantees the fallback can't touch real localStorage.
212
+ */
213
+ function memoryStorage() {
214
+ const mem = /* @__PURE__ */ new Map();
215
+ return {
216
+ getItem: (name) => {
217
+ const raw = mem.get(name);
218
+ if (!raw) return null;
219
+ try {
220
+ return JSON.parse(raw);
221
+ } catch {
222
+ return null;
223
+ }
224
+ },
225
+ setItem: (name, value) => {
226
+ mem.set(name, JSON.stringify(value));
227
+ },
228
+ removeItem: (name) => {
229
+ mem.delete(name);
230
+ }
231
+ };
232
+ }
233
+ /**
234
+ * A `persist` storage adapter that tolerates the *absence* of localStorage
235
+ * (SSR, privacy-mode throwing on access, sandboxed iframes). When storage is
236
+ * unavailable the store still works in-memory; nothing is persisted, but the
237
+ * widget never throws on boot.
238
+ */
239
+ function safeStorage() {
240
+ let ls = null;
241
+ try {
242
+ ls = typeof localStorage !== "undefined" ? localStorage : null;
243
+ if (ls) {
244
+ const probe = "__smoo_probe__";
245
+ ls.setItem(probe, "1");
246
+ ls.removeItem(probe);
247
+ }
248
+ } catch {
249
+ ls = null;
250
+ }
251
+ if (!ls) return memoryStorage();
252
+ const storage = ls;
253
+ return {
254
+ getItem: (name) => {
255
+ try {
256
+ const raw = storage.getItem(name);
257
+ return raw ? JSON.parse(raw) : null;
258
+ } catch {
259
+ return null;
260
+ }
261
+ },
262
+ setItem: (name, value) => {
263
+ try {
264
+ storage.setItem(name, JSON.stringify(value));
265
+ } catch {}
266
+ },
267
+ removeItem: (name) => {
268
+ try {
269
+ storage.removeItem(name);
270
+ } catch {}
271
+ }
272
+ };
273
+ }
274
+ /**
275
+ * Migrate a persisted blob from an older `version` to the current shape. Today
276
+ * v1 is the only version, so this just backfills any missing fields onto an
277
+ * unknown old blob; future versions add `case` branches here.
278
+ */
279
+ function migrate(persisted) {
280
+ const base = initialPersisted();
281
+ if (!persisted || typeof persisted !== "object") return base;
282
+ const p = persisted;
283
+ return {
284
+ version: 1,
285
+ sessionId: typeof p.sessionId === "string" ? p.sessionId : null,
286
+ identity: {
287
+ name: typeof p.identity?.name === "string" ? p.identity.name : void 0,
288
+ email: typeof p.identity?.email === "string" ? p.identity.email : void 0,
289
+ phone: typeof p.identity?.phone === "string" ? p.identity.phone : void 0
290
+ },
291
+ consent: {
292
+ emailOptIn: p.consent?.emailOptIn === true,
293
+ smsOptIn: p.consent?.smsOptIn === true,
294
+ consentSource: typeof p.consent?.consentSource === "string" ? p.consent.consentSource : void 0,
295
+ consentAt: typeof p.consent?.consentAt === "string" ? p.consent.consentAt : void 0
296
+ },
297
+ verifiedEmail: typeof p.verifiedEmail === "string" ? p.verifiedEmail : null,
298
+ verifiedEmailSessionId: typeof p.verifiedEmailSessionId === "string" ? p.verifiedEmailSessionId : null,
299
+ browserFingerprint: typeof p.browserFingerprint === "string" ? p.browserFingerprint : null
300
+ };
301
+ }
302
+ /**
303
+ * Create the per-agent persisted Zustand store. The `partialize` step ensures
304
+ * only the persisted shape (never any future transient action/UI state) is
305
+ * written to localStorage.
306
+ */
307
+ function createWidgetStore(agentId) {
308
+ return createStore()(persist((set) => ({
309
+ ...initialPersisted(),
310
+ setSessionId: (sessionId) => set({ sessionId }),
311
+ mergeIdentity: (identity) => set((state) => ({ identity: {
312
+ ...state.identity,
313
+ ...identity.name !== void 0 ? { name: identity.name } : {},
314
+ ...identity.email !== void 0 ? { email: identity.email } : {},
315
+ ...identity.phone !== void 0 ? { phone: identity.phone } : {}
316
+ } })),
317
+ setConsent: (consent) => set({ consent: { ...consent } }),
318
+ setVerifiedEmail: (verifiedEmail, sessionId) => set((state) => ({
319
+ verifiedEmail,
320
+ verifiedEmailSessionId: verifiedEmail === null ? null : sessionId ?? state.sessionId
321
+ })),
322
+ setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
323
+ clearSession: () => set({
324
+ sessionId: null,
325
+ verifiedEmail: null,
326
+ verifiedEmailSessionId: null
327
+ })
328
+ }), {
329
+ name: storageKey(agentId),
330
+ version: 1,
331
+ storage: safeStorage(),
332
+ migrate,
333
+ partialize: (state) => ({
334
+ version: 1,
335
+ sessionId: state.sessionId,
336
+ identity: state.identity,
337
+ consent: state.consent,
338
+ verifiedEmail: state.verifiedEmail,
339
+ verifiedEmailSessionId: state.verifiedEmailSessionId,
340
+ browserFingerprint: state.browserFingerprint
341
+ })
342
+ }));
343
+ }
344
+ //#endregion
51
345
  //#region \0@oxc-project+runtime@0.134.0/helpers/esm/typeof.js
52
346
  function _typeof(o) {
53
347
  "@babel/helpers - typeof";
@@ -97,13 +391,41 @@ function _defineProperty(e, r, t) {
97
391
  * so the swap is purely at the client-library boundary.
98
392
  *
99
393
  * Flow:
100
- * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`.
394
+ * 1. `connect()` → opens the WebSocket transport and `create_conversation_session`
395
+ * (or RESUMES a persisted session via `get_session`/`get_messages`).
101
396
  * 2. `send(text)` → `send_message`, streaming `stream_token` deltas into the
102
397
  * in-progress assistant message, then the terminal
103
398
  * `eventual_response`.
104
399
  *
400
+ * 0.7.0 (SMOODEV-2129e) adds the identity / persistence / consent client layer:
401
+ * - `browserFingerprint` computed once + sent on every `create_conversation_session`.
402
+ * - identity + marketing consent threaded into the session `metadata`.
403
+ * - same-session RESUME on load (no engine change — `get_session` + `get_messages`).
404
+ * - returning-visitor RESUME via `POST /internal/resume-by-fingerprint`, and
405
+ * cross-device "restore my chats" via the `POST /internal/identity/{request-otp,
406
+ * verify-otp,resolve}` routes on the chat-ws wrapper. The engine (smooth-operator
407
+ * 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so these are HTTP
408
+ * `fetch()` calls (origin-allowlisted + optional authContext, per ADR-046/048) —
409
+ * NOT WS frames.
410
+ *
105
411
  * The controller is UI-agnostic: it emits typed events and the view renders them.
106
412
  */
413
+ /**
414
+ * Derive the HTTP base for the chat-ws wrapper's `/internal/*` REST routes from
415
+ * the WS endpoint: `wss://ai.smoo.ai/ws` → `https://ai.smoo.ai`. The engine
416
+ * (smooth-operator 1.8.0) owns the `/ws` dispatch and REJECTS unknown verbs, so
417
+ * the cross-device identity flow + fingerprint resume are HTTP POST routes on the
418
+ * wrapper (origin-allowlisted + authContext, per ADR-046/ADR-048) — NOT WS frames.
419
+ */
420
+ function httpBaseFromWsEndpoint(endpoint) {
421
+ try {
422
+ const u = new URL(endpoint);
423
+ u.protocol = u.protocol === "ws:" ? "http:" : u.protocol === "wss:" ? "https:" : u.protocol;
424
+ return `${u.protocol}//${u.host}`;
425
+ } catch {
426
+ return null;
427
+ }
428
+ }
107
429
  /** Pull the final assistant text out of an `eventual_response` data payload. */
108
430
  function extractFinalText(response) {
109
431
  if (!response || typeof response !== "object") return null;
@@ -144,40 +466,89 @@ function extractCitations(inner) {
144
466
  }
145
467
  return out;
146
468
  }
469
+ /** Convert a server message row into a finalized {@link ChatMessage}. */
470
+ function wireMessageToChat(m, idx) {
471
+ const text = typeof m.content?.text === "string" ? m.content.text : "";
472
+ if (!text) return null;
473
+ const role = m.direction === "outbound" ? "assistant" : "user";
474
+ return {
475
+ id: typeof m.id === "string" ? m.id : `hist-${idx}`,
476
+ role,
477
+ text,
478
+ streaming: false
479
+ };
480
+ }
147
481
  var ConversationController = class {
148
- constructor(config, events) {
482
+ constructor(config, events, store) {
149
483
  _defineProperty(this, "config", void 0);
150
484
  _defineProperty(this, "events", void 0);
485
+ _defineProperty(this, "store", void 0);
151
486
  _defineProperty(this, "client", null);
152
487
  _defineProperty(this, "sessionId", null);
153
488
  _defineProperty(this, "messages", []);
154
489
  _defineProperty(this, "status", "idle");
155
490
  _defineProperty(this, "seq", 0);
156
- _defineProperty(this, "identity", void 0);
157
491
  _defineProperty(this, "activeRequestId", null);
158
492
  _defineProperty(this, "interrupt", null);
493
+ _defineProperty(this, "identityRestore", { phase: "idle" });
494
+ _defineProperty(this, "resumeAttempted", false);
495
+ _defineProperty(this, "httpBase", void 0);
159
496
  this.config = config;
160
497
  this.events = events;
161
- this.identity = {
162
- name: config.userName,
163
- email: config.userEmail,
164
- phone: config.userPhone
165
- };
498
+ this.httpBase = httpBaseFromWsEndpoint(config.endpoint);
499
+ if (this.httpBase === null) queueMicrotask(() => this.setStatus("error", `Invalid chat endpoint: ${config.endpoint}`));
500
+ this.store = store ?? createWidgetStore(config.agentId);
501
+ const seed = {};
502
+ if (config.userName) seed.name = config.userName;
503
+ if (config.userEmail) seed.email = config.userEmail;
504
+ if (config.userPhone) seed.phone = config.userPhone;
505
+ if (Object.keys(seed).length > 0) this.store.getState().mergeIdentity(seed);
166
506
  }
167
507
  get connectionStatus() {
168
508
  return this.status;
169
509
  }
170
- /** Merge in visitor identity (from the pre-chat form). Applied on next connect. */
510
+ /** The persisted store, exposed so the view can read identity for the pre-chat gate. */
511
+ getStore() {
512
+ return this.store;
513
+ }
514
+ /** True when a persisted session pointer exists (drives the resume path). */
515
+ hasPersistedSession() {
516
+ return !!this.store.getState().sessionId;
517
+ }
518
+ /** True when persisted identity exists (lets the view skip the pre-chat form). */
519
+ hasPersistedIdentity() {
520
+ const id = this.store.getState().identity;
521
+ return !!(id.name || id.email || id.phone);
522
+ }
523
+ /** Merge in visitor identity + consent (from the pre-chat form). Applied on next connect. */
171
524
  setUserInfo(info) {
172
- this.identity = {
173
- ...this.identity,
174
- ...info
175
- };
525
+ const { name, email, phone, consent } = info;
526
+ this.store.getState().mergeIdentity({
527
+ name,
528
+ email,
529
+ phone
530
+ });
531
+ if (consent) {
532
+ const consentAt = consent.emailOptIn || consent.smsOptIn ? (/* @__PURE__ */ new Date()).toISOString() : void 0;
533
+ this.store.getState().setConsent({
534
+ emailOptIn: consent.emailOptIn,
535
+ smsOptIn: consent.smsOptIn,
536
+ consentSource: "chat-widget-prechat",
537
+ consentAt
538
+ });
539
+ }
176
540
  }
177
541
  setInterrupt(interrupt) {
178
542
  this.interrupt = interrupt;
179
543
  this.events.onInterrupt?.(interrupt);
180
544
  }
545
+ setIdentityRestore(state) {
546
+ this.identityRestore = state;
547
+ this.events.onIdentityRestore?.(state);
548
+ }
549
+ get currentIdentityRestore() {
550
+ return this.identityRestore;
551
+ }
181
552
  /** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */
182
553
  verifyOtp(code) {
183
554
  if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return;
@@ -208,20 +579,95 @@ var ConversationController = class {
208
579
  emitMessages() {
209
580
  this.events.onMessages(this.messages.map((m) => ({ ...m })));
210
581
  }
211
- /** Open the transport and create a conversation session. Idempotent. */
582
+ /** Compute (once) + return the persisted browser fingerprint. */
583
+ fingerprint() {
584
+ const state = this.store.getState();
585
+ return getOrCreateFingerprint(() => state.browserFingerprint, (fp) => this.store.getState().setBrowserFingerprint(fp));
586
+ }
587
+ /**
588
+ * Build the `metadata` payload threaded into `create_conversation_session`:
589
+ * phone (no first-class engine field) and consent.
590
+ *
591
+ * NOTE: `verifiedEmail` is deliberately NOT stamped here. It is a per-session
592
+ * OTP proof bound to the session it was verified against
593
+ * (`verifiedEmailSessionId`), and the server only treats an actual OTP `verify`
594
+ * as proof — metadata `verifiedEmail` is just a hint. Auto-stamping it onto
595
+ * every brand-new `create_conversation_session` would mislabel a fresh
596
+ * visitor's session with a prior (possibly different) visitor's email on a
597
+ * shared browser. The verified email is only used when RESUMING the exact
598
+ * session it was proven for — see {@link verifiedEmailForSession}.
599
+ */
600
+ sessionMetadata() {
601
+ const state = this.store.getState();
602
+ const meta = {};
603
+ if (state.identity.phone) meta.userPhone = state.identity.phone;
604
+ const consent = state.consent;
605
+ if (consent.emailOptIn || consent.smsOptIn || consent.consentAt) {
606
+ const c = {
607
+ emailOptIn: consent.emailOptIn,
608
+ smsOptIn: consent.smsOptIn,
609
+ consentSource: consent.consentSource ?? "chat-widget-prechat"
610
+ };
611
+ if (consent.consentAt) c.consentAt = consent.consentAt;
612
+ meta.consent = c;
613
+ }
614
+ return Object.keys(meta).length > 0 ? meta : void 0;
615
+ }
616
+ /**
617
+ * The verified-email hint, but ONLY when the OTP proof is bound to the session
618
+ * being resumed (`verifiedEmailSessionId === sessionId`). Returns null
619
+ * otherwise so a stale/cross-visitor proof is never threaded onto a session it
620
+ * wasn't proven for.
621
+ */
622
+ verifiedEmailForSession(sessionId) {
623
+ const state = this.store.getState();
624
+ if (state.verifiedEmail && state.verifiedEmailSessionId === sessionId) return state.verifiedEmail;
625
+ return null;
626
+ }
627
+ /** Lazily open the WS client (default transport). Idempotent within a connect. */
628
+ async ensureClient() {
629
+ if (this.client) return;
630
+ this.client = new SmoothAgentClient({ url: this.config.endpoint });
631
+ await this.client.connect();
632
+ }
633
+ /**
634
+ * Open the connection and either RESUME or create a session.
635
+ *
636
+ * 1. Persisted pointer (ADR-048 §b): `get_session` → if not `ended`, reuse +
637
+ * hydrate from `get_messages` (newest-first, reversed). On ended/404 clear
638
+ * ONLY the pointer (identity/consent survive).
639
+ * 2. No persisted pointer: POST `/internal/resume-by-fingerprint` FIRST; if
640
+ * `resumable`, adopt the returned session (the wrapper has primed the
641
+ * operator registry), reuse the sessionId, and hydrate via get_session/
642
+ * get_messages — rather than relying on createConversationSession to resume.
643
+ * 3. Otherwise create a fresh session.
644
+ */
212
645
  async connect() {
213
646
  if (this.status === "connecting" || this.status === "ready") return;
214
647
  this.setStatus("connecting");
215
648
  try {
216
- this.client = new SmoothAgentClient({ url: this.config.endpoint });
217
- await this.client.connect();
218
- const session = await this.client.createConversationSession({
219
- agentId: this.config.agentId,
220
- userName: this.identity.name,
221
- userEmail: this.identity.email,
222
- ...this.identity.phone ? { metadata: { userPhone: this.identity.phone } } : {}
223
- });
224
- this.sessionId = session.sessionId;
649
+ await this.ensureClient();
650
+ if (!this.resumeAttempted) {
651
+ this.resumeAttempted = true;
652
+ const persistedSessionId = this.store.getState().sessionId;
653
+ if (persistedSessionId) {
654
+ if (await this.tryResume(persistedSessionId)) {
655
+ this.setStatus("ready");
656
+ return;
657
+ }
658
+ this.store.getState().clearSession();
659
+ } else {
660
+ const fpSessionId = await this.resumeByFingerprint();
661
+ if (fpSessionId) {
662
+ if (await this.tryResume(fpSessionId)) {
663
+ this.store.getState().setSessionId(fpSessionId);
664
+ this.setStatus("ready");
665
+ return;
666
+ }
667
+ }
668
+ }
669
+ }
670
+ await this.createSession();
225
671
  this.setStatus("ready");
226
672
  } catch (err) {
227
673
  this.setStatus("error", err instanceof Error ? err.message : String(err));
@@ -229,6 +675,105 @@ var ConversationController = class {
229
675
  }
230
676
  }
231
677
  /**
678
+ * Build the auth fields every `/internal/*` route shares: `agentId` (required
679
+ * for the agent-policy lookup), `agentName` (used as the OTP email sender), and
680
+ * the optional pre-auth `authContext` the host page may have configured. The
681
+ * `Origin` header is sent automatically by the browser and checked server-side.
682
+ */
683
+ authBody() {
684
+ const body = { agentId: this.config.agentId };
685
+ if (this.config.agentName) body.agentName = this.config.agentName;
686
+ if (this.config.authContext) body.authContext = this.config.authContext;
687
+ return body;
688
+ }
689
+ /** POST JSON to a `/internal/*` route; returns the parsed body (or throws). */
690
+ async postInternal(path, payload) {
691
+ if (this.httpBase === null) throw new Error(`Cannot reach ${path}: the chat endpoint is not an absolute URL.`);
692
+ const res = await fetch(`${this.httpBase}${path}`, {
693
+ method: "POST",
694
+ headers: { "content-type": "application/json" },
695
+ credentials: "omit",
696
+ body: JSON.stringify({
697
+ ...this.authBody(),
698
+ ...payload
699
+ })
700
+ });
701
+ let json = {};
702
+ try {
703
+ json = await res.json();
704
+ } catch {
705
+ json = {};
706
+ }
707
+ if (!res.ok) {
708
+ const err = json.error;
709
+ throw new Error(err?.message ?? `${path} failed (${res.status})`);
710
+ }
711
+ return json;
712
+ }
713
+ /**
714
+ * POST `/internal/resume-by-fingerprint`. Returns the resumable sessionId when
715
+ * the wrapper found (and primed) a recent session for this fingerprint, else
716
+ * null. Network/route failures are swallowed → null (fall through to create).
717
+ */
718
+ async resumeByFingerprint() {
719
+ try {
720
+ const json = await this.postInternal("/internal/resume-by-fingerprint", { browserFingerprint: this.fingerprint() });
721
+ if (json.resumable === true && typeof json.sessionId === "string") return json.sessionId;
722
+ } catch {}
723
+ return null;
724
+ }
725
+ /** `create_conversation_session` with fingerprint + identity + consent metadata. */
726
+ async createSession() {
727
+ if (!this.client) throw new Error("Conversation is not connected");
728
+ const state = this.store.getState();
729
+ const metadata = this.sessionMetadata();
730
+ const session = await this.client.createConversationSession({
731
+ agentId: this.config.agentId,
732
+ userName: state.identity.name,
733
+ userEmail: state.identity.email,
734
+ browserFingerprint: this.fingerprint(),
735
+ ...metadata ? { metadata } : {}
736
+ });
737
+ this.sessionId = session.sessionId;
738
+ this.store.getState().setSessionId(session.sessionId);
739
+ }
740
+ /**
741
+ * Attempt to resume `sessionId`: returns true and hydrates the transcript when
742
+ * the session is live, false when it has ended / can't be fetched.
743
+ */
744
+ async tryResume(sessionId) {
745
+ if (!this.client) return false;
746
+ let snap;
747
+ try {
748
+ snap = await this.client.getSession({ sessionId });
749
+ } catch {
750
+ return false;
751
+ }
752
+ if (snap.status === "ended") return false;
753
+ this.sessionId = sessionId;
754
+ await this.hydrateHistory(sessionId);
755
+ return true;
756
+ }
757
+ /** Page recent history (newest-first), reverse to chronological, and render. */
758
+ async hydrateHistory(sessionId) {
759
+ if (!this.client) return;
760
+ try {
761
+ const page = await this.client.getMessages({
762
+ sessionId,
763
+ limit: 50
764
+ });
765
+ const chronological = [...Array.isArray(page.messages) ? page.messages : []].reverse();
766
+ const hydrated = [];
767
+ chronological.forEach((m, i) => {
768
+ const chat = wireMessageToChat(m, i);
769
+ if (chat) hydrated.push(chat);
770
+ });
771
+ this.messages.length = 0;
772
+ this.messages.push(...hydrated);
773
+ this.emitMessages();
774
+ } catch {}
775
+ }
776
+ /**
232
777
  * Submit a user message. Appends the user bubble immediately, then streams the
233
778
  * assistant reply token-by-token, finalizing on `eventual_response`.
234
779
  */
@@ -330,12 +875,170 @@ var ConversationController = class {
330
875
  default: break;
331
876
  }
332
877
  }
878
+ /**
879
+ * Begin the cross-device restore: POST `/internal/identity/request-otp` for
880
+ * `email` over `channel`. The view collects the email via an explicit affordance.
881
+ */
882
+ async requestIdentityOtp(email, channel = "email") {
883
+ const trimmed = email.trim();
884
+ if (!trimmed) return;
885
+ this.setIdentityRestore({
886
+ phase: "requesting",
887
+ email: trimmed,
888
+ channel
889
+ });
890
+ if (!this.sessionId) try {
891
+ await this.connect();
892
+ } catch {}
893
+ if (!this.sessionId) {
894
+ this.setIdentityRestore({
895
+ phase: "error",
896
+ message: "No active session to verify against."
897
+ });
898
+ return;
899
+ }
900
+ try {
901
+ const json = await this.postInternal("/internal/identity/request-otp", {
902
+ sessionId: this.sessionId,
903
+ email: trimmed,
904
+ channel
905
+ });
906
+ const masked = typeof json.maskedDestination === "string" ? json.maskedDestination : void 0;
907
+ this.setIdentityRestore({
908
+ phase: "awaiting_code",
909
+ email: trimmed,
910
+ channel,
911
+ maskedDestination: masked
912
+ });
913
+ } catch (err) {
914
+ this.setIdentityRestore({
915
+ phase: "error",
916
+ message: err instanceof Error ? err.message : "Could not send a verification code."
917
+ });
918
+ }
919
+ }
920
+ /** Submit the code: POST `/internal/identity/verify-otp`, then resolve on success. */
921
+ async verifyIdentityOtp(code) {
922
+ const state = this.identityRestore;
923
+ const trimmed = code.trim();
924
+ if (!trimmed || state.phase !== "awaiting_code") return;
925
+ const { email, channel } = state;
926
+ if (!this.sessionId) {
927
+ this.setIdentityRestore({
928
+ phase: "error",
929
+ message: "No active session to verify against."
930
+ });
931
+ return;
932
+ }
933
+ this.setIdentityRestore({
934
+ phase: "verifying",
935
+ email,
936
+ channel
937
+ });
938
+ try {
939
+ const json = await this.postInternal("/internal/identity/verify-otp", {
940
+ sessionId: this.sessionId,
941
+ email,
942
+ code: trimmed
943
+ });
944
+ if (json.event === "otp_verified") {
945
+ this.store.getState().setVerifiedEmail(email, this.sessionId);
946
+ await this.resolveIdentity(email);
947
+ } else if (json.event === "otp_invalid") {
948
+ const remaining = typeof json.attemptsRemaining === "number" ? json.attemptsRemaining : void 0;
949
+ this.setIdentityRestore({
950
+ phase: "awaiting_code",
951
+ email,
952
+ channel,
953
+ error: "That code was incorrect.",
954
+ attemptsRemaining: remaining
955
+ });
956
+ } else this.setIdentityRestore({
957
+ phase: "error",
958
+ message: "Verification failed."
959
+ });
960
+ } catch (err) {
961
+ this.setIdentityRestore({
962
+ phase: "error",
963
+ message: err instanceof Error ? err.message : "Verification failed."
964
+ });
965
+ }
966
+ }
967
+ /** Resolve the verified identity → restorable conversations via POST `/internal/identity/resolve`. */
968
+ async resolveIdentity(email) {
969
+ if (!this.sessionId) return;
970
+ this.setIdentityRestore({
971
+ phase: "resolving",
972
+ email
973
+ });
974
+ try {
975
+ const json = await this.postInternal("/internal/identity/resolve", {
976
+ sessionId: this.sessionId,
977
+ email
978
+ });
979
+ if (json.resolved !== true) {
980
+ this.setIdentityRestore({
981
+ phase: "resolved",
982
+ email,
983
+ conversations: []
984
+ });
985
+ return;
986
+ }
987
+ const raw = json.conversations;
988
+ const conversations = Array.isArray(raw) ? raw.map((c) => {
989
+ if (!c || typeof c !== "object") return null;
990
+ const o = c;
991
+ const conversationId = typeof o.conversationId === "string" ? o.conversationId : "";
992
+ const sessionId = typeof o.sessionId === "string" ? o.sessionId : "";
993
+ if (!sessionId) return null;
994
+ return {
995
+ conversationId,
996
+ sessionId,
997
+ lastActivityAt: typeof o.lastActivityAt === "string" ? o.lastActivityAt : void 0,
998
+ preview: typeof o.preview === "string" ? o.preview : void 0
999
+ };
1000
+ }).filter((c) => c !== null) : [];
1001
+ this.setIdentityRestore({
1002
+ phase: "resolved",
1003
+ email,
1004
+ conversations
1005
+ });
1006
+ } catch (err) {
1007
+ this.setIdentityRestore({
1008
+ phase: "error",
1009
+ message: err instanceof Error ? err.message : "Could not load your chats."
1010
+ });
1011
+ }
1012
+ }
1013
+ /**
1014
+ * Replay a chosen restorable conversation: point the live session at its
1015
+ * sessionId, hydrate its transcript (get_session + get_messages), and persist
1016
+ * the new pointer so the next `sendMessage` continues it.
1017
+ */
1018
+ async restoreConversation(sessionId) {
1019
+ if (!this.client) await this.ensureClient();
1020
+ const proven = this.sessionId ? this.verifiedEmailForSession(this.sessionId) : null;
1021
+ if (await this.tryResume(sessionId)) {
1022
+ this.store.getState().setSessionId(sessionId);
1023
+ if (proven) this.store.getState().setVerifiedEmail(proven, sessionId);
1024
+ this.setIdentityRestore({ phase: "idle" });
1025
+ this.setStatus("ready");
1026
+ } else this.setIdentityRestore({
1027
+ phase: "error",
1028
+ message: "That conversation is no longer available."
1029
+ });
1030
+ }
1031
+ /** Dismiss the cross-device restore panel. */
1032
+ cancelIdentityRestore() {
1033
+ this.setIdentityRestore({ phase: "idle" });
1034
+ }
333
1035
  /** Tear down the underlying client. */
334
1036
  disconnect() {
335
1037
  this.client?.disconnect("widget closed");
336
1038
  this.client = null;
337
1039
  this.sessionId = null;
338
1040
  this.activeRequestId = null;
1041
+ this.resumeAttempted = false;
339
1042
  this.setInterrupt(null);
340
1043
  this.setStatus("closed");
341
1044
  }
@@ -1162,6 +1865,24 @@ function buildStyles(theme, mode = "popover") {
1162
1865
  border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent);
1163
1866
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent);
1164
1867
  }
1868
+ /* Inline phone validity — subtle, themed. Empty stays neutral (optional field). */
1869
+ .pc-field.valid input {
1870
+ border-color: color-mix(in srgb, var(--sac-primary) 55%, #2faa6a 45%);
1871
+ }
1872
+ .pc-field.invalid input {
1873
+ border-color: color-mix(in srgb, #e2566b 62%, var(--sac-border) 38%);
1874
+ }
1875
+ .pc-field.invalid input:focus {
1876
+ box-shadow: 0 0 0 4px color-mix(in srgb, #e2566b 16%, transparent);
1877
+ }
1878
+ .pc-field .pc-hint {
1879
+ min-height: 13px;
1880
+ margin-top: 1px;
1881
+ font-size: 11.5px;
1882
+ font-weight: 500;
1883
+ line-height: 1.2;
1884
+ color: color-mix(in srgb, #e2566b 78%, var(--sac-text) 22%);
1885
+ }
1165
1886
  .pc-submit {
1166
1887
  margin-top: 4px;
1167
1888
  border: none;
@@ -1177,6 +1898,17 @@ function buildStyles(theme, mode = "popover") {
1177
1898
  }
1178
1899
  .pc-submit:hover { transform: translateY(-1px); }
1179
1900
  .pc-submit:active { transform: scale(.98); }
1901
+ .pc-consents { display: flex; flex-direction: column; gap: 9px; margin-top: 2px; }
1902
+ .pc-consent { display: flex; align-items: flex-start; gap: 9px; cursor: pointer; }
1903
+ .pc-consent input {
1904
+ margin-top: 2px;
1905
+ width: 16px;
1906
+ height: 16px;
1907
+ flex: 0 0 auto;
1908
+ accent-color: var(--sac-primary);
1909
+ cursor: pointer;
1910
+ }
1911
+ .pc-consent span { font-size: 12px; line-height: 1.4; color: color-mix(in srgb, var(--sac-text) 72%, transparent); }
1180
1912
 
1181
1913
  /* ─────────────────── Starter-prompt chips ─────────────────────────── */
1182
1914
  .prompts { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 2px 35px; }
@@ -1255,6 +1987,61 @@ function buildStyles(theme, mode = "popover") {
1255
1987
  .int-row .int-btn { flex: 1; }
1256
1988
  .int-row .int-input + .int-btn { flex: 0 0 auto; }
1257
1989
  .int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
1990
+ .int-card { position: relative; }
1991
+ .int-close {
1992
+ position: absolute;
1993
+ top: 8px;
1994
+ right: 9px;
1995
+ border: none;
1996
+ background: transparent;
1997
+ color: color-mix(in srgb, var(--sac-text) 55%, transparent);
1998
+ font-size: 18px;
1999
+ line-height: 1;
2000
+ cursor: pointer;
2001
+ padding: 2px 4px;
2002
+ border-radius: 6px;
2003
+ transition: color .2s ease, background .2s ease;
2004
+ }
2005
+ .int-close:hover { color: var(--sac-text); background: color-mix(in srgb, var(--sac-text) 8%, transparent); }
2006
+
2007
+ /* ─────────────── Cross-device "Restore my chats" ──────────────────── */
2008
+ .restore-link {
2009
+ border: none;
2010
+ background: none;
2011
+ padding: 0;
2012
+ font: inherit;
2013
+ font-size: 10.5px;
2014
+ letter-spacing: .04em;
2015
+ color: color-mix(in srgb, var(--sac-primary) 80%, var(--sac-text));
2016
+ cursor: pointer;
2017
+ text-decoration: underline;
2018
+ text-underline-offset: 2px;
2019
+ }
2020
+ .restore-link:hover { color: var(--sac-primary); }
2021
+ .restore-list { display: flex; flex-direction: column; gap: 7px; margin-top: 9px; }
2022
+ .restore-item {
2023
+ display: flex;
2024
+ align-items: center;
2025
+ justify-content: space-between;
2026
+ gap: 10px;
2027
+ text-align: left;
2028
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
2029
+ background: var(--sac-bg);
2030
+ color: var(--sac-text);
2031
+ border-radius: 10px;
2032
+ padding: 9px 11px;
2033
+ font-family: inherit;
2034
+ font-size: 12.5px;
2035
+ cursor: pointer;
2036
+ transition: border-color .2s ease, background .2s ease, transform .2s ease;
2037
+ }
2038
+ .restore-item:hover {
2039
+ border-color: color-mix(in srgb, var(--sac-primary) 50%, transparent);
2040
+ background: color-mix(in srgb, var(--sac-primary) 8%, var(--sac-bg));
2041
+ transform: translateY(-1px);
2042
+ }
2043
+ .restore-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2044
+ .restore-when { flex: 0 0 auto; font-size: 11px; color: color-mix(in srgb, var(--sac-text) 55%, transparent); }
1258
2045
 
1259
2046
  .hidden { display: none !important; }
1260
2047
 
@@ -1266,7 +2053,46 @@ function buildStyles(theme, mode = "popover") {
1266
2053
  }
1267
2054
  //#endregion
1268
2055
  //#region src/element.ts
2056
+ /**
2057
+ * `<smooth-agent-chat>` — a framework-light embeddable chat web component.
2058
+ *
2059
+ * A clean, dependency-light web component that preserves a familiar embedding
2060
+ * model — a launcher + popover panel, declarative HTML attributes, and a
2061
+ * programmatic API — while talking to the `@smooai/smooth-operator` protocol
2062
+ * client. The visual layer is the "Aurora Glass" design system (see
2063
+ * {@link buildStyles}): a spring launcher with a live presence pulse, a
2064
+ * glass-depth panel, a gradient brand avatar + status dot, an animated typing
2065
+ * indicator, message rise-in, refined source cards, and an icon composer. Every
2066
+ * color is driven by `--sac-*` custom properties so a host's brand flows through.
2067
+ *
2068
+ * Embedding model:
2069
+ * <smooth-agent-chat endpoint="ws://localhost:8787/ws" agent-id="…"></smooth-agent-chat>
2070
+ * or programmatically via {@link mountChatWidget}.
2071
+ */
1269
2072
  const ELEMENT_TAG = "smooth-agent-chat";
2073
+ /**
2074
+ * Default region for phone parsing/formatting on the pre-chat form. The widget
2075
+ * is US-first; the backend does the authoritative E.164 normalization (SMOODEV-2153),
2076
+ * so this only governs the as-you-type display + the inline validity hint and the
2077
+ * best-effort E.164 we send when the number already parses as valid.
2078
+ */
2079
+ const PHONE_DEFAULT_REGION = "US";
2080
+ /**
2081
+ * Best-effort E.164 for an as-typed phone number. Returns the canonical
2082
+ * `+1…` form when the value parses to a valid number in {@link PHONE_DEFAULT_REGION},
2083
+ * otherwise `null` (caller falls back to sending the raw value — the backend
2084
+ * re-parses and normalizes/nulls authoritatively).
2085
+ */
2086
+ function phoneToE164(value) {
2087
+ const v = value.trim();
2088
+ if (!v) return null;
2089
+ try {
2090
+ if (!isValidPhoneNumber(v, PHONE_DEFAULT_REGION)) return null;
2091
+ return parsePhoneNumber(v, PHONE_DEFAULT_REGION).number;
2092
+ } catch {
2093
+ return null;
2094
+ }
2095
+ }
1270
2096
  const OBSERVED = [
1271
2097
  "endpoint",
1272
2098
  "agent-id",
@@ -1315,6 +2141,9 @@ var SmoothAgentChatElement = class extends HTMLElement {
1315
2141
  _defineProperty(this, "greeting", "");
1316
2142
  _defineProperty(this, "interrupt", null);
1317
2143
  _defineProperty(this, "interruptEl", null);
2144
+ _defineProperty(this, "identityRestore", { phase: "idle" });
2145
+ _defineProperty(this, "allowChatRestore", true);
2146
+ _defineProperty(this, "gating", false);
1318
2147
  _defineProperty(this, "panelEl", null);
1319
2148
  _defineProperty(this, "launcherEl", null);
1320
2149
  _defineProperty(this, "messagesEl", null);
@@ -1361,7 +2190,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1361
2190
  openChat() {
1362
2191
  this.open = true;
1363
2192
  this.syncOpenState();
1364
- this.controller?.connect().catch(() => {});
2193
+ if (!this.gating) this.controller?.connect().catch(() => {});
1365
2194
  }
1366
2195
  /** Collapse the chat panel back to the launcher. */
1367
2196
  closeChat() {
@@ -1382,6 +2211,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1382
2211
  userName: this.overrides.userName,
1383
2212
  userEmail: this.overrides.userEmail,
1384
2213
  userPhone: this.overrides.userPhone,
2214
+ authContext: this.overrides.authContext,
1385
2215
  placeholder: this.overrides.placeholder ?? this.getAttribute("placeholder") ?? void 0,
1386
2216
  greeting: this.overrides.greeting ?? this.getAttribute("greeting") ?? void 0,
1387
2217
  connectionErrorMessage: this.overrides.connectionErrorMessage,
@@ -1390,6 +2220,9 @@ var SmoothAgentChatElement = class extends HTMLElement {
1390
2220
  requireName: this.overrides.requireName,
1391
2221
  requireEmail: this.overrides.requireEmail,
1392
2222
  requirePhone: this.overrides.requirePhone,
2223
+ collectPhone: this.overrides.collectPhone,
2224
+ collectConsent: this.overrides.collectConsent,
2225
+ allowChatRestore: this.overrides.allowChatRestore,
1393
2226
  allowAnonymous: this.overrides.allowAnonymous,
1394
2227
  theme
1395
2228
  };
@@ -1401,6 +2234,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1401
2234
  return;
1402
2235
  }
1403
2236
  const resolved = resolveConfig(config);
2237
+ this.allowChatRestore = resolved.allowChatRestore;
1404
2238
  if (!this.controller) {
1405
2239
  this.controller = new ConversationController(config, {
1406
2240
  onMessages: (messages) => {
@@ -1414,9 +2248,14 @@ var SmoothAgentChatElement = class extends HTMLElement {
1414
2248
  onInterrupt: (interrupt) => {
1415
2249
  this.interrupt = interrupt;
1416
2250
  this.renderInterrupt();
2251
+ },
2252
+ onIdentityRestore: (state) => {
2253
+ this.identityRestore = state;
2254
+ this.renderInterrupt();
1417
2255
  }
1418
2256
  });
1419
2257
  if (resolved.startOpen) this.open = true;
2258
+ if (this.controller.hasPersistedSession() || this.controller.hasPersistedIdentity()) this.userInfoSatisfied = true;
1420
2259
  }
1421
2260
  const fullpage = resolved.mode === "fullpage";
1422
2261
  if (fullpage) this.open = true;
@@ -1441,7 +2280,14 @@ var SmoothAgentChatElement = class extends HTMLElement {
1441
2280
  this.examplePrompts = resolved.examplePrompts;
1442
2281
  this.greeting = resolved.greeting;
1443
2282
  const gating = needsUserInfo(resolved) && !this.userInfoSatisfied;
1444
- const field = (name, type, label, autocomplete) => `<label class="pc-field"><span>${escapeHtml(label)}</span><input name="${name}" type="${type}" autocomplete="${autocomplete}" required /></label>`;
2283
+ this.gating = gating;
2284
+ const showPhone = resolved.requirePhone || resolved.collectPhone;
2285
+ const field = (name, type, label, autocomplete, required, 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>`;
2286
+ const consentBox = (name, label) => `<label class="pc-consent"><input name="${name}" type="checkbox" /><span>${escapeHtml(label)}</span></label>`;
2287
+ const consentHtml = resolved.collectConsent ? `<div class="pc-consents">
2288
+ ${consentBox("emailOptIn", "Email me product news and offers.")}
2289
+ ${consentBox("smsOptIn", "Text me updates by SMS. Message/data rates may apply.")}
2290
+ </div>` : "";
1445
2291
  const prechatHtml = `
1446
2292
  <div class="prechat">
1447
2293
  <div class="pc-head">
@@ -1449,12 +2295,14 @@ var SmoothAgentChatElement = class extends HTMLElement {
1449
2295
  <div class="pc-sub">A couple details so ${escapeHtml(resolved.agentName)} can help.</div>
1450
2296
  </div>
1451
2297
  <form class="pc-form" novalidate>
1452
- ${resolved.requireName ? field("name", "text", "Name", "name") : ""}
1453
- ${resolved.requireEmail ? field("email", "email", "Email", "email") : ""}
1454
- ${resolved.requirePhone ? field("phone", "tel", "Phone", "tel") : ""}
2298
+ ${resolved.requireName ? field("name", "text", "Name", "name", true) : ""}
2299
+ ${resolved.requireEmail ? field("email", "email", "Email", "email", true) : ""}
2300
+ ${showPhone ? field("phone", "tel", "Phone", "tel", resolved.requirePhone, true) : ""}
2301
+ ${consentHtml}
1455
2302
  <button type="submit" class="pc-submit">Start chat</button>
1456
2303
  </form>
1457
2304
  </div>`;
2305
+ const restoreLink = this.allowChatRestore ? ` · <button type="button" class="restore-link">Restore my chats</button>` : "";
1458
2306
  const chatHtml = `
1459
2307
  <div class="messages"></div>
1460
2308
  <div class="interrupt hidden"></div>
@@ -1463,7 +2311,7 @@ var SmoothAgentChatElement = class extends HTMLElement {
1463
2311
  <textarea rows="1" placeholder="${escapeHtml(resolved.placeholder)}"></textarea>
1464
2312
  <button class="send" type="button" aria-label="Send message">${ICON.send}</button>
1465
2313
  </div>
1466
- <div class="footer">powered by <b>smooth&#8209;operator</b></div>
2314
+ <div class="footer">powered by <b>smooth&#8209;operator</b>${restoreLink}</div>
1467
2315
  </div>`;
1468
2316
  const container = document.createElement("div");
1469
2317
  container.innerHTML = `
@@ -1501,6 +2349,14 @@ var SmoothAgentChatElement = class extends HTMLElement {
1501
2349
  ev.preventDefault();
1502
2350
  this.handlePrechatSubmit(pcForm);
1503
2351
  });
2352
+ this.wirePhoneField(pcForm);
2353
+ container.querySelector(".restore-link")?.addEventListener("click", () => {
2354
+ (async () => {
2355
+ this.identityRestore = { phase: "awaiting_email" };
2356
+ this.renderInterrupt();
2357
+ await this.controller?.connect().catch(() => {});
2358
+ })();
2359
+ });
1504
2360
  if (fullpage && !gating) this.controller?.connect().catch(() => {});
1505
2361
  this.syncOpenState();
1506
2362
  if (!gating) this.renderMessages();
@@ -1519,6 +2375,11 @@ var SmoothAgentChatElement = class extends HTMLElement {
1519
2375
  el.replaceChildren();
1520
2376
  const it = this.interrupt;
1521
2377
  if (!it) {
2378
+ if (this.identityRestore.phase !== "idle") {
2379
+ el.classList.remove("hidden");
2380
+ el.appendChild(this.buildRestoreCard());
2381
+ return;
2382
+ }
1522
2383
  el.classList.add("hidden");
1523
2384
  return;
1524
2385
  }
@@ -1598,15 +2459,247 @@ var SmoothAgentChatElement = class extends HTMLElement {
1598
2459
  }
1599
2460
  el.appendChild(card);
1600
2461
  }
1601
- /** Collect identity from the pre-chat form, then drop into the chat view. */
2462
+ /**
2463
+ * Build the cross-device "Restore my chats" card (ADR-048 §c). Reuses the
2464
+ * same overlay slot + visual language as the OTP interrupt. All server-supplied
2465
+ * strings (masked destination, conversation previews) are set via `textContent`
2466
+ * — never innerHTML — so they can't inject markup; only the static lock icon
2467
+ * uses innerHTML.
2468
+ */
2469
+ buildRestoreCard() {
2470
+ const state = this.identityRestore;
2471
+ const card = document.createElement("div");
2472
+ card.className = "int-card";
2473
+ const head = document.createElement("div");
2474
+ head.className = "int-head";
2475
+ const ico = document.createElement("span");
2476
+ ico.className = "int-ico";
2477
+ ico.innerHTML = ICON.lock;
2478
+ const title = document.createElement("span");
2479
+ title.className = "int-title";
2480
+ title.textContent = "Restore your chats";
2481
+ head.append(ico, title);
2482
+ card.appendChild(head);
2483
+ const close = document.createElement("button");
2484
+ close.className = "int-close";
2485
+ close.type = "button";
2486
+ close.setAttribute("aria-label", "Cancel");
2487
+ close.textContent = "×";
2488
+ close.addEventListener("click", () => {
2489
+ this.controller?.cancelIdentityRestore();
2490
+ this.identityRestore = { phase: "idle" };
2491
+ this.renderInterrupt();
2492
+ });
2493
+ card.appendChild(close);
2494
+ if (state.phase === "awaiting_email") {
2495
+ const desc = document.createElement("div");
2496
+ desc.className = "int-desc";
2497
+ desc.textContent = "Enter your email and we'll send a code to find your previous chats.";
2498
+ card.appendChild(desc);
2499
+ const row = document.createElement("div");
2500
+ row.className = "int-row";
2501
+ const input = document.createElement("input");
2502
+ input.className = "int-input";
2503
+ input.type = "email";
2504
+ input.autocomplete = "email";
2505
+ input.placeholder = "you@example.com";
2506
+ const go = () => {
2507
+ const email = input.value.trim();
2508
+ if (email) this.controller?.requestIdentityOtp(email, "email");
2509
+ };
2510
+ input.addEventListener("keydown", (ev) => {
2511
+ if (ev.key === "Enter") {
2512
+ ev.preventDefault();
2513
+ go();
2514
+ }
2515
+ });
2516
+ const send = document.createElement("button");
2517
+ send.className = "int-btn primary";
2518
+ send.type = "button";
2519
+ send.textContent = "Send code";
2520
+ send.addEventListener("click", go);
2521
+ row.append(input, send);
2522
+ card.appendChild(row);
2523
+ if (state.error) {
2524
+ const err = document.createElement("div");
2525
+ err.className = "int-error";
2526
+ err.textContent = state.error;
2527
+ card.appendChild(err);
2528
+ }
2529
+ queueMicrotask(() => input.focus());
2530
+ } else if (state.phase === "requesting" || state.phase === "verifying" || state.phase === "resolving") {
2531
+ const msg = document.createElement("div");
2532
+ msg.className = "int-sent";
2533
+ msg.textContent = state.phase === "requesting" ? "Sending a code…" : state.phase === "verifying" ? "Verifying…" : "Finding your chats…";
2534
+ card.appendChild(msg);
2535
+ } else if (state.phase === "awaiting_code") {
2536
+ if (state.maskedDestination) {
2537
+ const sent = document.createElement("div");
2538
+ sent.className = "int-sent";
2539
+ sent.textContent = `Code sent to ${state.maskedDestination}.`;
2540
+ card.appendChild(sent);
2541
+ }
2542
+ const row = document.createElement("div");
2543
+ row.className = "int-row";
2544
+ const input = document.createElement("input");
2545
+ input.className = "int-input";
2546
+ input.type = "text";
2547
+ input.inputMode = "numeric";
2548
+ input.autocomplete = "one-time-code";
2549
+ input.placeholder = "Enter code";
2550
+ const submit = () => {
2551
+ const code = input.value.trim();
2552
+ if (code) this.controller?.verifyIdentityOtp(code);
2553
+ };
2554
+ input.addEventListener("keydown", (ev) => {
2555
+ if (ev.key === "Enter") {
2556
+ ev.preventDefault();
2557
+ submit();
2558
+ }
2559
+ });
2560
+ const verify = document.createElement("button");
2561
+ verify.className = "int-btn primary";
2562
+ verify.type = "button";
2563
+ verify.textContent = "Verify";
2564
+ verify.addEventListener("click", submit);
2565
+ row.append(input, verify);
2566
+ card.appendChild(row);
2567
+ if (state.error) {
2568
+ const err = document.createElement("div");
2569
+ err.className = "int-error";
2570
+ err.textContent = state.attemptsRemaining != null ? `${state.error} (${state.attemptsRemaining} left)` : state.error;
2571
+ card.appendChild(err);
2572
+ }
2573
+ queueMicrotask(() => input.focus());
2574
+ } else if (state.phase === "resolved") if (state.conversations.length === 0) {
2575
+ const none = document.createElement("div");
2576
+ none.className = "int-desc";
2577
+ none.textContent = "No previous chats found for that email.";
2578
+ card.appendChild(none);
2579
+ } else {
2580
+ const pick = document.createElement("div");
2581
+ pick.className = "int-desc";
2582
+ pick.textContent = "Pick a conversation to continue:";
2583
+ card.appendChild(pick);
2584
+ const list = document.createElement("div");
2585
+ list.className = "restore-list";
2586
+ for (const conv of state.conversations) {
2587
+ const btn = document.createElement("button");
2588
+ btn.type = "button";
2589
+ btn.className = "restore-item";
2590
+ const preview = document.createElement("span");
2591
+ preview.className = "restore-preview";
2592
+ preview.textContent = conv.preview || "Conversation";
2593
+ btn.appendChild(preview);
2594
+ if (conv.lastActivityAt) {
2595
+ const when = document.createElement("span");
2596
+ when.className = "restore-when";
2597
+ when.textContent = this.formatWhen(conv.lastActivityAt);
2598
+ btn.appendChild(when);
2599
+ }
2600
+ btn.addEventListener("click", () => {
2601
+ this.controller?.restoreConversation(conv.sessionId);
2602
+ });
2603
+ list.appendChild(btn);
2604
+ }
2605
+ card.appendChild(list);
2606
+ }
2607
+ else if (state.phase === "error") {
2608
+ const err = document.createElement("div");
2609
+ err.className = "int-error";
2610
+ err.textContent = state.message;
2611
+ card.appendChild(err);
2612
+ }
2613
+ return card;
2614
+ }
2615
+ /** Format an ISO timestamp as a short, locale-aware label (best-effort). */
2616
+ formatWhen(iso) {
2617
+ const d = new Date(iso);
2618
+ if (Number.isNaN(d.getTime())) return "";
2619
+ try {
2620
+ return d.toLocaleDateString(void 0, {
2621
+ month: "short",
2622
+ day: "numeric"
2623
+ });
2624
+ } catch {
2625
+ return "";
2626
+ }
2627
+ }
2628
+ /**
2629
+ * Wire as-you-type formatting + an inline validity hint onto the pre-chat
2630
+ * phone input (libphonenumber-js, US default region). Autofill is preserved:
2631
+ * the input keeps its `type="tel"` + `autocomplete="tel"` + implicit <label>,
2632
+ * and we also reformat on `change` so a browser-autofilled value gets
2633
+ * formatted/validated too.
2634
+ *
2635
+ * As-you-type caret note: `AsYouType` reformats the entire string, which
2636
+ * moves the caret to the end on a mid-string edit. To avoid fighting the
2637
+ * user, we only rewrite the value when the caret is at the end (the typical
2638
+ * append-a-digit case) and never on a deletion — so backspacing the
2639
+ * formatting characters works naturally.
2640
+ */
2641
+ wirePhoneField(form) {
2642
+ const input = form?.querySelector("input[name=\"phone\"]");
2643
+ if (!input) return;
2644
+ const hint = input.parentElement?.querySelector(".pc-hint");
2645
+ const updateHint = () => {
2646
+ const v = input.value.trim();
2647
+ const field = input.closest(".pc-field");
2648
+ if (!v) {
2649
+ field?.classList.remove("valid", "invalid");
2650
+ if (hint) hint.textContent = "";
2651
+ return;
2652
+ }
2653
+ const ok = isValidPhoneNumber(v, PHONE_DEFAULT_REGION);
2654
+ field?.classList.toggle("valid", ok);
2655
+ field?.classList.toggle("invalid", !ok);
2656
+ if (hint) hint.textContent = ok ? "" : "Enter a valid phone number";
2657
+ };
2658
+ const reformat = () => {
2659
+ if (input.selectionStart === input.value.length && input.selectionEnd === input.value.length) {
2660
+ const formatted = new AsYouType(PHONE_DEFAULT_REGION).input(input.value);
2661
+ if (formatted.length >= input.value.length) input.value = formatted;
2662
+ }
2663
+ updateHint();
2664
+ };
2665
+ input.addEventListener("input", (ev) => {
2666
+ const ie = ev;
2667
+ if (typeof ie.inputType === "string" && ie.inputType.startsWith("delete")) {
2668
+ updateHint();
2669
+ return;
2670
+ }
2671
+ reformat();
2672
+ });
2673
+ input.addEventListener("change", reformat);
2674
+ }
2675
+ /** Collect identity + consent from the pre-chat form, then drop into the chat view. */
1602
2676
  handlePrechatSubmit(form) {
1603
2677
  if (!form.reportValidity()) return;
1604
2678
  const data = new FormData(form);
1605
2679
  const val = (k) => data.get(k)?.trim() || void 0;
2680
+ const checked = (k) => data.get(k) === "on";
2681
+ const rawPhone = val("phone");
2682
+ const phoneInput = form.querySelector("input[name=\"phone\"]");
2683
+ if (rawPhone && phoneInput && !isValidPhoneNumber(rawPhone, PHONE_DEFAULT_REGION)) {
2684
+ const required = phoneInput.hasAttribute("required");
2685
+ const field = phoneInput.closest(".pc-field");
2686
+ field?.classList.add("invalid");
2687
+ const hint = field?.querySelector(".pc-hint");
2688
+ if (hint) hint.textContent = "Enter a valid phone number";
2689
+ if (required) {
2690
+ phoneInput.focus();
2691
+ return;
2692
+ }
2693
+ }
2694
+ const phone = rawPhone ? phoneToE164(rawPhone) ?? rawPhone : void 0;
1606
2695
  this.controller?.setUserInfo({
1607
2696
  name: val("name"),
1608
2697
  email: val("email"),
1609
- phone: val("phone")
2698
+ phone,
2699
+ consent: {
2700
+ emailOptIn: checked("emailOptIn"),
2701
+ smsOptIn: checked("smsOptIn")
2702
+ }
1610
2703
  });
1611
2704
  this.userInfoSatisfied = true;
1612
2705
  this.render();
@@ -2053,6 +3146,6 @@ function initChatWidgetLoader() {
2053
3146
  else window.addEventListener("load", schedule, { once: true });
2054
3147
  }
2055
3148
  //#endregion
2056
- export { ConversationController, ELEMENT_TAG, SmoothAgentChatElement, defineChatWidget, initChatWidgetLoader, mountChatWidget, mountFullPageChat };
3149
+ export { ConversationController, ELEMENT_TAG, PERSIST_VERSION, SmoothAgentChatElement, computeFingerprint, createWidgetStore, defineChatWidget, getOrCreateFingerprint, httpBaseFromWsEndpoint, initChatWidgetLoader, mountChatWidget, mountFullPageChat, storageKey };
2057
3150
 
2058
3151
  //# sourceMappingURL=index.js.map