@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.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Persisted widget state — the identity / consent / session-pointer client layer
3
+ * (ADR-048, SMOODEV-2129e).
4
+ *
5
+ * Built on Zustand's framework-agnostic `vanilla` store + the `persist`
6
+ * middleware (the user explicitly chose Zustand; it's ~1KB and has no React
7
+ * dependency, so it works inside the web component). The store is keyed per
8
+ * agent — localStorage key `smoo-chat-widget:<agentId>` — so two agents embedded
9
+ * on the same origin don't clobber each other.
10
+ *
11
+ * ## What persists (and, deliberately, what does NOT)
12
+ *
13
+ * Only a **pointer** to the conversation plus the visitor's identity + marketing
14
+ * consent + verified email + browser fingerprint. The transcript is **never**
15
+ * persisted: the smooth-operator server is the source of truth and the widget
16
+ * re-hydrates history via `getMessages` on resume. This keeps localStorage small
17
+ * and avoids stale/divergent transcripts.
18
+ *
19
+ * `version` drives `persist.migrate` so future shape changes can upgrade old
20
+ * blobs in place instead of silently dropping them.
21
+ */
22
+ import { createStore, type StoreApi } from 'zustand/vanilla';
23
+ import { persist, type PersistStorage } from 'zustand/middleware';
24
+
25
+ /** Current persisted-shape version. Bump when the shape below changes incompatibly. */
26
+ export const PERSIST_VERSION = 1 as const;
27
+
28
+ /** Marketing-consent record captured at the pre-chat form. */
29
+ export interface ConsentState {
30
+ emailOptIn: boolean;
31
+ smsOptIn: boolean;
32
+ /** Where the consent was captured. The widget always stamps `chat-widget-prechat`. */
33
+ consentSource?: string;
34
+ /** ISO 8601 timestamp stamped when the visitor ticked a consent box. */
35
+ consentAt?: string;
36
+ }
37
+
38
+ /** Visitor identity collected from config or the pre-chat form. */
39
+ export interface IdentityState {
40
+ name?: string;
41
+ email?: string;
42
+ phone?: string;
43
+ }
44
+
45
+ /**
46
+ * The exact persisted shape (ADR-048 §d). Only the pointer + identity + consent
47
+ * persist — never the transcript.
48
+ */
49
+ export interface PersistedWidgetState {
50
+ version: typeof PERSIST_VERSION;
51
+ /** Pointer to the active conversation session; cleared on ended/404. */
52
+ sessionId: string | null;
53
+ identity: IdentityState;
54
+ consent: ConsentState;
55
+ /**
56
+ * Email proven via OTP for the CURRENT session. Session-scoped: cleared on
57
+ * `clearSession()` so it can't leak across visitors on a shared browser, and
58
+ * only threaded into session metadata when {@link verifiedEmailSessionId}
59
+ * matches the live session (i.e. on resume of the verified session) — never
60
+ * auto-stamped onto a brand-new session.
61
+ */
62
+ verifiedEmail: string | null;
63
+ /** The sessionId the {@link verifiedEmail} was proven against (binds the proof to one session). */
64
+ verifiedEmailSessionId: string | null;
65
+ /** Stable per-browser correlation token (see fingerprint.ts). */
66
+ browserFingerprint: string | null;
67
+ }
68
+
69
+ /** Store actions layered on top of the persisted state. */
70
+ export interface WidgetStoreActions {
71
+ setSessionId: (sessionId: string | null) => void;
72
+ /** Merge identity fields (undefined values don't clobber existing ones). */
73
+ mergeIdentity: (identity: IdentityState) => void;
74
+ /** Replace consent wholesale (the pre-chat form computes the full record). */
75
+ setConsent: (consent: ConsentState) => void;
76
+ /** Record an OTP-proven email bound to a specific session. */
77
+ setVerifiedEmail: (email: string | null, sessionId?: string | null) => void;
78
+ setBrowserFingerprint: (fp: string) => void;
79
+ /**
80
+ * Clear the session pointer (and the session-scoped `verifiedEmail` proof) but
81
+ * KEEP identity / consent / fingerprint — used when a persisted session has
82
+ * ended or 404s so the next turn starts a fresh session for a known visitor.
83
+ * `verifiedEmail` is deliberately cleared here: it is a per-session OTP proof,
84
+ * not a durable identity, so it must NOT survive onto a new session (which on a
85
+ * shared browser could be a different visitor).
86
+ */
87
+ clearSession: () => void;
88
+ }
89
+
90
+ export type WidgetStore = PersistedWidgetState & WidgetStoreActions;
91
+
92
+ const EMPTY_CONSENT: ConsentState = { emailOptIn: false, smsOptIn: false };
93
+
94
+ function initialPersisted(): PersistedWidgetState {
95
+ return {
96
+ version: PERSIST_VERSION,
97
+ sessionId: null,
98
+ identity: {},
99
+ consent: { ...EMPTY_CONSENT },
100
+ verifiedEmail: null,
101
+ verifiedEmailSessionId: null,
102
+ browserFingerprint: null,
103
+ };
104
+ }
105
+
106
+ /** localStorage key for an agent's persisted widget state. */
107
+ export function storageKey(agentId: string): string {
108
+ return `smoo-chat-widget:${agentId}`;
109
+ }
110
+
111
+ /**
112
+ * An explicit in-memory (Map-backed) `PersistStorage`. Used as the fallback when
113
+ * real localStorage is unavailable.
114
+ *
115
+ * IMPORTANT: we must NOT return `undefined` from {@link safeStorage} in that case.
116
+ * zustand v5's `persist` treats a missing `storage` option by falling back to its
117
+ * OWN `createJSONStorage(() => localStorage)` — i.e. it re-engages the very
118
+ * localStorage the guard was trying to avoid (throwing again in privacy mode).
119
+ * Handing it this no-op storage keeps the store working purely in memory and
120
+ * guarantees the fallback can't touch real localStorage.
121
+ */
122
+ function memoryStorage(): PersistStorage<PersistedWidgetState> {
123
+ const mem = new Map<string, string>();
124
+ return {
125
+ getItem: (name) => {
126
+ const raw = mem.get(name);
127
+ if (!raw) return null;
128
+ try {
129
+ return JSON.parse(raw) as ReturnType<PersistStorage<PersistedWidgetState>['getItem']>;
130
+ } catch {
131
+ return null;
132
+ }
133
+ },
134
+ setItem: (name, value) => {
135
+ mem.set(name, JSON.stringify(value));
136
+ },
137
+ removeItem: (name) => {
138
+ mem.delete(name);
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * A `persist` storage adapter that tolerates the *absence* of localStorage
145
+ * (SSR, privacy-mode throwing on access, sandboxed iframes). When storage is
146
+ * unavailable the store still works in-memory; nothing is persisted, but the
147
+ * widget never throws on boot.
148
+ */
149
+ function safeStorage(): PersistStorage<PersistedWidgetState> {
150
+ let ls: Storage | null = null;
151
+ try {
152
+ ls = typeof localStorage !== 'undefined' ? localStorage : null;
153
+ // Probe: some environments expose localStorage but throw on access.
154
+ if (ls) {
155
+ const probe = '__smoo_probe__';
156
+ ls.setItem(probe, '1');
157
+ ls.removeItem(probe);
158
+ }
159
+ } catch {
160
+ ls = null;
161
+ }
162
+ // Fall back to an explicit in-memory store — NEVER `undefined` (see memoryStorage).
163
+ if (!ls) return memoryStorage();
164
+ const storage = ls;
165
+ return {
166
+ getItem: (name) => {
167
+ try {
168
+ const raw = storage.getItem(name);
169
+ return raw ? (JSON.parse(raw) as ReturnType<PersistStorage<PersistedWidgetState>['getItem']>) : null;
170
+ } catch {
171
+ return null;
172
+ }
173
+ },
174
+ setItem: (name, value) => {
175
+ try {
176
+ storage.setItem(name, JSON.stringify(value));
177
+ } catch {
178
+ /* quota / privacy-mode write failures are non-fatal */
179
+ }
180
+ },
181
+ removeItem: (name) => {
182
+ try {
183
+ storage.removeItem(name);
184
+ } catch {
185
+ /* non-fatal */
186
+ }
187
+ },
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Migrate a persisted blob from an older `version` to the current shape. Today
193
+ * v1 is the only version, so this just backfills any missing fields onto an
194
+ * unknown old blob; future versions add `case` branches here.
195
+ */
196
+ function migrate(persisted: unknown): PersistedWidgetState {
197
+ const base = initialPersisted();
198
+ if (!persisted || typeof persisted !== 'object') return base;
199
+ const p = persisted as Partial<PersistedWidgetState>;
200
+ return {
201
+ version: PERSIST_VERSION,
202
+ sessionId: typeof p.sessionId === 'string' ? p.sessionId : null,
203
+ identity: {
204
+ name: typeof p.identity?.name === 'string' ? p.identity.name : undefined,
205
+ email: typeof p.identity?.email === 'string' ? p.identity.email : undefined,
206
+ phone: typeof p.identity?.phone === 'string' ? p.identity.phone : undefined,
207
+ },
208
+ consent: {
209
+ emailOptIn: p.consent?.emailOptIn === true,
210
+ smsOptIn: p.consent?.smsOptIn === true,
211
+ consentSource: typeof p.consent?.consentSource === 'string' ? p.consent.consentSource : undefined,
212
+ consentAt: typeof p.consent?.consentAt === 'string' ? p.consent.consentAt : undefined,
213
+ },
214
+ verifiedEmail: typeof p.verifiedEmail === 'string' ? p.verifiedEmail : null,
215
+ verifiedEmailSessionId: typeof p.verifiedEmailSessionId === 'string' ? p.verifiedEmailSessionId : null,
216
+ browserFingerprint: typeof p.browserFingerprint === 'string' ? p.browserFingerprint : null,
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Create the per-agent persisted Zustand store. The `partialize` step ensures
222
+ * only the persisted shape (never any future transient action/UI state) is
223
+ * written to localStorage.
224
+ */
225
+ export function createWidgetStore(agentId: string): StoreApi<WidgetStore> {
226
+ return createStore<WidgetStore>()(
227
+ persist(
228
+ (set) => ({
229
+ ...initialPersisted(),
230
+ setSessionId: (sessionId) => set({ sessionId }),
231
+ mergeIdentity: (identity) =>
232
+ set((state) => ({
233
+ identity: {
234
+ ...state.identity,
235
+ ...(identity.name !== undefined ? { name: identity.name } : {}),
236
+ ...(identity.email !== undefined ? { email: identity.email } : {}),
237
+ ...(identity.phone !== undefined ? { phone: identity.phone } : {}),
238
+ },
239
+ })),
240
+ setConsent: (consent) => set({ consent: { ...consent } }),
241
+ setVerifiedEmail: (verifiedEmail, sessionId) =>
242
+ set((state) => ({
243
+ verifiedEmail,
244
+ // Bind the proof to the session it was verified against. When the
245
+ // caller omits sessionId, fall back to the live pointer so the
246
+ // proof is never left unbound.
247
+ verifiedEmailSessionId: verifiedEmail === null ? null : (sessionId ?? state.sessionId),
248
+ })),
249
+ setBrowserFingerprint: (browserFingerprint) => set({ browserFingerprint }),
250
+ // Drop the pointer AND the session-scoped OTP proof; keep durable identity.
251
+ clearSession: () => set({ sessionId: null, verifiedEmail: null, verifiedEmailSessionId: null }),
252
+ }),
253
+ {
254
+ name: storageKey(agentId),
255
+ version: PERSIST_VERSION,
256
+ storage: safeStorage(),
257
+ migrate,
258
+ // Persist ONLY the data shape — never the action functions.
259
+ partialize: (state): PersistedWidgetState => ({
260
+ version: PERSIST_VERSION,
261
+ sessionId: state.sessionId,
262
+ identity: state.identity,
263
+ consent: state.consent,
264
+ verifiedEmail: state.verifiedEmail,
265
+ verifiedEmailSessionId: state.verifiedEmailSessionId,
266
+ browserFingerprint: state.browserFingerprint,
267
+ }),
268
+ },
269
+ ),
270
+ );
271
+ }
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;
@@ -530,6 +548,17 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
530
548
  }
531
549
  .pc-submit:hover { transform: translateY(-1px); }
532
550
  .pc-submit:active { transform: scale(.98); }
551
+ .pc-consents { display: flex; flex-direction: column; gap: 9px; margin-top: 2px; }
552
+ .pc-consent { display: flex; align-items: flex-start; gap: 9px; cursor: pointer; }
553
+ .pc-consent input {
554
+ margin-top: 2px;
555
+ width: 16px;
556
+ height: 16px;
557
+ flex: 0 0 auto;
558
+ accent-color: var(--sac-primary);
559
+ cursor: pointer;
560
+ }
561
+ .pc-consent span { font-size: 12px; line-height: 1.4; color: color-mix(in srgb, var(--sac-text) 72%, transparent); }
533
562
 
534
563
  /* ─────────────────── Starter-prompt chips ─────────────────────────── */
535
564
  .prompts { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 2px 35px; }
@@ -608,6 +637,61 @@ export function buildStyles(theme: ResolvedTheme, mode: ChatWidgetMode = 'popove
608
637
  .int-row .int-btn { flex: 1; }
609
638
  .int-row .int-input + .int-btn { flex: 0 0 auto; }
610
639
  .int-error { margin-top: 8px; font-size: 12px; color: #f87171; }
640
+ .int-card { position: relative; }
641
+ .int-close {
642
+ position: absolute;
643
+ top: 8px;
644
+ right: 9px;
645
+ border: none;
646
+ background: transparent;
647
+ color: color-mix(in srgb, var(--sac-text) 55%, transparent);
648
+ font-size: 18px;
649
+ line-height: 1;
650
+ cursor: pointer;
651
+ padding: 2px 4px;
652
+ border-radius: 6px;
653
+ transition: color .2s ease, background .2s ease;
654
+ }
655
+ .int-close:hover { color: var(--sac-text); background: color-mix(in srgb, var(--sac-text) 8%, transparent); }
656
+
657
+ /* ─────────────── Cross-device "Restore my chats" ──────────────────── */
658
+ .restore-link {
659
+ border: none;
660
+ background: none;
661
+ padding: 0;
662
+ font: inherit;
663
+ font-size: 10.5px;
664
+ letter-spacing: .04em;
665
+ color: color-mix(in srgb, var(--sac-primary) 80%, var(--sac-text));
666
+ cursor: pointer;
667
+ text-decoration: underline;
668
+ text-underline-offset: 2px;
669
+ }
670
+ .restore-link:hover { color: var(--sac-primary); }
671
+ .restore-list { display: flex; flex-direction: column; gap: 7px; margin-top: 9px; }
672
+ .restore-item {
673
+ display: flex;
674
+ align-items: center;
675
+ justify-content: space-between;
676
+ gap: 10px;
677
+ text-align: left;
678
+ border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent);
679
+ background: var(--sac-bg);
680
+ color: var(--sac-text);
681
+ border-radius: 10px;
682
+ padding: 9px 11px;
683
+ font-family: inherit;
684
+ font-size: 12.5px;
685
+ cursor: pointer;
686
+ transition: border-color .2s ease, background .2s ease, transform .2s ease;
687
+ }
688
+ .restore-item:hover {
689
+ border-color: color-mix(in srgb, var(--sac-primary) 50%, transparent);
690
+ background: color-mix(in srgb, var(--sac-primary) 8%, var(--sac-bg));
691
+ transform: translateY(-1px);
692
+ }
693
+ .restore-preview { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
694
+ .restore-when { flex: 0 0 auto; font-size: 11px; color: color-mix(in srgb, var(--sac-text) 55%, transparent); }
611
695
 
612
696
  .hidden { display: none !important; }
613
697