@kavchat/react-native 0.0.0-stage → 0.0.1
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
package/lib/storage.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export function memoryStorage() {
|
|
2
|
+
const values = new Map();
|
|
3
|
+
return {
|
|
4
|
+
getItem: (k) => values.get(k) ?? null,
|
|
5
|
+
setItem: (k, v) => {
|
|
6
|
+
values.set(k, v);
|
|
7
|
+
},
|
|
8
|
+
removeItem: (k) => {
|
|
9
|
+
values.delete(k);
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function defaultStorage() {
|
|
14
|
+
try {
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
16
|
+
const mod = require('@react-native-async-storage/async-storage');
|
|
17
|
+
const store = (mod && (mod.default ?? mod));
|
|
18
|
+
if (store && typeof store.getItem === 'function' && typeof store.setItem === 'function')
|
|
19
|
+
return store;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// לא מותקן
|
|
23
|
+
}
|
|
24
|
+
return memoryStorage();
|
|
25
|
+
}
|
|
26
|
+
export const releaseKey = (pk) => `kavchat:release:${pk}`;
|
|
27
|
+
export const sessionKey = (pk) => `kavchat:session:${pk}`;
|
|
28
|
+
export async function readJson(store, key) {
|
|
29
|
+
try {
|
|
30
|
+
const raw = await store.getItem(key);
|
|
31
|
+
return raw ? JSON.parse(raw) : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function writeJson(store, key, value) {
|
|
38
|
+
try {
|
|
39
|
+
await store.setItem(key, JSON.stringify(value));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// אין מקום או אין גישה. ממשיכים בלי
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const ID_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
|
|
46
|
+
/** מזהה שיחה אקראי של 22 תווים מתוך [A-Za-z0-9_-], כפי שהשרת מצפה */
|
|
47
|
+
export function randomId(length = 22, random = Math.random) {
|
|
48
|
+
const bytes = new Uint8Array(length);
|
|
49
|
+
const c = globalThis.crypto;
|
|
50
|
+
if (random === Math.random && c && typeof c.getRandomValues === 'function')
|
|
51
|
+
c.getRandomValues(bytes);
|
|
52
|
+
else
|
|
53
|
+
for (let i = 0; i < length; i++)
|
|
54
|
+
bytes[i] = Math.floor(random() * 256);
|
|
55
|
+
let out = '';
|
|
56
|
+
for (let i = 0; i < length; i++)
|
|
57
|
+
out += ID_CHARS[bytes[i] & 63];
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** טביעה קצרה של הטוקן (FNV-1a, כמו ב-Flutter), כדי לא לשמור את הטוקן בדיסק */
|
|
61
|
+
export function userFingerprint(token) {
|
|
62
|
+
if (!token)
|
|
63
|
+
return '';
|
|
64
|
+
let h = 0x811c9dc5;
|
|
65
|
+
for (let i = 0; i < token.length; i++) {
|
|
66
|
+
h ^= token.charCodeAt(i);
|
|
67
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
68
|
+
}
|
|
69
|
+
return h.toString(16);
|
|
70
|
+
}
|
package/lib/text.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { UiTexts } from '@kavchat/core';
|
|
2
|
+
import type { TicketInput } from './api';
|
|
3
|
+
/** שפת הממשק: המבוקשת או שפת המכשיר אם יש לה תרגום, אחרת שפת הבוט */
|
|
4
|
+
export declare function pickLocale(requested: string | undefined, deviceLocale: string | undefined, releaseLocale: string): string;
|
|
5
|
+
/** שפת המכשיר בלי ספריות נוספות. Intl קיים ב-Hermes החדש. אם לא, חוזרים לשפת הבוט */
|
|
6
|
+
export declare function deviceLocale(): string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* מחזיר את הכתובת רק אם הפרוטוקול מותר
|
|
9
|
+
* לא משתמשים ב-URL כי ב-React Native המימוש שלו חלקי (protocol לא נתמך בגרסאות ישנות)
|
|
10
|
+
*/
|
|
11
|
+
export declare function safeUrl(raw: string, schemes: Array<'https' | 'tel'>): string | null;
|
|
12
|
+
export type TextPart = {
|
|
13
|
+
text: string;
|
|
14
|
+
href?: string;
|
|
15
|
+
};
|
|
16
|
+
/** טקסט של הבוט מפוצל לקטעים. רק https הופך לקישור, וכל השאר נשאר טקסט */
|
|
17
|
+
export declare function splitLinks(text: string): TextPart[];
|
|
18
|
+
/** אותה בדיקה כמו בווידג'ט: הודעה חובה, ואורח חייב להשאיר מייל או טלפון */
|
|
19
|
+
export declare function validateTicket(input: TicketInput, identified: boolean, t: UiTexts): string;
|
|
20
|
+
/** שגיאה מהשרת: קודים מוכרים בשפת הממשק, אחרת ההודעה של השרת, אחרת הודעה כללית */
|
|
21
|
+
export declare function ticketError(code: string, message: string, t: UiTexts): string;
|
package/lib/text.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { UI_TEXTS } from '@kavchat/core';
|
|
2
|
+
/**
|
|
3
|
+
* עזרי טקסט בלי תלות ב-React Native: שפה, קישורים, בדיקת טופס והודעות שגיאה
|
|
4
|
+
*/
|
|
5
|
+
const baseLocale = (l) => String(l || '').toLowerCase().split(/[-_]/)[0].replace(/^iw$/, 'he');
|
|
6
|
+
/** שפת הממשק: המבוקשת או שפת המכשיר אם יש לה תרגום, אחרת שפת הבוט */
|
|
7
|
+
export function pickLocale(requested, deviceLocale, releaseLocale) {
|
|
8
|
+
const wanted = baseLocale(requested || deviceLocale || '');
|
|
9
|
+
if (UI_TEXTS[wanted])
|
|
10
|
+
return wanted;
|
|
11
|
+
const fallback = baseLocale(releaseLocale);
|
|
12
|
+
return UI_TEXTS[fallback] ? fallback : 'en';
|
|
13
|
+
}
|
|
14
|
+
/** שפת המכשיר בלי ספריות נוספות. Intl קיים ב-Hermes החדש. אם לא, חוזרים לשפת הבוט */
|
|
15
|
+
export function deviceLocale() {
|
|
16
|
+
try {
|
|
17
|
+
return Intl.DateTimeFormat().resolvedOptions().locale || undefined;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* מחזיר את הכתובת רק אם הפרוטוקול מותר
|
|
25
|
+
* לא משתמשים ב-URL כי ב-React Native המימוש שלו חלקי (protocol לא נתמך בגרסאות ישנות)
|
|
26
|
+
*/
|
|
27
|
+
export function safeUrl(raw, schemes) {
|
|
28
|
+
const url = String(raw || '').trim();
|
|
29
|
+
if (!url || /\s/.test(url))
|
|
30
|
+
return null;
|
|
31
|
+
const m = /^([a-z][a-z0-9+.-]*):(.*)$/i.exec(url);
|
|
32
|
+
if (!m)
|
|
33
|
+
return null;
|
|
34
|
+
const scheme = m[1].toLowerCase();
|
|
35
|
+
if (!schemes.includes(scheme))
|
|
36
|
+
return null;
|
|
37
|
+
if (scheme === 'https' && !/^\/\/[^/?#\s]+/.test(m[2]))
|
|
38
|
+
return null;
|
|
39
|
+
if (scheme === 'tel' && !/^[0-9+*#(),;.\-]+$/.test(m[2]))
|
|
40
|
+
return null;
|
|
41
|
+
return `${scheme}:${m[2]}`;
|
|
42
|
+
}
|
|
43
|
+
const URL_RE = /https:\/\/[^\s<>"'`]+/g;
|
|
44
|
+
const TRAILING = /[.,;:!?)\]}'"»]+$/;
|
|
45
|
+
/** טקסט של הבוט מפוצל לקטעים. רק https הופך לקישור, וכל השאר נשאר טקסט */
|
|
46
|
+
export function splitLinks(text) {
|
|
47
|
+
const parts = [];
|
|
48
|
+
let last = 0;
|
|
49
|
+
const re = new RegExp(URL_RE.source, 'g');
|
|
50
|
+
let m;
|
|
51
|
+
while ((m = re.exec(text))) {
|
|
52
|
+
const raw = m[0].replace(TRAILING, '');
|
|
53
|
+
const href = raw ? safeUrl(raw, ['https']) : null;
|
|
54
|
+
if (!href)
|
|
55
|
+
continue;
|
|
56
|
+
if (m.index > last)
|
|
57
|
+
parts.push({ text: text.slice(last, m.index) });
|
|
58
|
+
parts.push({ text: raw, href });
|
|
59
|
+
last = m.index + raw.length;
|
|
60
|
+
re.lastIndex = last;
|
|
61
|
+
}
|
|
62
|
+
if (last < text.length)
|
|
63
|
+
parts.push({ text: text.slice(last) });
|
|
64
|
+
return parts;
|
|
65
|
+
}
|
|
66
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
67
|
+
/** אותה בדיקה כמו בווידג'ט: הודעה חובה, ואורח חייב להשאיר מייל או טלפון */
|
|
68
|
+
export function validateTicket(input, identified, t) {
|
|
69
|
+
if (!input.message)
|
|
70
|
+
return t['form.required'];
|
|
71
|
+
if (!input.email && !input.phone && !identified)
|
|
72
|
+
return t['form.contactRequired'];
|
|
73
|
+
if (input.email && !EMAIL_RE.test(input.email))
|
|
74
|
+
return t['form.emailInvalid'];
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
/** שגיאה מהשרת: קודים מוכרים בשפת הממשק, אחרת ההודעה של השרת, אחרת הודעה כללית */
|
|
78
|
+
export function ticketError(code, message, t) {
|
|
79
|
+
if (code === 'ticket_contact_required')
|
|
80
|
+
return t['form.contactRequired'];
|
|
81
|
+
if (code === 'email_invalid')
|
|
82
|
+
return t['form.emailInvalid'];
|
|
83
|
+
if (code === 'network')
|
|
84
|
+
return t['form.error'];
|
|
85
|
+
return message || t['form.error'];
|
|
86
|
+
}
|
package/lib/theme.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* צבעים מתוך primaryColor של הלקוח, כמו בווידג'ט לאתר
|
|
3
|
+
* הטקסט על הצבע הראשי נבחר לפי יחס ניגודיות (WCAG), כדי שגם צבע בהיר יישאר קריא
|
|
4
|
+
*/
|
|
5
|
+
export declare const DEFAULT_PRIMARY = "#2563eb";
|
|
6
|
+
export declare function parseHex(color: string): [number, number, number] | null;
|
|
7
|
+
export declare function contrast(a: string, b: string): number;
|
|
8
|
+
export interface KavchatTheme {
|
|
9
|
+
primary: string;
|
|
10
|
+
/** טקסט על רקע הצבע הראשי */
|
|
11
|
+
onPrimary: string;
|
|
12
|
+
/** טקסט וקווי מתאר בצבע הראשי על רקע לבן. צבע בהיר מדי מוחלף בכהה */
|
|
13
|
+
accent: string;
|
|
14
|
+
background: string;
|
|
15
|
+
botBubble: string;
|
|
16
|
+
userBubble: string;
|
|
17
|
+
text: string;
|
|
18
|
+
muted: string;
|
|
19
|
+
error: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function themeFor(primaryColor: string | undefined, override?: Partial<KavchatTheme>): KavchatTheme;
|
package/lib/theme.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* צבעים מתוך primaryColor של הלקוח, כמו בווידג'ט לאתר
|
|
3
|
+
* הטקסט על הצבע הראשי נבחר לפי יחס ניגודיות (WCAG), כדי שגם צבע בהיר יישאר קריא
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_PRIMARY = '#2563eb';
|
|
6
|
+
const DARK = '#111827';
|
|
7
|
+
const WHITE = '#ffffff';
|
|
8
|
+
export function parseHex(color) {
|
|
9
|
+
const m = /^#([0-9a-f]{6})$/i.exec(String(color || '').trim());
|
|
10
|
+
if (!m)
|
|
11
|
+
return null;
|
|
12
|
+
const n = parseInt(m[1], 16);
|
|
13
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
14
|
+
}
|
|
15
|
+
function luminance(rgb) {
|
|
16
|
+
const [r, g, b] = rgb.map((v) => {
|
|
17
|
+
const c = v / 255;
|
|
18
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
19
|
+
});
|
|
20
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
21
|
+
}
|
|
22
|
+
export function contrast(a, b) {
|
|
23
|
+
const la = luminance(parseHex(a) ?? [0, 0, 0]);
|
|
24
|
+
const lb = luminance(parseHex(b) ?? [0, 0, 0]);
|
|
25
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
26
|
+
}
|
|
27
|
+
export function themeFor(primaryColor, override) {
|
|
28
|
+
const color = override?.primary ?? primaryColor ?? '';
|
|
29
|
+
const primary = parseHex(color) ? color.toLowerCase() : DEFAULT_PRIMARY;
|
|
30
|
+
const onPrimary = contrast(primary, WHITE) >= contrast(primary, DARK) ? WHITE : DARK;
|
|
31
|
+
const accent = contrast(primary, WHITE) >= 3 ? primary : DARK;
|
|
32
|
+
return {
|
|
33
|
+
primary,
|
|
34
|
+
onPrimary,
|
|
35
|
+
accent,
|
|
36
|
+
background: '#f3f4f6',
|
|
37
|
+
botBubble: '#ffffff',
|
|
38
|
+
userBubble: '#e0e7ff',
|
|
39
|
+
text: DARK,
|
|
40
|
+
muted: '#6b7280',
|
|
41
|
+
error: '#b91c1c',
|
|
42
|
+
...stripUndefined(override),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function stripUndefined(o) {
|
|
46
|
+
const out = {};
|
|
47
|
+
if (!o)
|
|
48
|
+
return out;
|
|
49
|
+
for (const [k, v] of Object.entries(o))
|
|
50
|
+
if (v !== undefined)
|
|
51
|
+
out[k] = v;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
/**
|
|
3
|
+
* מסך הצ'אט. כיוון הפריסה נקבע לפי I18nManager של האפליקציה המארחת, ולא כופים RTL.
|
|
4
|
+
* טקסט עם textAlign ברירת מחדל מיושר לפי השפה שלו, כך שעברית וערבית נקראות נכון גם באפליקציה LTR
|
|
5
|
+
*/
|
|
6
|
+
export interface KavchatChatProps {
|
|
7
|
+
/** כפתור סגירה בכותרת. בלי זה אין כפתור */
|
|
8
|
+
onClose?: () => void;
|
|
9
|
+
/** בלי כותרת, כשהאפליקציה מציגה כותרת משלה בניווט */
|
|
10
|
+
hideHeader?: boolean;
|
|
11
|
+
style?: StyleProp<ViewStyle>;
|
|
12
|
+
}
|
|
13
|
+
export declare function KavchatChat({ onClose, hideHeader, style }: KavchatChatProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { ActivityIndicator, Image, KeyboardAvoidingView, Linking, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native';
|
|
4
|
+
import { format } from '@kavchat/core';
|
|
5
|
+
import { BRANDING_URL } from '../controller';
|
|
6
|
+
import { safeUrl, splitLinks } from '../text';
|
|
7
|
+
import { useKavchatContext } from './KavchatProvider';
|
|
8
|
+
export function KavchatChat({ onClose, hideHeader, style }) {
|
|
9
|
+
const { controller, snapshot, theme } = useKavchatContext();
|
|
10
|
+
const t = snapshot.texts;
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
controller.ensureConversation();
|
|
13
|
+
}, [controller, snapshot.status]);
|
|
14
|
+
if (snapshot.status === 'loading') {
|
|
15
|
+
return (_jsx(View, { style: [styles.center, { backgroundColor: theme.background }, style], accessibilityLabel: t['widget.loading'], children: _jsx(ActivityIndicator, { color: theme.accent }) }));
|
|
16
|
+
}
|
|
17
|
+
if (snapshot.status === 'unavailable' || !snapshot.release) {
|
|
18
|
+
return (_jsxs(View, { style: [styles.center, { backgroundColor: theme.background }, style], children: [_jsx(Text, { style: [styles.body, { color: theme.text }], children: t['widget.unavailable'] }), onClose ? _jsx(ChipButton, { label: t['widget.close'], theme: theme, onPress: onClose }) : null] }));
|
|
19
|
+
}
|
|
20
|
+
const s = snapshot.release.settings;
|
|
21
|
+
return (_jsxs(KeyboardAvoidingView, { style: [styles.flex, { backgroundColor: theme.background }, style], behavior: Platform.OS === 'ios' ? 'padding' : undefined, children: [hideHeader ? null : (_jsxs(View, { style: [styles.header, { backgroundColor: theme.primary }], children: [_jsx(Avatar, { text: s.avatarText || s.botName || '', imageUrl: s.avatarImageUrl, theme: theme }), _jsxs(View, { style: styles.titles, children: [_jsx(Text, { style: [styles.title, { color: theme.onPrimary }], numberOfLines: 1, accessibilityRole: "header", children: s.botName }), s.subtitle ? (_jsx(Text, { style: [styles.subtitle, { color: theme.onPrimary }], numberOfLines: 1, children: s.subtitle })) : null] }), onClose ? (_jsx(Pressable, { onPress: onClose, hitSlop: 12, accessibilityRole: "button", accessibilityLabel: t['widget.close'], style: styles.close, children: _jsx(Text, { style: [styles.closeText, { color: theme.onPrimary }], children: '×' }) })) : null] })), snapshot.offline ? (_jsx(View, { style: styles.banner, accessibilityRole: "alert", children: _jsx(Text, { style: styles.bannerText, children: t['widget.offline'] }) })) : null, snapshot.view === 'chat' ? _jsx(ChatView, {}) : snapshot.view === 'form' ? _jsx(ContactForm, {}) : _jsx(SentView, {}), s.removeBranding ? null : (
|
|
22
|
+
// בתוכנית החינמית הסמל חובה
|
|
23
|
+
_jsx(Pressable, { onPress: () => Linking.openURL(BRANDING_URL).catch(() => undefined), accessibilityRole: "link", style: styles.badge, children: _jsx(Text, { style: [styles.badgeText, { color: theme.muted }], children: t['widget.poweredBy'] }) }))] }));
|
|
24
|
+
}
|
|
25
|
+
function ChatView() {
|
|
26
|
+
const { controller, snapshot, theme } = useKavchatContext();
|
|
27
|
+
const t = snapshot.texts;
|
|
28
|
+
const [text, setText] = useState('');
|
|
29
|
+
const scroll = useRef(null);
|
|
30
|
+
const conv = snapshot.conversation;
|
|
31
|
+
const typing = snapshot.typing;
|
|
32
|
+
const send = () => {
|
|
33
|
+
if (typing)
|
|
34
|
+
return;
|
|
35
|
+
const value = text;
|
|
36
|
+
setText('');
|
|
37
|
+
void controller.sendText(value);
|
|
38
|
+
};
|
|
39
|
+
return (_jsxs(View, { style: styles.flex, children: [_jsxs(ScrollView, { ref: scroll, style: styles.flex, contentContainerStyle: styles.list, onContentSizeChange: () => scroll.current?.scrollToEnd({ animated: true }), keyboardShouldPersistTaps: "handled", children: [(conv?.messages ?? []).map((m) => (_jsx(Bubble, { message: m, theme: theme, aiLabel: t['chat.aiLabel'] }, m.id))), typing ? (_jsx(View, { style: [styles.bubble, styles.botBubble, { backgroundColor: theme.botBubble }], accessibilityLabel: t['widget.loading'], accessibilityLiveRegion: "polite", children: _jsx(ActivityIndicator, { size: "small", color: theme.accent }) })) : null, conv && conv.chips.length > 0 ? (_jsx(View, { style: styles.chips, accessibilityRole: "menu", children: conv.chips.map((chip) => (_jsx(ChipButton, { label: chip.label, theme: theme, fixed: isFixed(chip), onPress: () => controller.pressChip(chip) }, chip.id))) })) : null] }), _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { value: text, onChangeText: setText, onSubmitEditing: send, placeholder: t['widget.placeholder'], accessibilityLabel: t['widget.placeholder'], placeholderTextColor: theme.muted, maxLength: 500, returnKeyType: "send", blurOnSubmit: false, style: [styles.input, { color: theme.text }] }), _jsx(Pressable, { onPress: send, disabled: !text.trim() || typing, accessibilityRole: "button", accessibilityLabel: t['widget.send'], style: [styles.send, { backgroundColor: theme.primary }, (!text.trim() || typing) && styles.disabled], children: _jsx(SendIcon, { color: theme.onPrimary }) })] })] }));
|
|
40
|
+
}
|
|
41
|
+
const isFixed = (chip) => chip.kind === 'back' || chip.kind === 'home';
|
|
42
|
+
function Bubble({ message, theme, aiLabel }) {
|
|
43
|
+
const bot = message.from === 'bot';
|
|
44
|
+
const img = bot && message.imageUrl ? safeUrl(message.imageUrl, ['https']) : null;
|
|
45
|
+
const ai = bot && message.ai === true;
|
|
46
|
+
return (_jsxs(View, { style: [styles.bubble, bot ? [styles.botBubble, { backgroundColor: theme.botBubble }] : [styles.userBubble, { backgroundColor: theme.userBubble }]], children: [img ? _jsx(Image, { source: { uri: img }, style: styles.image, resizeMode: "cover", accessibilityIgnoresInvertColors: true }) : null, ai ? (
|
|
47
|
+
// תווית קטנה: המשתמש צריך לדעת שהתשובה נוצרה ב-AI ולא נכתבה בתסריט
|
|
48
|
+
_jsx(Text, { style: [styles.aiLabel, { color: theme.muted, borderColor: theme.muted }], children: aiLabel })) : null, _jsx(Text, { style: [styles.body, { color: theme.text }], selectable: true, children: ai
|
|
49
|
+
? // תשובת AI היא טקסט פשוט בלבד, בלי קישורים ובלי עיבוד
|
|
50
|
+
message.text
|
|
51
|
+
: bot
|
|
52
|
+
? splitLinks(message.text).map((p, i) => p.href ? (_jsx(Text, { style: [styles.link, { color: theme.accent }], accessibilityRole: "link", onPress: () => Linking.openURL(p.href).catch(() => undefined), children: p.text }, i)) : (p.text))
|
|
53
|
+
: // הודעות המשתמש לא הופכות לקישורים
|
|
54
|
+
message.text })] }));
|
|
55
|
+
}
|
|
56
|
+
function ChipButton({ label, theme, fixed, onPress }) {
|
|
57
|
+
return (_jsx(Pressable, { onPress: onPress, accessibilityRole: "button", style: ({ pressed }) => [styles.chip, { borderColor: theme.accent }, fixed && styles.chipFixed, pressed && { backgroundColor: theme.userBubble }], children: _jsx(Text, { style: [styles.chipText, { color: theme.accent }, fixed && styles.chipTextFixed], children: label }) }));
|
|
58
|
+
}
|
|
59
|
+
function ContactForm() {
|
|
60
|
+
const { controller, snapshot, theme } = useKavchatContext();
|
|
61
|
+
const t = snapshot.texts;
|
|
62
|
+
const effect = snapshot.contact;
|
|
63
|
+
const topics = snapshot.release?.settings.contactTopics ?? [];
|
|
64
|
+
const initialTopic = effect?.contactTopicId && topics.some((x) => x.id === effect.contactTopicId) ? effect.contactTopicId : '';
|
|
65
|
+
const [name, setName] = useState(snapshot.userName ?? '');
|
|
66
|
+
const [email, setEmail] = useState('');
|
|
67
|
+
const [phone, setPhone] = useState('');
|
|
68
|
+
const [topicId, setTopicId] = useState(initialTopic);
|
|
69
|
+
const [message, setMessage] = useState(effect?.prefill ?? '');
|
|
70
|
+
const [error, setError] = useState('');
|
|
71
|
+
const [sending, setSending] = useState(false);
|
|
72
|
+
const submit = async () => {
|
|
73
|
+
if (sending)
|
|
74
|
+
return;
|
|
75
|
+
setSending(true);
|
|
76
|
+
setError('');
|
|
77
|
+
const problem = await controller.submitTicket({ name, email, phone, topicId, message });
|
|
78
|
+
setSending(false);
|
|
79
|
+
if (problem)
|
|
80
|
+
setError(problem);
|
|
81
|
+
};
|
|
82
|
+
const inputStyle = [styles.field, { color: theme.text }];
|
|
83
|
+
return (_jsxs(ScrollView, { style: styles.flex, contentContainerStyle: styles.form, keyboardShouldPersistTaps: "handled", children: [_jsx(Text, { style: [styles.formTitle, { color: theme.text }], accessibilityRole: "header", children: t['form.title'] }), _jsx(Label, { text: t['form.name'], theme: theme }), _jsx(TextInput, { value: name, onChangeText: setName, maxLength: 100, autoComplete: "name", textContentType: "name", style: inputStyle, accessibilityLabel: t['form.name'] }), _jsx(Label, { text: t['form.email'], theme: theme }), _jsx(TextInput, { value: email, onChangeText: setEmail, keyboardType: "email-address", autoCapitalize: "none", autoComplete: "email", textContentType: "emailAddress", style: inputStyle, accessibilityLabel: t['form.email'] }), _jsx(Label, { text: t['form.phone'], theme: theme }), _jsx(TextInput, { value: phone, onChangeText: setPhone, keyboardType: "phone-pad", autoComplete: "tel", textContentType: "telephoneNumber", style: inputStyle, accessibilityLabel: t['form.phone'] }), topics.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Label, { text: t['form.topic'], theme: theme }), _jsx(View, { style: styles.chips, accessibilityRole: "radiogroup", children: topics.map((tp) => {
|
|
84
|
+
const on = tp.id === topicId;
|
|
85
|
+
return (_jsx(Pressable, { onPress: () => setTopicId(on ? '' : tp.id), accessibilityRole: "radio", accessibilityState: { checked: on }, style: [styles.chip, { borderColor: theme.accent }, on && { backgroundColor: theme.primary, borderColor: theme.primary }], children: _jsx(Text, { style: [styles.chipText, { color: on ? theme.onPrimary : theme.accent }], children: tp.name }) }, tp.id));
|
|
86
|
+
}) })] })) : null, _jsx(Label, { text: t['form.message'], theme: theme }), _jsx(TextInput, { value: message, onChangeText: setMessage, multiline: true, maxLength: 2000, style: [inputStyle, styles.message], accessibilityLabel: t['form.message'] }), error ? (_jsx(Text, { style: [styles.error, { color: theme.error }], accessibilityRole: "alert", accessibilityLiveRegion: "polite", children: error })) : null, _jsxs(View, { style: styles.actions, children: [_jsx(Pressable, { onPress: submit, disabled: sending, accessibilityRole: "button", accessibilityState: { disabled: sending, busy: sending }, style: [styles.btn, { backgroundColor: theme.primary }, sending && styles.disabled], children: _jsx(Text, { style: [styles.btnText, { color: theme.onPrimary }], children: sending ? t['form.sending'] : t['form.submit'] }) }), _jsx(Pressable, { onPress: () => controller.backToChat(), accessibilityRole: "button", style: [styles.btn, styles.btnGhost, { borderColor: theme.accent }], children: _jsx(Text, { style: [styles.btnText, { color: theme.accent }], children: t['form.cancel'] }) })] })] }));
|
|
87
|
+
}
|
|
88
|
+
function SentView() {
|
|
89
|
+
const { controller, snapshot, theme } = useKavchatContext();
|
|
90
|
+
const t = snapshot.texts;
|
|
91
|
+
return (_jsxs(View, { style: styles.center, children: [_jsx(Text, { style: [styles.body, styles.sent, { color: theme.text }], accessibilityLiveRegion: "polite", children: format(t['form.sent'], { number: snapshot.ticketNumber ?? '' }) }), _jsx(Pressable, { onPress: () => controller.backToChat(), accessibilityRole: "button", style: [styles.btn, { backgroundColor: theme.primary }], children: _jsx(Text, { style: [styles.btnText, { color: theme.onPrimary }], children: t['form.backToChat'] }) })] }));
|
|
92
|
+
}
|
|
93
|
+
function Label({ text, theme }) {
|
|
94
|
+
return _jsx(Text, { style: [styles.label, { color: theme.muted }], children: text });
|
|
95
|
+
}
|
|
96
|
+
function Avatar({ text, imageUrl, theme }) {
|
|
97
|
+
const img = imageUrl ? safeUrl(imageUrl, ['https']) : null;
|
|
98
|
+
return (_jsx(View, { style: [styles.avatar, { backgroundColor: theme.onPrimary }], importantForAccessibility: "no-hide-descendants", accessibilityElementsHidden: true, children: img ? _jsx(Image, { source: { uri: img }, style: styles.avatarImage }) : _jsx(Text, { style: [styles.avatarText, { color: theme.primary }], children: text.slice(0, 2) }) }));
|
|
99
|
+
}
|
|
100
|
+
/** אייקון שליחה בלי react-native-svg: משולש מגבולות. בכיוון RTL הוא מתהפך יחד עם הפריסה */
|
|
101
|
+
function SendIcon({ color }) {
|
|
102
|
+
return _jsx(View, { style: [styles.sendIcon, { borderStartColor: color }] });
|
|
103
|
+
}
|
|
104
|
+
const styles = StyleSheet.create({
|
|
105
|
+
flex: { flex: 1 },
|
|
106
|
+
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, gap: 16 },
|
|
107
|
+
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, gap: 10 },
|
|
108
|
+
titles: { flex: 1 },
|
|
109
|
+
title: { fontSize: 16, fontWeight: '600' },
|
|
110
|
+
subtitle: { fontSize: 12, opacity: 0.85 },
|
|
111
|
+
close: { padding: 4 },
|
|
112
|
+
closeText: { fontSize: 26, lineHeight: 28 },
|
|
113
|
+
avatar: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
|
114
|
+
avatarImage: { width: 36, height: 36 },
|
|
115
|
+
avatarText: { fontSize: 13, fontWeight: '700' },
|
|
116
|
+
banner: { backgroundColor: '#fef3c7', paddingHorizontal: 12, paddingVertical: 6 },
|
|
117
|
+
bannerText: { color: '#92400e', fontSize: 13 },
|
|
118
|
+
list: { padding: 12, gap: 6 },
|
|
119
|
+
bubble: { maxWidth: '85%', borderRadius: 14, paddingHorizontal: 12, paddingVertical: 8 },
|
|
120
|
+
// הבוט בצד ההתחלה והמשתמש בצד הסוף. flex-start מתהפך לבד ב-RTL
|
|
121
|
+
botBubble: { alignSelf: 'flex-start', borderWidth: StyleSheet.hairlineWidth, borderColor: '#e5e7eb' },
|
|
122
|
+
userBubble: { alignSelf: 'flex-end' },
|
|
123
|
+
body: { fontSize: 15, lineHeight: 21 },
|
|
124
|
+
link: { textDecorationLine: 'underline' },
|
|
125
|
+
aiLabel: { alignSelf: 'flex-start', fontSize: 11, fontWeight: '600', borderWidth: StyleSheet.hairlineWidth, borderRadius: 999, paddingHorizontal: 6, paddingVertical: 1, marginBottom: 4 },
|
|
126
|
+
image: { width: 220, height: 140, borderRadius: 8, marginBottom: 6 },
|
|
127
|
+
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 6 },
|
|
128
|
+
chip: { borderWidth: 1.5, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: '#ffffff' },
|
|
129
|
+
chipFixed: { borderStyle: 'dashed' },
|
|
130
|
+
chipText: { fontSize: 14, fontWeight: '600' },
|
|
131
|
+
chipTextFixed: { fontWeight: '400' },
|
|
132
|
+
composer: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 8, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#e5e7eb', backgroundColor: '#ffffff' },
|
|
133
|
+
input: { flex: 1, borderRadius: 20, backgroundColor: '#f3f4f6', paddingHorizontal: 14, paddingVertical: Platform.OS === 'ios' ? 10 : 6, fontSize: 15 },
|
|
134
|
+
send: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
|
|
135
|
+
sendIcon: { width: 0, height: 0, borderTopWidth: 8, borderBottomWidth: 8, borderStartWidth: 14, borderTopColor: 'transparent', borderBottomColor: 'transparent', marginStart: 4 },
|
|
136
|
+
disabled: { opacity: 0.5 },
|
|
137
|
+
form: { padding: 16, gap: 6 },
|
|
138
|
+
formTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
|
|
139
|
+
label: { fontSize: 13, marginTop: 6 },
|
|
140
|
+
field: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, paddingHorizontal: 10, paddingVertical: Platform.OS === 'ios' ? 10 : 6, fontSize: 15, backgroundColor: '#ffffff' },
|
|
141
|
+
message: { minHeight: 110, textAlignVertical: 'top' },
|
|
142
|
+
error: { fontSize: 14, marginTop: 6 },
|
|
143
|
+
actions: { flexDirection: 'row', gap: 10, marginTop: 12 },
|
|
144
|
+
btn: { borderRadius: 8, paddingHorizontal: 18, paddingVertical: 10, alignItems: 'center' },
|
|
145
|
+
btnGhost: { borderWidth: 1, backgroundColor: 'transparent' },
|
|
146
|
+
btnText: { fontSize: 15, fontWeight: '600' },
|
|
147
|
+
sent: { textAlign: 'center' },
|
|
148
|
+
badge: { alignItems: 'center', paddingVertical: 6, backgroundColor: '#ffffff' },
|
|
149
|
+
badgeText: { fontSize: 11 },
|
|
150
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
/**
|
|
3
|
+
* כפתור צף שפותח את הצ'אט בחלון מלא
|
|
4
|
+
* הכפתור לא מוצג כשהבוט לא זמין (כבוי, testers, מכסה), כמו בווידג'ט לאתר
|
|
5
|
+
*/
|
|
6
|
+
export interface KavchatLauncherProps {
|
|
7
|
+
/** מיקום הכפתור. ברירת מחדל: פינה תחתונה בצד הסוף. end מתהפך לבד ב-RTL */
|
|
8
|
+
style?: StyleProp<ViewStyle>;
|
|
9
|
+
/** בלי החלון המובנה, למשל כשהאפליקציה פותחת את KavchatChat במסך ניווט משלה */
|
|
10
|
+
onPress?: () => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function KavchatLauncher({ style, onPress }: KavchatLauncherProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Modal, Pressable, SafeAreaView, StyleSheet, View } from 'react-native';
|
|
3
|
+
import { KavchatChat } from './KavchatChat';
|
|
4
|
+
import { useKavchatContext } from './KavchatProvider';
|
|
5
|
+
export function KavchatLauncher({ style, onPress }) {
|
|
6
|
+
const { snapshot, theme, isOpen, open, close } = useKavchatContext();
|
|
7
|
+
if (snapshot.status !== 'ready')
|
|
8
|
+
return null;
|
|
9
|
+
const t = snapshot.texts;
|
|
10
|
+
return (_jsxs(_Fragment, { children: [_jsx(Pressable, { onPress: onPress ?? open, accessibilityRole: "button", accessibilityLabel: t['widget.open'], style: ({ pressed }) => [styles.fab, { backgroundColor: theme.primary }, pressed && styles.pressed, style], children: _jsx(ChatIcon, { color: theme.onPrimary }) }), onPress ? null : (_jsx(Modal, { visible: isOpen, animationType: "slide", presentationStyle: "pageSheet", onRequestClose: close, children: _jsx(SafeAreaView, { style: [styles.flex, { backgroundColor: theme.primary }], children: _jsx(KavchatChat, { onClose: close }) }) }))] }));
|
|
11
|
+
}
|
|
12
|
+
/** בועת צ'אט מ-View בלבד, כדי לא לחייב את האפליקציה בספריית אייקונים */
|
|
13
|
+
function ChatIcon({ color }) {
|
|
14
|
+
return (_jsxs(View, { style: styles.icon, children: [_jsx(View, { style: [styles.iconBody, { backgroundColor: color }] }), _jsx(View, { style: [styles.iconTail, { borderTopColor: color }] })] }));
|
|
15
|
+
}
|
|
16
|
+
const styles = StyleSheet.create({
|
|
17
|
+
flex: { flex: 1 },
|
|
18
|
+
fab: {
|
|
19
|
+
position: 'absolute',
|
|
20
|
+
bottom: 24,
|
|
21
|
+
end: 20,
|
|
22
|
+
width: 56,
|
|
23
|
+
height: 56,
|
|
24
|
+
borderRadius: 28,
|
|
25
|
+
alignItems: 'center',
|
|
26
|
+
justifyContent: 'center',
|
|
27
|
+
elevation: 6,
|
|
28
|
+
shadowColor: '#000',
|
|
29
|
+
shadowOpacity: 0.2,
|
|
30
|
+
shadowRadius: 6,
|
|
31
|
+
shadowOffset: { width: 0, height: 3 },
|
|
32
|
+
},
|
|
33
|
+
pressed: { opacity: 0.85 },
|
|
34
|
+
icon: { width: 24, height: 24 },
|
|
35
|
+
iconBody: { width: 24, height: 18, borderRadius: 5 },
|
|
36
|
+
iconTail: {
|
|
37
|
+
width: 0,
|
|
38
|
+
height: 0,
|
|
39
|
+
marginStart: 4,
|
|
40
|
+
borderTopWidth: 6,
|
|
41
|
+
borderEndWidth: 6,
|
|
42
|
+
borderEndColor: 'transparent',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { FetchLike } from '../api';
|
|
3
|
+
import { KavchatController, KavchatEvent, KavchatSnapshot } from '../controller';
|
|
4
|
+
import type { KavchatScreen } from '../screens';
|
|
5
|
+
import { StorageAdapter } from '../storage';
|
|
6
|
+
import { KavchatTheme } from '../theme';
|
|
7
|
+
/**
|
|
8
|
+
* ה-Provider מחזיק בקר אחד לכל האפליקציה, כדי שהשיחה תישמר גם כשמסך הצ'אט נסגר ונפתח
|
|
9
|
+
*/
|
|
10
|
+
export interface KavchatProviderProps {
|
|
11
|
+
/** המפתח הציבורי pk_live_... */
|
|
12
|
+
publicKey: string;
|
|
13
|
+
/** ברירת מחדל https://api.kavchat.com */
|
|
14
|
+
apiBase?: string;
|
|
15
|
+
/** שפת הממשק. ברירת מחדל: שפת המכשיר, ואם אין לה תרגום, שפת הבוט */
|
|
16
|
+
locale?: string;
|
|
17
|
+
/** JWT לזיהוי המשתמש, נחתם בשרת של הלקוח עם המפתח הסודי. לעולם לא המפתח הסודי עצמו */
|
|
18
|
+
userToken?: string | null;
|
|
19
|
+
userName?: string | null;
|
|
20
|
+
/** כפתור מסוג screen בתסריט: ניווט למסך באפליקציה */
|
|
21
|
+
onScreen?: (screen: string, targetId?: string) => void;
|
|
22
|
+
/** מסכי האפליקציה. נרשמים בשרת, והעורך בלוח הניהול מציע אותם ברשימה */
|
|
23
|
+
screens?: readonly KavchatScreen[];
|
|
24
|
+
onEvent?: (event: KavchatEvent) => void;
|
|
25
|
+
/** ברירת מחדל: AsyncStorage אם מותקן, אחרת זיכרון בלבד */
|
|
26
|
+
storage?: StorageAdapter;
|
|
27
|
+
/** עקיפה של הצבעים שמגיעים מההגדרות בלוח הניהול */
|
|
28
|
+
theme?: Partial<KavchatTheme>;
|
|
29
|
+
/** להחלפה בבדיקות */
|
|
30
|
+
fetch?: FetchLike;
|
|
31
|
+
children?: React.ReactNode;
|
|
32
|
+
}
|
|
33
|
+
export interface KavchatContextValue {
|
|
34
|
+
controller: KavchatController;
|
|
35
|
+
snapshot: KavchatSnapshot;
|
|
36
|
+
theme: KavchatTheme;
|
|
37
|
+
isOpen: boolean;
|
|
38
|
+
open: () => void;
|
|
39
|
+
close: () => void;
|
|
40
|
+
toggle: () => void;
|
|
41
|
+
}
|
|
42
|
+
export declare function KavchatProvider(props: KavchatProviderProps): React.JSX.Element;
|
|
43
|
+
export declare function useKavchatContext(): KavchatContextValue;
|
|
44
|
+
/** ה-hook הציבורי: מצב הפתיחה, זמינות הבוט והחלפת משתמש */
|
|
45
|
+
export declare function useKavchat(): {
|
|
46
|
+
status: import("..").KavchatStatus;
|
|
47
|
+
/** false כשהבוט כבוי, מוגבל למשתמשי בדיקה או שנגמרה המכסה. אז לא מציגים כניסה לצ'אט */
|
|
48
|
+
available: boolean;
|
|
49
|
+
offline: boolean;
|
|
50
|
+
locale: string;
|
|
51
|
+
isOpen: boolean;
|
|
52
|
+
open: () => void;
|
|
53
|
+
close: () => void;
|
|
54
|
+
toggle: () => void;
|
|
55
|
+
setUser: (user: {
|
|
56
|
+
token?: string | null;
|
|
57
|
+
name?: string | null;
|
|
58
|
+
} | null) => Promise<void>;
|
|
59
|
+
startNewConversation: () => void;
|
|
60
|
+
openContactForm: (prefill?: string) => void;
|
|
61
|
+
controller: KavchatController;
|
|
62
|
+
};
|