@kavchat/react-native 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -147,6 +147,44 @@ If your app is Hebrew or Arabic, set RTL once in your app as usual (`I18nManager
147
147
 
148
148
  The free plan shows "Powered by Kavchat" under the chat (linking to kavchat.com); paid plans hide it automatically.
149
149
 
150
+ ## Live chat with a representative
151
+
152
+ Nothing to set up for the basic flow. When live chat is on in the dashboard (`liveChat.enabled` in the release), the chat
153
+ behaves like the website widget (contract: `docs/live-chat-protocol.md`):
154
+
155
+ - **Option:** "Talk to a representative" is added before "Talk to our team" only after `GET /live/availability` says open
156
+ (cached 30 s, checked again on tap). Otherwise only the contact form, with a line for `closed` (and when the team is back)
157
+ or `no_agents`; `quota` and `disabled` show the form without explanation.
158
+ - **Request form** (`view: 'liveForm'`): guests leave an email or a phone, name and first message optional, department choice when
159
+ more than one is open; identified users (`userToken`) skip contact details. Not accepted: the contact form opens with what was typed.
160
+ - **Waiting / chat** (`view: 'live'`): countdown to `queueDeadline`, "Leave a message instead", "End chat", agent name, typing
161
+ (sent at most every 3 s), transfers, plain text only. When the wait ends, the chat becomes a request (a sync after the deadline
162
+ catches it even if the stream missed it).
163
+ - **Transport:** SSE with a single-use stream ticket, read through `XMLHttpRequest` progress events (or `fetch` with
164
+ `body.getReader()`), long-poll fallback (`POST /live/sync` with `wait`) when no streaming transport exists, after `live_busy`,
165
+ or after 3 failed streams. Reconnect with 1-30 s backoff and +-30% jitter, then sync after `lastSeq`. Order and dedupe by `seq`
166
+ (the shared reducer in `@kavchat/core`), client message ids for idempotent retries, pending and failed bubbles (tap to retry).
167
+ - **Lifecycle:** the stream closes when `AppState` is `background` and catches up on `active`. The credential (`lvc_...`, up to
168
+ 12 hours) is kept in `secureStorage` (default: `storage`) and restores an ongoing chat after an app restart. It is never put in a
169
+ URL or a log. Switching user drops it.
170
+ - **No push notifications in v1:** messages arrive only while the app is open.
171
+
172
+ ```tsx
173
+ import * as SecureStore from 'expo-secure-store';
174
+ const safe = (k: string) => k.replace(/[^A-Za-z0-9._-]/g, '_'); // SecureStore key charset
175
+ const secureStorage = {
176
+ getItem: (k: string) => SecureStore.getItemAsync(safe(k)),
177
+ setItem: (k: string, v: string) => SecureStore.setItemAsync(safe(k), v),
178
+ removeItem: (k: string) => SecureStore.deleteItemAsync(safe(k)),
179
+ };
180
+
181
+ <KavchatProvider publicKey="pk_live_..." secureStorage={secureStorage} livePage="checkout" onEvent={(e) => e.type === 'live' && console.log(e.phase)}>
182
+
183
+ const { openLive, livePhase } = useKavchat(); // your own "chat with us" button
184
+ ```
185
+
186
+ UI texts are the `live.*` keys of `@kavchat/core` (11 languages), the same as the website widget.
187
+
150
188
  ## AI answers (Premium)
151
189
 
152
190
  Nothing to configure in the app. When the workspace owner turns on AI answers in the dashboard (Premium, with the
@@ -2,6 +2,9 @@ import type { BotChip, BotEffect, BotState, UiTexts } from '@kavchat/core';
2
2
  import { EnabledRelease, FetchLike } from './api';
3
3
  import { KavchatScreen } from './screens';
4
4
  import { StorageAdapter } from './storage';
5
+ import { LiveDepartmentAvailability, LiveUnavailableReason } from './live/api';
6
+ import { LivePhase, LiveSnapshot, LiveTiming } from './live/session';
7
+ import type { StreamOpener } from './live/sse';
5
8
  /**
6
9
  * הבקר: טעינת הגרסה עם ETag ומטמון, שמירת השיחה, דיווח לשרת ופניות
7
10
  * קוד TypeScript רגיל בלי React Native, כדי שאפשר יהיה לבדוק אותו ב-Node.
@@ -9,11 +12,13 @@ import { StorageAdapter } from './storage';
9
12
  */
10
13
  export declare const DEFAULT_API = "https://api.kavchat.com";
11
14
  export declare const BRANDING_URL = "https://kavchat.com";
15
+ /** טופס פנייה (contact) או טופס הבקשה לנציג (live) */
12
16
  export type ContactEffect = Extract<BotEffect, {
13
- type: 'contact';
17
+ type: 'contact' | 'live';
14
18
  }>;
15
19
  export type KavchatStatus = 'loading' | 'ready' | 'unavailable';
16
- export type KavchatView = 'chat' | 'form' | 'sent';
20
+ /** live: צ'אט עם נציג (המתנה, שיחה, או המצב הסופי) */
21
+ export type KavchatView = 'chat' | 'form' | 'sent' | 'live' | 'liveForm';
17
22
  export type KavchatEvent = {
18
23
  type: 'ready';
19
24
  version: number;
@@ -28,7 +33,25 @@ export type KavchatEvent = {
28
33
  type: 'ticket';
29
34
  id: string;
30
35
  number: number;
36
+ }
37
+ /** צ'אט עם נציג: queued, active, closed, ticket, replaced או expired */
38
+ | {
39
+ type: 'live';
40
+ phase: LivePhase;
31
41
  };
42
+ /** האם להציע "צ'אט עם נציג" בטופס הפנייה, ואם לא, למה */
43
+ export interface LiveOffer {
44
+ /** בודקים עכשיו את /live/availability */
45
+ checking: boolean;
46
+ open: boolean;
47
+ /** closed ו-no_agents מוצגים כשורת הסבר. quota ו-disabled: רק הטופס, בלי הסבר */
48
+ reason: LiveUnavailableReason | null;
49
+ /** המחלקות הפתוחות. יותר מאחת: המשתמש בוחר */
50
+ departments: LiveDepartmentAvailability[];
51
+ /** כש-reason הוא closed: מתי הצוות זמין שוב */
52
+ nextOpenAt: string | null;
53
+ }
54
+ export declare const AVAILABILITY_CACHE_MS = 30000;
32
55
  export interface KavchatSnapshot {
33
56
  status: KavchatStatus;
34
57
  release: EnabledRelease | null;
@@ -48,6 +71,18 @@ export interface KavchatSnapshot {
48
71
  reason?: string;
49
72
  identified: boolean;
50
73
  userName?: string;
74
+ /** liveChat.enabled בגרסה: רק אז מציעים צ'אט עם נציג */
75
+ liveEnabled: boolean;
76
+ liveOffer: LiveOffer | null;
77
+ /** הצ'אט עם הנציג, אם התחיל */
78
+ live: LiveSnapshot | null;
79
+ /** מה שהמשתמש כבר כתב בטופס הבקשה לנציג, כשאין מי שיענה וחוזרים לטופס הפנייה */
80
+ contactPrefill: {
81
+ name?: string;
82
+ email?: string;
83
+ phone?: string;
84
+ message?: string;
85
+ } | null;
51
86
  }
52
87
  export interface ControllerOptions {
53
88
  publicKey: string;
@@ -69,6 +104,21 @@ export interface ControllerOptions {
69
104
  reportDelayMs?: number;
70
105
  sessionMaxAgeMs?: number;
71
106
  now?: () => number;
107
+ /**
108
+ * אחסון לקרדנשל של הצ'אט עם נציג (lvc_...). מומלץ אחסון מאובטח, למשל expo-secure-store.
109
+ * ברירת מחדל: storage
110
+ */
111
+ secureStorage?: StorageAdapter;
112
+ /** שם המסך שנשלח עם הבקשה לנציג (page), למשל 'checkout' */
113
+ livePage?: string;
114
+ /** להחלפה בבדיקות */
115
+ liveTiming?: Partial<LiveTiming>;
116
+ /** להחלפה בבדיקות. null: רק long-poll */
117
+ streamOpener?: StreamOpener | null;
118
+ random?: () => number;
119
+ }
120
+ export interface LiveRequestFields extends Partial<ContactFields> {
121
+ departmentId?: string;
72
122
  }
73
123
  export interface ContactFields {
74
124
  name?: string;
@@ -81,6 +131,16 @@ export declare class KavchatController {
81
131
  private opts;
82
132
  private api;
83
133
  private store;
134
+ private secure;
135
+ private liveCfg;
136
+ private liveSession;
137
+ private liveTried;
138
+ private livePhase;
139
+ /** נוצרים פעם אחת לכל ניסיון של המשתמש, ומשמשים לכל ניסיון חוזר */
140
+ private liveRequestId;
141
+ private liveMessageClientId;
142
+ private availability;
143
+ private foreground;
84
144
  private snap;
85
145
  private listeners;
86
146
  private session;
@@ -97,6 +157,8 @@ export declare class KavchatController {
97
157
  ready: Promise<void>;
98
158
  /** מתממש כשניסיון רישום המסכים האחרון הסתיים. בשביל בדיקות */
99
159
  screensSync: Promise<void>;
160
+ /** מתממש כשניסיון שחזור הצ'אט עם הנציג הסתיים. בשביל בדיקות */
161
+ liveRestore: Promise<void>;
100
162
  constructor(options: ControllerOptions);
101
163
  subscribe: (listener: () => void) => () => void;
102
164
  getSnapshot: () => KavchatSnapshot;
@@ -130,6 +192,11 @@ export declare class KavchatController {
130
192
  */
131
193
  private run;
132
194
  private setConversation;
195
+ /**
196
+ * הכפתורים להצגה: כמו במנוע, ועם "שיחה עם נציג" לפני "פנייה לצוות" כשיש נציג זמין עכשיו
197
+ * (liveChat.enabled בגרסה, ו-/live/availability אמר open)
198
+ */
199
+ chips(): BotChip[];
133
200
  private afterAction;
134
201
  private handleEffect;
135
202
  /** פתיחת הטופס מכפתור מחוץ לתסריט */
@@ -140,6 +207,38 @@ export declare class KavchatController {
140
207
  private scheduleReport;
141
208
  /** שולח את המסלול עכשיו. קוראים לזה גם כשהאפליקציה עוברת לרקע */
142
209
  flush(): Promise<boolean>;
210
+ private liveOptions;
211
+ private onLive;
212
+ /** אחרי הפעלה מחדש: חוזרים לשיחה שעוד פתוחה, אם הקרדנשל בתוקף */
213
+ private restoreLive;
214
+ /**
215
+ * בודק אם אפשר להציע צ'אט עם נציג. נקרא כשטופס הפנייה נפתח. התשובה נשמרת 30 שניות.
216
+ * בלי liveChat.enabled בגרסה לא שואלים בכלל
217
+ */
218
+ checkLiveAvailability(force?: boolean): Promise<void>;
219
+ private availabilityInflight;
220
+ private fetchAvailability;
221
+ /**
222
+ * לחיצה על "שיחה עם נציג" (הכפתור שלפני "פנייה לצוות", או openLive מהאפליקציה):
223
+ * בודקים זמינות שוב. יש נציג: טופס הבקשה (view liveForm). אין: טופס הפנייה עם שורת הסבר
224
+ */
225
+ openLive(effect?: ContactEffect): Promise<void>;
226
+ /**
227
+ * שליחת טופס הבקשה לנציג. אורח חייב להשאיר מייל או טלפון, ההודעה אופציונלית.
228
+ * מחזיר הודעת שגיאה בשפת הממשק, או null. כשאין מי שיענה: טופס הפנייה עם מה שנכתב ושורת הסבר (liveOffer.reason)
229
+ */
230
+ requestLive(fields?: LiveRequestFields): Promise<string | null>;
231
+ /** מחזיר הודעת שגיאה בשפת הממשק (ואז הטקסט נשאר בשדה), או null */
232
+ liveSend(text: string): string | null;
233
+ liveRetry(clientId: string): void;
234
+ /** נקרא בכל שינוי בשדה הטקסט של הצ'אט עם הנציג */
235
+ liveTyping(text: string): void;
236
+ liveLeaveMessage(text?: string): Promise<boolean>;
237
+ liveEnd(): Promise<boolean>;
238
+ /** "חזרה לעוזר" אחרי שהצ'אט עם הנציג הסתיים. שיחה פתוחה לא נסגרת מכאן */
239
+ closeLive(): boolean;
240
+ /** האפליקציה ברקע: הזרם נסגר. בחזרה: השלמה לפי seq וזרם חדש. אין התראות push בגרסה הזו */
241
+ setForeground(active: boolean): void;
143
242
  dispose(): void;
144
243
  private now;
145
244
  private emit;
package/lib/controller.js CHANGED
@@ -1,8 +1,10 @@
1
- import { aiQuestion, answerWithAi, configureEngine, eventsPayload, isRtl, pendingQuestion, pressChip, sendText, startConversation, transcriptOf, uiTexts, } from '@kavchat/core';
1
+ import { aiQuestion, answerWithAi, configureEngine, eventsPayload, isRtl, pendingQuestion, pressChip, sendText, startConversation, transcriptOf, uiTexts, withLiveChip, LIVE_AVAILABILITY_TTL_MS, } from '@kavchat/core';
2
2
  import { fetchRelease, postAiAnswer, postEvents, postScreens, postTicket } from './api';
3
3
  import { SCREENS_MAX_AGE_MS, normalizeScreens, screensFingerprint, screensKey } from './screens';
4
4
  import { memoryStorage, randomId, readJson, releaseKey, sessionKey, userFingerprint, writeJson, } from './storage';
5
5
  import { pickLocale, safeUrl, ticketError, validateTicket } from './text';
6
+ import { liveApi } from './live/api';
7
+ import { LiveSession, liveKey, requestHandoff } from './live/session';
6
8
  /**
7
9
  * הבקר: טעינת הגרסה עם ETag ומטמון, שמירת השיחה, דיווח לשרת ופניות
8
10
  * קוד TypeScript רגיל בלי React Native, כדי שאפשר יהיה לבדוק אותו ב-Node.
@@ -10,8 +12,41 @@ import { pickLocale, safeUrl, ticketError, validateTicket } from './text';
10
12
  */
11
13
  export const DEFAULT_API = 'https://api.kavchat.com';
12
14
  export const BRANDING_URL = 'https://kavchat.com';
15
+ export const AVAILABILITY_CACHE_MS = LIVE_AVAILABILITY_TTL_MS;
16
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
17
+ function liveConfig(release) {
18
+ const lc = release ? release.liveChat : undefined;
19
+ return lc && typeof lc === 'object' && lc.enabled === true ? lc : null;
20
+ }
21
+ function offerFrom(a) {
22
+ const open = a.enabled === true && a.open === true;
23
+ const reason = open ? null : a.reason ?? null;
24
+ return {
25
+ checking: false,
26
+ open,
27
+ reason,
28
+ departments: open ? (a.departments ?? []).filter((d) => d.open) : [],
29
+ nextOpenAt: reason === 'closed' ? earliest(a.departments) : null,
30
+ };
31
+ }
32
+ function earliest(deps) {
33
+ let best = null;
34
+ for (const d of deps ?? []) {
35
+ if (d.nextOpenAt && (!best || Date.parse(d.nextOpenAt) < Date.parse(best)))
36
+ best = d.nextOpenAt;
37
+ }
38
+ return best;
39
+ }
13
40
  export class KavchatController {
14
41
  constructor(options) {
42
+ this.liveSession = null;
43
+ this.liveTried = false;
44
+ this.livePhase = null;
45
+ /** נוצרים פעם אחת לכל ניסיון של המשתמש, ומשמשים לכל ניסיון חוזר */
46
+ this.liveRequestId = null;
47
+ this.liveMessageClientId = null;
48
+ this.availability = null;
49
+ this.foreground = true;
15
50
  this.listeners = new Set();
16
51
  this.session = null;
17
52
  this.loadSeq = 0;
@@ -27,6 +62,8 @@ export class KavchatController {
27
62
  this.ready = Promise.resolve();
28
63
  /** מתממש כשניסיון רישום המסכים האחרון הסתיים. בשביל בדיקות */
29
64
  this.screensSync = Promise.resolve();
65
+ /** מתממש כשניסיון שחזור הצ'אט עם הנציג הסתיים. בשביל בדיקות */
66
+ this.liveRestore = Promise.resolve();
30
67
  // ---------- מנוי, בשביל useSyncExternalStore ----------
31
68
  this.subscribe = (listener) => {
32
69
  this.listeners.add(listener);
@@ -35,6 +72,7 @@ export class KavchatController {
35
72
  };
36
73
  };
37
74
  this.getSnapshot = () => this.snap;
75
+ this.availabilityInflight = null;
38
76
  if (!options || !options.publicKey)
39
77
  throw new Error('Kavchat: publicKey is required');
40
78
  this.opts = { ...options };
@@ -45,6 +83,8 @@ export class KavchatController {
45
83
  getToken: () => this.opts.userToken || undefined,
46
84
  fetch: options.fetch,
47
85
  };
86
+ this.secure = options.secureStorage ?? this.store;
87
+ this.liveCfg = { ...this.api, getLocale: () => this.snap.locale };
48
88
  this.screens = normalizeScreens(options.screens);
49
89
  const locale = pickLocale(options.locale, options.deviceLocale, 'en');
50
90
  this.snap = {
@@ -61,6 +101,10 @@ export class KavchatController {
61
101
  ticketNumber: null,
62
102
  identified: !!options.userToken,
63
103
  userName: options.userName,
104
+ liveEnabled: false,
105
+ liveOffer: null,
106
+ live: null,
107
+ contactPrefill: null,
64
108
  };
65
109
  }
66
110
  update(patch) {
@@ -131,7 +175,8 @@ export class KavchatController {
131
175
  let view = this.snap.view;
132
176
  if (changed || !conversation) {
133
177
  conversation = await this.restoreSession(release, user);
134
- view = 'chat';
178
+ // צ'אט פתוח עם נציג נשאר על המסך גם כשהגרסה מתחלפת
179
+ view = this.liveSession ? 'live' : 'chat';
135
180
  }
136
181
  this.update({
137
182
  status: 'ready',
@@ -146,8 +191,11 @@ export class KavchatController {
146
191
  reason: undefined,
147
192
  identified: !!this.opts.userToken,
148
193
  userName: this.opts.userName,
194
+ liveEnabled: !!liveConfig(release),
149
195
  });
150
196
  this.emit({ type: 'ready', version: release.version, offline });
197
+ if (!this.liveTried)
198
+ this.liveRestore = this.restoreLive();
151
199
  }
152
200
  async restoreSession(release, user) {
153
201
  const saved = await readJson(this.store, sessionKey(this.opts.publicKey));
@@ -170,6 +218,17 @@ export class KavchatController {
170
218
  if (token === this.opts.userToken && name === this.opts.userName)
171
219
  return this.ready;
172
220
  this.flush();
221
+ if (userFingerprint(token) !== userFingerprint(this.opts.userToken)) {
222
+ // שיחה עם נציג של משתמש אחד לא ממשיכה אצל אחר
223
+ this.liveSession?.forget();
224
+ this.liveSession = null;
225
+ this.liveTried = false;
226
+ this.livePhase = null;
227
+ this.liveRequestId = null;
228
+ this.liveMessageClientId = null;
229
+ this.availability = null;
230
+ this.update({ live: null, liveOffer: null, view: this.snap.view === 'live' ? 'chat' : this.snap.view });
231
+ }
173
232
  this.opts.userToken = token;
174
233
  this.opts.userName = name;
175
234
  return this.load();
@@ -298,6 +357,17 @@ export class KavchatController {
298
357
  void writeJson(this.store, sessionKey(this.opts.publicKey), this.session);
299
358
  }
300
359
  this.update({ conversation: state, ...extra });
360
+ // כפתור "פנייה לצוות" על המסך: בודקים (עם מטמון של 30 שניות) אם להוסיף לפניו "שיחה עם נציג"
361
+ if (state.chips.some((c) => c.kind === 'contact'))
362
+ void this.checkLiveAvailability();
363
+ }
364
+ /**
365
+ * הכפתורים להצגה: כמו במנוע, ועם "שיחה עם נציג" לפני "פנייה לצוות" כשיש נציג זמין עכשיו
366
+ * (liveChat.enabled בגרסה, ו-/live/availability אמר open)
367
+ */
368
+ chips() {
369
+ const chips = this.snap.conversation?.chips ?? [];
370
+ return this.snap.liveEnabled && this.snap.liveOffer?.open ? withLiveChip(chips, this.snap.texts['live.talk']) : chips;
301
371
  }
302
372
  afterAction(r) {
303
373
  this.setConversation(r.state);
@@ -307,8 +377,12 @@ export class KavchatController {
307
377
  }
308
378
  handleEffect(effect) {
309
379
  this.emit({ type: 'effect', effect });
310
- if (effect.type === 'contact') {
311
- this.update({ view: 'form', contact: effect, ticketNumber: null });
380
+ if (effect.type === 'live') {
381
+ void this.openLive(effect);
382
+ }
383
+ else if (effect.type === 'contact') {
384
+ this.update({ view: 'form', contact: effect, ticketNumber: null, contactPrefill: null });
385
+ void this.checkLiveAvailability();
312
386
  }
313
387
  else if (effect.type === 'link') {
314
388
  // רק https ו-tel. כל פרוטוקול אחר מהתסריט נחסם
@@ -326,6 +400,7 @@ export class KavchatController {
326
400
  this.update({
327
401
  view: 'form',
328
402
  ticketNumber: null,
403
+ contactPrefill: null,
329
404
  contact: {
330
405
  type: 'contact',
331
406
  contactTopicId: null,
@@ -334,6 +409,7 @@ export class KavchatController {
334
409
  transcript: state ? transcriptOf(state) : [],
335
410
  },
336
411
  });
412
+ void this.checkLiveAvailability();
337
413
  }
338
414
  backToChat() {
339
415
  this.update({ view: 'chat', contact: null, ticketNumber: null });
@@ -393,10 +469,263 @@ export class KavchatController {
393
469
  return ok;
394
470
  });
395
471
  }
472
+ // ---------- צ'אט עם נציג ----------
473
+ liveOptions() {
474
+ return {
475
+ api: this.liveCfg,
476
+ store: this.secure,
477
+ storageKey: liveKey(this.opts.publicKey),
478
+ user: userFingerprint(this.opts.userToken),
479
+ opener: this.opts.streamOpener,
480
+ timing: this.opts.liveTiming,
481
+ random: this.opts.random,
482
+ now: this.opts.now,
483
+ onChange: (snap) => {
484
+ // רק הסשן הנוכחי מעדכן את המסך
485
+ if (this.liveSession && this.liveSession.getSnapshot() === snap)
486
+ this.onLive(snap);
487
+ },
488
+ };
489
+ }
490
+ onLive(snap) {
491
+ this.update({ live: snap });
492
+ if (snap.phase !== this.livePhase) {
493
+ this.livePhase = snap.phase;
494
+ this.emit({ type: 'live', phase: snap.phase });
495
+ }
496
+ }
497
+ /** אחרי הפעלה מחדש: חוזרים לשיחה שעוד פתוחה, אם הקרדנשל בתוקף */
498
+ async restoreLive() {
499
+ if (this.liveTried || this.liveSession)
500
+ return;
501
+ this.liveTried = true;
502
+ const user = userFingerprint(this.opts.userToken);
503
+ const s = await LiveSession.restore(this.liveOptions());
504
+ if (!s)
505
+ return;
506
+ if (this.disposed || this.liveSession || user !== userFingerprint(this.opts.userToken)) {
507
+ s.dispose();
508
+ return;
509
+ }
510
+ this.liveSession = s;
511
+ if (!this.foreground)
512
+ s.pause();
513
+ this.livePhase = null;
514
+ this.update({ view: 'live' });
515
+ this.onLive(s.getSnapshot());
516
+ }
517
+ /**
518
+ * בודק אם אפשר להציע צ'אט עם נציג. נקרא כשטופס הפנייה נפתח. התשובה נשמרת 30 שניות.
519
+ * בלי liveChat.enabled בגרסה לא שואלים בכלל
520
+ */
521
+ checkLiveAvailability(force = false) {
522
+ if (!liveConfig(this.snap.release)) {
523
+ if (this.snap.liveOffer)
524
+ this.update({ liveOffer: null });
525
+ return Promise.resolve();
526
+ }
527
+ const cached = this.availability;
528
+ if (!force && cached && this.now() - cached.at < AVAILABILITY_CACHE_MS) {
529
+ if (this.snap.liveOffer?.checking || !this.snap.liveOffer)
530
+ this.update({ liveOffer: offerFrom(cached.value) });
531
+ return Promise.resolve();
532
+ }
533
+ // בקשה אחת בכל פעם: הכפתורים, הטופס והלחיצה על הכפתור מחכים לאותה תשובה
534
+ if (!this.availabilityInflight || force) {
535
+ this.availabilityInflight = this.fetchAvailability().finally(() => {
536
+ this.availabilityInflight = null;
537
+ });
538
+ }
539
+ return this.availabilityInflight;
540
+ }
541
+ async fetchAvailability() {
542
+ // עד שהתשובה מגיעה נשארת ההצעה הקודמת, כדי שהכפתור לא יהבהב
543
+ if (!this.snap.liveOffer)
544
+ this.update({ liveOffer: { checking: true, open: false, reason: null, departments: [], nextOpenAt: null } });
545
+ const r = await liveApi.availability(this.liveCfg);
546
+ if (this.disposed)
547
+ return;
548
+ if (!r.ok) {
549
+ // לא ידוע: מציגים רק את הטופס, בלי הסבר
550
+ this.update({ liveOffer: { checking: false, open: false, reason: null, departments: [], nextOpenAt: null } });
551
+ return;
552
+ }
553
+ this.availability = { at: this.now(), value: r.data };
554
+ this.update({ liveOffer: offerFrom(r.data) });
555
+ }
556
+ /**
557
+ * לחיצה על "שיחה עם נציג" (הכפתור שלפני "פנייה לצוות", או openLive מהאפליקציה):
558
+ * בודקים זמינות שוב. יש נציג: טופס הבקשה (view liveForm). אין: טופס הפנייה עם שורת הסבר
559
+ */
560
+ async openLive(effect) {
561
+ const state = this.snap.conversation;
562
+ const e = effect ?? {
563
+ type: 'live',
564
+ contactTopicId: null,
565
+ scriptId: state?.lastScriptId ?? null,
566
+ prefill: '',
567
+ transcript: state ? transcriptOf(state) : [],
568
+ };
569
+ if (this.liveSession && !this.liveSession.isFinal()) {
570
+ this.update({ view: 'live' });
571
+ return;
572
+ }
573
+ await this.checkLiveAvailability();
574
+ if (this.disposed)
575
+ return;
576
+ const open = !!this.snap.liveOffer?.open;
577
+ this.update({ view: open ? 'liveForm' : 'form', contact: e, ticketNumber: null, contactPrefill: null });
578
+ }
579
+ /**
580
+ * שליחת טופס הבקשה לנציג. אורח חייב להשאיר מייל או טלפון, ההודעה אופציונלית.
581
+ * מחזיר הודעת שגיאה בשפת הממשק, או null. כשאין מי שיענה: טופס הפנייה עם מה שנכתב ושורת הסבר (liveOffer.reason)
582
+ */
583
+ async requestLive(fields = {}) {
584
+ const t = this.snap.texts;
585
+ if (this.liveSession && !this.liveSession.isFinal()) {
586
+ this.update({ view: 'live' });
587
+ return null;
588
+ }
589
+ const trim = (v) => (v ?? '').trim() || undefined;
590
+ const email = trim(fields.email);
591
+ const phone = trim(fields.phone);
592
+ const message = trim(fields.message);
593
+ const identified = !!this.opts.userToken;
594
+ if (!email && !phone && !identified)
595
+ return t['form.contactRequired'];
596
+ if (email && !EMAIL_RE.test(email))
597
+ return t['form.emailInvalid'];
598
+ if (message && message.length > 2000)
599
+ return this.snap.texts['live.tooLong'];
600
+ this.liveRequestId ?? (this.liveRequestId = randomId(22));
601
+ if (message)
602
+ this.liveMessageClientId ?? (this.liveMessageClientId = randomId(22));
603
+ const effect = this.snap.contact;
604
+ const bot = this.snap.conversation;
605
+ const transcript = (effect?.transcript ?? (bot ? transcriptOf(bot) : []))
606
+ .slice(-200)
607
+ .map((x) => ({ from: x.from, text: String(x.text).slice(0, 1000), at: x.at }));
608
+ const departments = this.snap.liveOffer?.departments ?? [];
609
+ const body = {
610
+ requestId: this.liveRequestId,
611
+ sessionId: this.session?.id,
612
+ identityToken: this.opts.userToken || undefined,
613
+ name: trim(fields.name),
614
+ email,
615
+ phone,
616
+ // ברירת מחדל: המחלקה הפתוחה הראשונה
617
+ departmentId: trim(fields.departmentId) ?? departments[0]?.id,
618
+ topicId: trim(fields.topicId) ?? (effect?.contactTopicId || undefined),
619
+ message,
620
+ messageClientId: message ? this.liveMessageClientId ?? undefined : undefined,
621
+ page: this.opts.livePage || undefined,
622
+ transcript,
623
+ };
624
+ const r = await requestHandoff(this.liveCfg, body, this.opts.liveTiming, this.opts.random);
625
+ if (this.disposed)
626
+ return null;
627
+ if (!r.ok) {
628
+ if (r.code === 'ticket_contact_required')
629
+ return t['form.contactRequired'];
630
+ if (r.code === 'email_invalid')
631
+ return t['form.emailInvalid'];
632
+ if (r.code === 'live_message_too_long')
633
+ return this.snap.texts['live.tooLong'];
634
+ if (r.code === 'live_conversation_open')
635
+ return t['live.elsewhere'];
636
+ if (r.code === 'network')
637
+ return t['form.error'];
638
+ return r.message || t['form.error'];
639
+ }
640
+ this.liveRequestId = null;
641
+ this.liveMessageClientId = null;
642
+ if (!r.data.accepted) {
643
+ // אין מי שיענה עכשיו: טופס הפנייה הרגיל עם מה שכבר נכתב ושורת הסבר
644
+ const cached = this.availability?.value;
645
+ this.availability = null;
646
+ this.update({
647
+ view: 'form',
648
+ contact: effect ? { ...effect, type: 'contact' } : effect,
649
+ contactPrefill: { name: trim(fields.name), email, phone, message },
650
+ liveOffer: {
651
+ checking: false,
652
+ open: false,
653
+ reason: r.data.reason,
654
+ departments: [],
655
+ nextOpenAt: r.data.reason === 'closed' ? earliest(cached?.departments) : null,
656
+ },
657
+ });
658
+ return null;
659
+ }
660
+ void this.flush();
661
+ const s = await LiveSession.start(this.liveOptions(), r.data);
662
+ if (this.disposed) {
663
+ s.dispose();
664
+ return null;
665
+ }
666
+ this.liveSession?.dispose();
667
+ this.liveSession = s;
668
+ this.liveTried = true;
669
+ if (!this.foreground)
670
+ s.pause();
671
+ this.livePhase = null;
672
+ this.update({ view: 'live' });
673
+ this.onLive(s.getSnapshot());
674
+ return null;
675
+ }
676
+ /** מחזיר הודעת שגיאה בשפת הממשק (ואז הטקסט נשאר בשדה), או null */
677
+ liveSend(text) {
678
+ const s = this.liveSession;
679
+ if (!s)
680
+ return null;
681
+ const code = s.send(text);
682
+ if (code === 'live_message_too_long')
683
+ return this.snap.texts['live.tooLong'];
684
+ return null;
685
+ }
686
+ liveRetry(clientId) {
687
+ this.liveSession?.retry(clientId);
688
+ }
689
+ /** נקרא בכל שינוי בשדה הטקסט של הצ'אט עם הנציג */
690
+ liveTyping(text) {
691
+ this.liveSession?.typing(text);
692
+ }
693
+ liveLeaveMessage(text) {
694
+ return this.liveSession ? this.liveSession.leaveMessage(text) : Promise.resolve(false);
695
+ }
696
+ liveEnd() {
697
+ return this.liveSession ? this.liveSession.endChat() : Promise.resolve(false);
698
+ }
699
+ /** "חזרה לעוזר" אחרי שהצ'אט עם הנציג הסתיים. שיחה פתוחה לא נסגרת מכאן */
700
+ closeLive() {
701
+ const s = this.liveSession;
702
+ if (s && !s.isFinal())
703
+ return false;
704
+ s?.dispose();
705
+ this.liveSession = null;
706
+ this.livePhase = null;
707
+ this.update({ live: null, view: 'chat', contact: null, liveOffer: null });
708
+ return true;
709
+ }
710
+ /** האפליקציה ברקע: הזרם נסגר. בחזרה: השלמה לפי seq וזרם חדש. אין התראות push בגרסה הזו */
711
+ setForeground(active) {
712
+ if (this.foreground === active)
713
+ return;
714
+ this.foreground = active;
715
+ const s = this.liveSession;
716
+ if (!s)
717
+ return;
718
+ if (active)
719
+ void s.resume();
720
+ else
721
+ s.pause();
722
+ }
396
723
  dispose() {
397
724
  if (this.disposed)
398
725
  return;
399
726
  void this.flush();
727
+ this.liveSession?.dispose();
728
+ this.liveSession = null;
400
729
  this.disposed = true;
401
730
  this.listeners.clear();
402
731
  }
package/lib/index.d.ts CHANGED
@@ -5,7 +5,10 @@ export type { KavchatChatProps } from './ui/KavchatChat';
5
5
  export { KavchatLauncher } from './ui/KavchatLauncher';
6
6
  export type { KavchatLauncherProps } from './ui/KavchatLauncher';
7
7
  export { KavchatController, DEFAULT_API, BRANDING_URL } from './controller';
8
- export type { ControllerOptions, ContactFields, KavchatEvent, KavchatSnapshot, KavchatStatus, KavchatView } from './controller';
8
+ export type { ControllerOptions, ContactFields, KavchatEvent, KavchatSnapshot, KavchatStatus, KavchatView, LiveOffer, LiveRequestFields } from './controller';
9
+ export type { LiveConversation, LiveEvent, LiveState, LiveUnavailableReason } from './live/api';
10
+ export type { LiveConnection, LivePending, LivePhase, LiveSnapshot } from './live/session';
11
+ export type { StreamOpener } from './live/sse';
9
12
  export { memoryStorage, defaultStorage } from './storage';
10
13
  export type { StorageAdapter } from './storage';
11
14
  export { themeFor } from './theme';