@kavchat/react-native 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +180 -2
- package/lib/api.d.ts +87 -0
- package/lib/api.js +112 -0
- package/lib/controller.d.ts +147 -0
- package/lib/controller.js +419 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +6 -0
- package/lib/screens.d.ts +30 -0
- package/lib/screens.js +120 -0
- package/lib/storage.d.ts +35 -0
- package/lib/storage.js +70 -0
- package/lib/text.d.ts +21 -0
- package/lib/text.js +86 -0
- package/lib/theme.d.ts +21 -0
- package/lib/theme.js +53 -0
- package/lib/ui/KavchatChat.d.ts +13 -0
- package/lib/ui/KavchatChat.js +150 -0
- package/lib/ui/KavchatLauncher.d.ts +12 -0
- package/lib/ui/KavchatLauncher.js +44 -0
- package/lib/ui/KavchatProvider.d.ts +62 -0
- package/lib/ui/KavchatProvider.js +113 -0
- package/package.json +58 -4
- package/src/api.ts +167 -0
- package/src/controller.ts +512 -0
- package/src/index.ts +13 -0
- package/src/screens.ts +141 -0
- package/src/storage.ts +105 -0
- package/src/text.ts +84 -0
- package/src/theme.ts +69 -0
- package/src/ui/KavchatChat.tsx +383 -0
- package/src/ui/KavchatLauncher.tsx +81 -0
- package/src/ui/KavchatProvider.tsx +166 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
3
|
+
import { AppState, Linking } from 'react-native';
|
|
4
|
+
import { KavchatController } from '../controller';
|
|
5
|
+
import { defaultStorage } from '../storage';
|
|
6
|
+
import { deviceLocale } from '../text';
|
|
7
|
+
import { themeFor } from '../theme';
|
|
8
|
+
const KavchatContext = createContext(null);
|
|
9
|
+
export function KavchatProvider(props) {
|
|
10
|
+
const { publicKey, apiBase, locale, userToken, userName, onScreen, screens, onEvent, storage, theme, fetch, children } = props;
|
|
11
|
+
const controllerRef = useRef(null);
|
|
12
|
+
const keyRef = useRef('');
|
|
13
|
+
const key = `${publicKey}|${apiBase ?? ''}`;
|
|
14
|
+
if (!controllerRef.current || keyRef.current !== key) {
|
|
15
|
+
controllerRef.current?.dispose();
|
|
16
|
+
keyRef.current = key;
|
|
17
|
+
controllerRef.current = new KavchatController({
|
|
18
|
+
publicKey,
|
|
19
|
+
apiBase,
|
|
20
|
+
locale,
|
|
21
|
+
userToken: userToken || undefined,
|
|
22
|
+
userName: userName || undefined,
|
|
23
|
+
storage: storage ?? defaultStorage(),
|
|
24
|
+
deviceLocale: deviceLocale(),
|
|
25
|
+
screens,
|
|
26
|
+
fetch,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const controller = controllerRef.current;
|
|
30
|
+
// callbacks חדשים בכל רינדור לא בונים בקר חדש
|
|
31
|
+
controller.setCallbacks({
|
|
32
|
+
onScreen,
|
|
33
|
+
onEvent,
|
|
34
|
+
onLink: (url) => {
|
|
35
|
+
Linking.openURL(url).catch((e) => console.warn('Kavchat: cannot open link', e));
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
void controller.load();
|
|
40
|
+
// לא dispose: ב-StrictMode האפקט רץ פעמיים על אותו בקר. רק שולחים את מה שנצבר
|
|
41
|
+
return () => {
|
|
42
|
+
void controller.flush();
|
|
43
|
+
};
|
|
44
|
+
}, [controller]);
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
void controller.setUser({ token: userToken, name: userName });
|
|
47
|
+
}, [controller, userToken, userName]);
|
|
48
|
+
// מערך חדש בכל רינדור לא שולח שוב: משווים לפי התוכן
|
|
49
|
+
const screensKey = JSON.stringify(screens ?? null);
|
|
50
|
+
const firstScreens = useRef(true);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
// בבנייה הבקר כבר קיבל את המסכים
|
|
53
|
+
if (firstScreens.current) {
|
|
54
|
+
firstScreens.current = false;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
controller.registerScreens(screens);
|
|
58
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
59
|
+
}, [controller, screensKey]);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
controller.setLocale(locale);
|
|
62
|
+
}, [controller, locale]);
|
|
63
|
+
// דיווח המסלול כשהאפליקציה עוברת לרקע, כי ייתכן שלא תחזור
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const sub = AppState.addEventListener('change', (s) => {
|
|
66
|
+
if (s === 'background' || s === 'inactive')
|
|
67
|
+
void controller.flush();
|
|
68
|
+
});
|
|
69
|
+
return () => sub.remove();
|
|
70
|
+
}, [controller]);
|
|
71
|
+
const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
72
|
+
const [isOpen, setOpen] = useState(false);
|
|
73
|
+
const open = useCallback(() => {
|
|
74
|
+
controller.ensureConversation();
|
|
75
|
+
setOpen(true);
|
|
76
|
+
}, [controller]);
|
|
77
|
+
const close = useCallback(() => setOpen(false), []);
|
|
78
|
+
const toggle = useCallback(() => (isOpen ? close() : open()), [isOpen, open, close]);
|
|
79
|
+
const primary = snapshot.release?.settings.primaryColor;
|
|
80
|
+
const themeKey = JSON.stringify(theme ?? null);
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
const resolved = useMemo(() => themeFor(primary, theme), [primary, themeKey]);
|
|
83
|
+
const value = useMemo(() => ({ controller, snapshot, theme: resolved, isOpen: isOpen && snapshot.status === 'ready', open, close, toggle }), [controller, snapshot, resolved, isOpen, open, close, toggle]);
|
|
84
|
+
return _jsx(KavchatContext.Provider, { value: value, children: children });
|
|
85
|
+
}
|
|
86
|
+
export function useKavchatContext() {
|
|
87
|
+
const ctx = useContext(KavchatContext);
|
|
88
|
+
if (!ctx)
|
|
89
|
+
throw new Error('Kavchat: wrap your app with <KavchatProvider>');
|
|
90
|
+
return ctx;
|
|
91
|
+
}
|
|
92
|
+
/** ה-hook הציבורי: מצב הפתיחה, זמינות הבוט והחלפת משתמש */
|
|
93
|
+
export function useKavchat() {
|
|
94
|
+
const { controller, snapshot, isOpen, open, close, toggle } = useKavchatContext();
|
|
95
|
+
return {
|
|
96
|
+
status: snapshot.status,
|
|
97
|
+
/** false כשהבוט כבוי, מוגבל למשתמשי בדיקה או שנגמרה המכסה. אז לא מציגים כניסה לצ'אט */
|
|
98
|
+
available: snapshot.status === 'ready',
|
|
99
|
+
offline: snapshot.offline,
|
|
100
|
+
locale: snapshot.locale,
|
|
101
|
+
isOpen,
|
|
102
|
+
open,
|
|
103
|
+
close,
|
|
104
|
+
toggle,
|
|
105
|
+
setUser: (user) => controller.setUser(user),
|
|
106
|
+
startNewConversation: () => controller.startFresh(),
|
|
107
|
+
openContactForm: (prefill) => {
|
|
108
|
+
open();
|
|
109
|
+
controller.openContactForm(prefill);
|
|
110
|
+
},
|
|
111
|
+
controller,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kavchat/react-native",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Kavchat for React Native: the scripted support chat as a provider, a chat screen and a floating launcher.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"react-native": "src/index.ts",
|
|
9
|
+
"source": "src/index.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"lib",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.build.json",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@kavchat/core": "0.0.2"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@react-native-async-storage/async-storage": ">=1.17.0",
|
|
26
|
+
"react": ">=18.0.0",
|
|
27
|
+
"react-native": ">=0.72.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependenciesMeta": {
|
|
30
|
+
"@react-native-async-storage/async-storage": {
|
|
31
|
+
"optional": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/react": "^19.1.1",
|
|
36
|
+
"react": "^19.3.0",
|
|
37
|
+
"react-native": "^0.88.0",
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"vitest": "^5.0.5"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"chatbot",
|
|
43
|
+
"customer-support",
|
|
44
|
+
"expo",
|
|
45
|
+
"kavchat",
|
|
46
|
+
"react-native",
|
|
47
|
+
"rtl",
|
|
48
|
+
"scripted-bot",
|
|
49
|
+
"support-chat"
|
|
50
|
+
],
|
|
51
|
+
"homepage": "https://kavchat.com/docs",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"email": "support@kavchat.com"
|
|
54
|
+
},
|
|
55
|
+
"author": "maxsave <support@kavchat.com>",
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public",
|
|
58
|
+
"registry": "https://registry.npmjs.org/"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { ClientRelease, TranscriptEntry } from '@kavchat/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* קריאות ל-Client API, כמו בווידג'ט לאתר
|
|
5
|
+
* כלל CORS: בלי כותרות מותאמות (Cache-Control, Pragma וכו'). רק Authorization, Content-Type
|
|
6
|
+
* ו-If-None-Match, שהשרת מתיר. מניעת מטמון נעשית עם cache: 'no-store'
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type FetchLike = (url: string, init?: Record<string, unknown>) => Promise<{
|
|
10
|
+
status: number;
|
|
11
|
+
ok: boolean;
|
|
12
|
+
headers: { get(name: string): string | null };
|
|
13
|
+
json(): Promise<unknown>;
|
|
14
|
+
}>;
|
|
15
|
+
|
|
16
|
+
export interface ApiConfig {
|
|
17
|
+
apiBase: string;
|
|
18
|
+
publicKey: string;
|
|
19
|
+
getToken: () => string | undefined;
|
|
20
|
+
/** להחלפה בבדיקות. ברירת מחדל: fetch הגלובלי של React Native */
|
|
21
|
+
fetch?: FetchLike;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type DisabledRelease = { enabled: false; reason?: string };
|
|
25
|
+
export type AnyRelease = ClientRelease | DisabledRelease;
|
|
26
|
+
export type EnabledRelease = Extract<ClientRelease, { enabled: true }>;
|
|
27
|
+
|
|
28
|
+
export type ReleaseResult =
|
|
29
|
+
| { kind: 'ok'; release: AnyRelease; etag: string }
|
|
30
|
+
| { kind: 'not-modified' }
|
|
31
|
+
/** מפתח שגוי או חסום: לא מציגים את הבוט */
|
|
32
|
+
| { kind: 'denied'; status: number }
|
|
33
|
+
/** אין רשת או שהשרת נפל: משתמשים בעותק השמור */
|
|
34
|
+
| { kind: 'offline' };
|
|
35
|
+
|
|
36
|
+
export interface TicketInput {
|
|
37
|
+
message: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
email?: string;
|
|
40
|
+
phone?: string;
|
|
41
|
+
topicId?: string;
|
|
42
|
+
scriptId?: string;
|
|
43
|
+
sessionId?: string;
|
|
44
|
+
transcript?: TranscriptEntry[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TicketResult = { ok: true; id: string; number: number } | { ok: false; code: string; message: string };
|
|
48
|
+
|
|
49
|
+
const endpoint = (c: ApiConfig, path: string) =>
|
|
50
|
+
`${c.apiBase.replace(/\/+$/, '')}/v1/client/${encodeURIComponent(c.publicKey)}/${path}`;
|
|
51
|
+
|
|
52
|
+
function headers(c: ApiConfig, extra: Record<string, string> = {}): Record<string, string> {
|
|
53
|
+
const token = c.getToken();
|
|
54
|
+
return token ? { ...extra, Authorization: `Bearer ${token}` } : extra;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const doFetch = (c: ApiConfig): FetchLike => c.fetch ?? (globalThis.fetch as unknown as FetchLike);
|
|
58
|
+
|
|
59
|
+
export async function fetchRelease(c: ApiConfig, etag?: string): Promise<ReleaseResult> {
|
|
60
|
+
let res: Awaited<ReturnType<FetchLike>>;
|
|
61
|
+
try {
|
|
62
|
+
res = await doFetch(c)(endpoint(c, 'release'), {
|
|
63
|
+
method: 'GET',
|
|
64
|
+
cache: 'no-store',
|
|
65
|
+
credentials: 'omit',
|
|
66
|
+
headers: headers(c, etag ? { 'If-None-Match': etag } : {}),
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
return { kind: 'offline' };
|
|
70
|
+
}
|
|
71
|
+
if (res.status === 304) return { kind: 'not-modified' };
|
|
72
|
+
if (res.status >= 500 || res.status === 429) return { kind: 'offline' };
|
|
73
|
+
if (!res.ok) return { kind: 'denied', status: res.status };
|
|
74
|
+
const body = (await res.json().catch(() => null)) as { success?: boolean; data?: { enabled?: unknown } } | null;
|
|
75
|
+
if (!body || body.success !== true || !body.data || typeof body.data.enabled !== 'boolean') return { kind: 'offline' };
|
|
76
|
+
return { kind: 'ok', release: body.data as AnyRelease, etag: res.headers.get('ETag') || '' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function postJson(c: ApiConfig, path: string, body: unknown, signal?: unknown) {
|
|
80
|
+
return doFetch(c)(endpoint(c, path), {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
cache: 'no-store',
|
|
83
|
+
credentials: 'omit',
|
|
84
|
+
headers: headers(c, { 'Content-Type': 'application/json' }),
|
|
85
|
+
body: JSON.stringify(body),
|
|
86
|
+
...(signal ? { signal } : {}),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface AiAnswer {
|
|
91
|
+
answer: string;
|
|
92
|
+
scriptId: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const AI_TIMEOUT_MS = 20000;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* תשובת AI לשאלה שאין לה תסריט (פרימיום). כל כישלון, 429, זמן קצוב או answer: null מחזירים null,
|
|
99
|
+
* והצ'אט מציג את הודעת "לא נמצא" הרגילה
|
|
100
|
+
*/
|
|
101
|
+
export async function postAiAnswer(c: ApiConfig, sessionId: string, question: string, timeoutMs = AI_TIMEOUT_MS): Promise<AiAnswer | null> {
|
|
102
|
+
const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
103
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
104
|
+
const timeout = new Promise<null>((resolve) => {
|
|
105
|
+
timer = setTimeout(() => {
|
|
106
|
+
ctrl?.abort();
|
|
107
|
+
resolve(null);
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
});
|
|
110
|
+
const request = (async (): Promise<AiAnswer | null> => {
|
|
111
|
+
try {
|
|
112
|
+
const res = await postJson(c, 'ai/answer', { sessionId, question }, ctrl?.signal);
|
|
113
|
+
if (!res.ok) return null;
|
|
114
|
+
const body = (await res.json().catch(() => null)) as { success?: boolean; data?: { answer?: unknown; scriptId?: unknown } } | null;
|
|
115
|
+
const answer = body && body.success === true && body.data && typeof body.data.answer === 'string' ? body.data.answer.trim() : '';
|
|
116
|
+
if (!answer) return null;
|
|
117
|
+
const scriptId = typeof body!.data!.scriptId === 'string' && body!.data!.scriptId ? (body!.data!.scriptId as string) : null;
|
|
118
|
+
return { answer, scriptId };
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
try {
|
|
124
|
+
return await Promise.race([request, timeout]);
|
|
125
|
+
} finally {
|
|
126
|
+
if (timer) clearTimeout(timer);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function postEvents(c: ApiConfig, payload: unknown): Promise<boolean> {
|
|
131
|
+
try {
|
|
132
|
+
const res = await postJson(c, 'events', payload);
|
|
133
|
+
return res.ok;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function postTicket(c: ApiConfig, input: TicketInput): Promise<TicketResult> {
|
|
140
|
+
try {
|
|
141
|
+
const res = await postJson(c, 'tickets', input);
|
|
142
|
+
const body = (await res.json().catch(() => null)) as
|
|
143
|
+
| { success?: boolean; data?: { id?: unknown; number?: unknown }; code?: string; message?: string }
|
|
144
|
+
| null;
|
|
145
|
+
if (res.ok && body && body.success && body.data) return { ok: true, id: String(body.data.id), number: Number(body.data.number) };
|
|
146
|
+
return { ok: false, code: String(body?.code || `http_${res.status}`), message: String(body?.message || '') };
|
|
147
|
+
} catch {
|
|
148
|
+
return { ok: false, code: 'network', message: '' };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ScreensPayload {
|
|
153
|
+
platform: 'react-native';
|
|
154
|
+
screens: { id: string; label: string; needsTarget: boolean }[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** רישום המסכים. הצלחה רק ב-200 עם success, כדי לא לשמור טביעה על תשובה חלקית */
|
|
158
|
+
export async function postScreens(c: ApiConfig, payload: ScreensPayload): Promise<boolean> {
|
|
159
|
+
try {
|
|
160
|
+
const res = await postJson(c, 'screens', payload);
|
|
161
|
+
if (res.status !== 200) return false;
|
|
162
|
+
const body = (await res.json().catch(() => null)) as { success?: boolean } | null;
|
|
163
|
+
return !!body && body.success === true;
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|