@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/src/storage.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { BotState } from '@kavchat/core';
|
|
2
|
+
import type { AnyRelease } from './api';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* אחסון במכשיר דרך מתאם, כדי שאפליקציה בלי AsyncStorage עדיין תעבוד (רק בלי מטמון בין הפעלות)
|
|
6
|
+
* המפתחות זהים ל-Flutter, כדי שההתנהגות תהיה אחידה בכל ה-SDK
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface StorageAdapter {
|
|
10
|
+
getItem(key: string): Promise<string | null> | string | null;
|
|
11
|
+
setItem(key: string, value: string): Promise<void> | void;
|
|
12
|
+
removeItem?(key: string): Promise<void> | void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function memoryStorage(): StorageAdapter {
|
|
16
|
+
const values = new Map<string, string>();
|
|
17
|
+
return {
|
|
18
|
+
getItem: (k) => values.get(k) ?? null,
|
|
19
|
+
setItem: (k, v) => {
|
|
20
|
+
values.set(k, v);
|
|
21
|
+
},
|
|
22
|
+
removeItem: (k) => {
|
|
23
|
+
values.delete(k);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* AsyncStorage אם הוא מותקן, אחרת אחסון בזיכרון
|
|
30
|
+
* ה-require עטוף ב-try כדי ש-Metro יתייחס אליו כתלות אופציונלית
|
|
31
|
+
*/
|
|
32
|
+
// Metro מספק require בזמן ריצה. ההצהרה כאן חוסכת תלות ב-@types/node רק בשביל שורה אחת
|
|
33
|
+
declare const require: (id: string) => unknown;
|
|
34
|
+
|
|
35
|
+
export function defaultStorage(): StorageAdapter {
|
|
36
|
+
try {
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
|
+
const mod = require('@react-native-async-storage/async-storage') as { default?: unknown } | undefined;
|
|
39
|
+
const store = (mod && (mod.default ?? mod)) as StorageAdapter | undefined;
|
|
40
|
+
if (store && typeof store.getItem === 'function' && typeof store.setItem === 'function') return store;
|
|
41
|
+
} catch {
|
|
42
|
+
// לא מותקן
|
|
43
|
+
}
|
|
44
|
+
return memoryStorage();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const releaseKey = (pk: string) => `kavchat:release:${pk}`;
|
|
48
|
+
export const sessionKey = (pk: string) => `kavchat:session:${pk}`;
|
|
49
|
+
|
|
50
|
+
export async function readJson<T>(store: StorageAdapter, key: string): Promise<T | null> {
|
|
51
|
+
try {
|
|
52
|
+
const raw = await store.getItem(key);
|
|
53
|
+
return raw ? (JSON.parse(raw) as T) : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function writeJson(store: StorageAdapter, key: string, value: unknown): Promise<void> {
|
|
60
|
+
try {
|
|
61
|
+
await store.setItem(key, JSON.stringify(value));
|
|
62
|
+
} catch {
|
|
63
|
+
// אין מקום או אין גישה. ממשיכים בלי
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CachedRelease {
|
|
68
|
+
etag: string;
|
|
69
|
+
release: AnyRelease;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** השיחה השמורה. בלי התסריטים עצמם, הם מגיעים מהגרסה השמורה */
|
|
73
|
+
export interface StoredSession {
|
|
74
|
+
id: string;
|
|
75
|
+
startedAt: string;
|
|
76
|
+
version: number;
|
|
77
|
+
/** טביעה של המשתמש, לא הטוקן עצמו, כדי לא להציג שיחה של משתמש אחר אחרי החלפת חשבון */
|
|
78
|
+
user: string;
|
|
79
|
+
savedAt?: number;
|
|
80
|
+
state: Omit<BotState, 'scripts'> | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ID_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
|
|
84
|
+
|
|
85
|
+
/** מזהה שיחה אקראי של 22 תווים מתוך [A-Za-z0-9_-], כפי שהשרת מצפה */
|
|
86
|
+
export function randomId(length = 22, random: () => number = Math.random): string {
|
|
87
|
+
const bytes = new Uint8Array(length);
|
|
88
|
+
const c = (globalThis as { crypto?: { getRandomValues?: (a: Uint8Array) => Uint8Array } }).crypto;
|
|
89
|
+
if (random === Math.random && c && typeof c.getRandomValues === 'function') c.getRandomValues(bytes);
|
|
90
|
+
else for (let i = 0; i < length; i++) bytes[i] = Math.floor(random() * 256);
|
|
91
|
+
let out = '';
|
|
92
|
+
for (let i = 0; i < length; i++) out += ID_CHARS[bytes[i] & 63];
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** טביעה קצרה של הטוקן (FNV-1a, כמו ב-Flutter), כדי לא לשמור את הטוקן בדיסק */
|
|
97
|
+
export function userFingerprint(token: string | null | undefined): string {
|
|
98
|
+
if (!token) return '';
|
|
99
|
+
let h = 0x811c9dc5;
|
|
100
|
+
for (let i = 0; i < token.length; i++) {
|
|
101
|
+
h ^= token.charCodeAt(i);
|
|
102
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
103
|
+
}
|
|
104
|
+
return h.toString(16);
|
|
105
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { UI_TEXTS } from '@kavchat/core';
|
|
2
|
+
import type { UiTexts } from '@kavchat/core';
|
|
3
|
+
import type { TicketInput } from './api';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* עזרי טקסט בלי תלות ב-React Native: שפה, קישורים, בדיקת טופס והודעות שגיאה
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const baseLocale = (l: string) => String(l || '').toLowerCase().split(/[-_]/)[0].replace(/^iw$/, 'he');
|
|
10
|
+
|
|
11
|
+
/** שפת הממשק: המבוקשת או שפת המכשיר אם יש לה תרגום, אחרת שפת הבוט */
|
|
12
|
+
export function pickLocale(requested: string | undefined, deviceLocale: string | undefined, releaseLocale: string): string {
|
|
13
|
+
const wanted = baseLocale(requested || deviceLocale || '');
|
|
14
|
+
if (UI_TEXTS[wanted]) return wanted;
|
|
15
|
+
const fallback = baseLocale(releaseLocale);
|
|
16
|
+
return UI_TEXTS[fallback] ? fallback : 'en';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** שפת המכשיר בלי ספריות נוספות. Intl קיים ב-Hermes החדש. אם לא, חוזרים לשפת הבוט */
|
|
20
|
+
export function deviceLocale(): string | undefined {
|
|
21
|
+
try {
|
|
22
|
+
return Intl.DateTimeFormat().resolvedOptions().locale || undefined;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* מחזיר את הכתובת רק אם הפרוטוקול מותר
|
|
30
|
+
* לא משתמשים ב-URL כי ב-React Native המימוש שלו חלקי (protocol לא נתמך בגרסאות ישנות)
|
|
31
|
+
*/
|
|
32
|
+
export function safeUrl(raw: string, schemes: Array<'https' | 'tel'>): string | null {
|
|
33
|
+
const url = String(raw || '').trim();
|
|
34
|
+
if (!url || /\s/.test(url)) return null;
|
|
35
|
+
const m = /^([a-z][a-z0-9+.-]*):(.*)$/i.exec(url);
|
|
36
|
+
if (!m) return null;
|
|
37
|
+
const scheme = m[1].toLowerCase() as 'https' | 'tel';
|
|
38
|
+
if (!schemes.includes(scheme)) return null;
|
|
39
|
+
if (scheme === 'https' && !/^\/\/[^/?#\s]+/.test(m[2])) return null;
|
|
40
|
+
if (scheme === 'tel' && !/^[0-9+*#(),;.\-]+$/.test(m[2])) return null;
|
|
41
|
+
return `${scheme}:${m[2]}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type TextPart = { text: string; href?: string };
|
|
45
|
+
|
|
46
|
+
const URL_RE = /https:\/\/[^\s<>"'`]+/g;
|
|
47
|
+
const TRAILING = /[.,;:!?)\]}'"»]+$/;
|
|
48
|
+
|
|
49
|
+
/** טקסט של הבוט מפוצל לקטעים. רק https הופך לקישור, וכל השאר נשאר טקסט */
|
|
50
|
+
export function splitLinks(text: string): TextPart[] {
|
|
51
|
+
const parts: TextPart[] = [];
|
|
52
|
+
let last = 0;
|
|
53
|
+
const re = new RegExp(URL_RE.source, 'g');
|
|
54
|
+
let m: RegExpExecArray | null;
|
|
55
|
+
while ((m = re.exec(text))) {
|
|
56
|
+
const raw = m[0].replace(TRAILING, '');
|
|
57
|
+
const href = raw ? safeUrl(raw, ['https']) : null;
|
|
58
|
+
if (!href) continue;
|
|
59
|
+
if (m.index > last) parts.push({ text: text.slice(last, m.index) });
|
|
60
|
+
parts.push({ text: raw, href });
|
|
61
|
+
last = m.index + raw.length;
|
|
62
|
+
re.lastIndex = last;
|
|
63
|
+
}
|
|
64
|
+
if (last < text.length) parts.push({ text: text.slice(last) });
|
|
65
|
+
return parts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
69
|
+
|
|
70
|
+
/** אותה בדיקה כמו בווידג'ט: הודעה חובה, ואורח חייב להשאיר מייל או טלפון */
|
|
71
|
+
export function validateTicket(input: TicketInput, identified: boolean, t: UiTexts): string {
|
|
72
|
+
if (!input.message) return t['form.required'];
|
|
73
|
+
if (!input.email && !input.phone && !identified) return t['form.contactRequired'];
|
|
74
|
+
if (input.email && !EMAIL_RE.test(input.email)) return t['form.emailInvalid'];
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** שגיאה מהשרת: קודים מוכרים בשפת הממשק, אחרת ההודעה של השרת, אחרת הודעה כללית */
|
|
79
|
+
export function ticketError(code: string, message: string, t: UiTexts): string {
|
|
80
|
+
if (code === 'ticket_contact_required') return t['form.contactRequired'];
|
|
81
|
+
if (code === 'email_invalid') return t['form.emailInvalid'];
|
|
82
|
+
if (code === 'network') return t['form.error'];
|
|
83
|
+
return message || t['form.error'];
|
|
84
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* צבעים מתוך primaryColor של הלקוח, כמו בווידג'ט לאתר
|
|
3
|
+
* הטקסט על הצבע הראשי נבחר לפי יחס ניגודיות (WCAG), כדי שגם צבע בהיר יישאר קריא
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_PRIMARY = '#2563eb';
|
|
7
|
+
const DARK = '#111827';
|
|
8
|
+
const WHITE = '#ffffff';
|
|
9
|
+
|
|
10
|
+
export function parseHex(color: string): [number, number, number] | null {
|
|
11
|
+
const m = /^#([0-9a-f]{6})$/i.exec(String(color || '').trim());
|
|
12
|
+
if (!m) return null;
|
|
13
|
+
const n = parseInt(m[1], 16);
|
|
14
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function luminance(rgb: [number, number, number]): number {
|
|
18
|
+
const [r, g, b] = rgb.map((v) => {
|
|
19
|
+
const c = v / 255;
|
|
20
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
21
|
+
});
|
|
22
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function contrast(a: string, b: string): number {
|
|
26
|
+
const la = luminance(parseHex(a) ?? [0, 0, 0]);
|
|
27
|
+
const lb = luminance(parseHex(b) ?? [0, 0, 0]);
|
|
28
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface KavchatTheme {
|
|
32
|
+
primary: string;
|
|
33
|
+
/** טקסט על רקע הצבע הראשי */
|
|
34
|
+
onPrimary: string;
|
|
35
|
+
/** טקסט וקווי מתאר בצבע הראשי על רקע לבן. צבע בהיר מדי מוחלף בכהה */
|
|
36
|
+
accent: string;
|
|
37
|
+
background: string;
|
|
38
|
+
botBubble: string;
|
|
39
|
+
userBubble: string;
|
|
40
|
+
text: string;
|
|
41
|
+
muted: string;
|
|
42
|
+
error: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function themeFor(primaryColor: string | undefined, override?: Partial<KavchatTheme>): KavchatTheme {
|
|
46
|
+
const color = override?.primary ?? primaryColor ?? '';
|
|
47
|
+
const primary = parseHex(color) ? color.toLowerCase() : DEFAULT_PRIMARY;
|
|
48
|
+
const onPrimary = contrast(primary, WHITE) >= contrast(primary, DARK) ? WHITE : DARK;
|
|
49
|
+
const accent = contrast(primary, WHITE) >= 3 ? primary : DARK;
|
|
50
|
+
return {
|
|
51
|
+
primary,
|
|
52
|
+
onPrimary,
|
|
53
|
+
accent,
|
|
54
|
+
background: '#f3f4f6',
|
|
55
|
+
botBubble: '#ffffff',
|
|
56
|
+
userBubble: '#e0e7ff',
|
|
57
|
+
text: DARK,
|
|
58
|
+
muted: '#6b7280',
|
|
59
|
+
error: '#b91c1c',
|
|
60
|
+
...stripUndefined(override),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stripUndefined<T extends object>(o: T | undefined): Partial<T> {
|
|
65
|
+
const out: Partial<T> = {};
|
|
66
|
+
if (!o) return out;
|
|
67
|
+
for (const [k, v] of Object.entries(o)) if (v !== undefined) (out as Record<string, unknown>)[k] = v;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { ComponentRef } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
ActivityIndicator,
|
|
5
|
+
Image,
|
|
6
|
+
KeyboardAvoidingView,
|
|
7
|
+
Linking,
|
|
8
|
+
Platform,
|
|
9
|
+
Pressable,
|
|
10
|
+
ScrollView,
|
|
11
|
+
StyleSheet,
|
|
12
|
+
Text,
|
|
13
|
+
TextInput,
|
|
14
|
+
View,
|
|
15
|
+
} from 'react-native';
|
|
16
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
17
|
+
import { format } from '@kavchat/core';
|
|
18
|
+
import type { BotChip, BotMessage } from '@kavchat/core';
|
|
19
|
+
import { BRANDING_URL } from '../controller';
|
|
20
|
+
import { safeUrl, splitLinks } from '../text';
|
|
21
|
+
import type { KavchatTheme } from '../theme';
|
|
22
|
+
import { useKavchatContext } from './KavchatProvider';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* מסך הצ'אט. כיוון הפריסה נקבע לפי I18nManager של האפליקציה המארחת, ולא כופים RTL.
|
|
26
|
+
* טקסט עם textAlign ברירת מחדל מיושר לפי השפה שלו, כך שעברית וערבית נקראות נכון גם באפליקציה LTR
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface KavchatChatProps {
|
|
30
|
+
/** כפתור סגירה בכותרת. בלי זה אין כפתור */
|
|
31
|
+
onClose?: () => void;
|
|
32
|
+
/** בלי כותרת, כשהאפליקציה מציגה כותרת משלה בניווט */
|
|
33
|
+
hideHeader?: boolean;
|
|
34
|
+
style?: StyleProp<ViewStyle>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function KavchatChat({ onClose, hideHeader, style }: KavchatChatProps) {
|
|
38
|
+
const { controller, snapshot, theme } = useKavchatContext();
|
|
39
|
+
const t = snapshot.texts;
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
controller.ensureConversation();
|
|
43
|
+
}, [controller, snapshot.status]);
|
|
44
|
+
|
|
45
|
+
if (snapshot.status === 'loading') {
|
|
46
|
+
return (
|
|
47
|
+
<View style={[styles.center, { backgroundColor: theme.background }, style]} accessibilityLabel={t['widget.loading']}>
|
|
48
|
+
<ActivityIndicator color={theme.accent} />
|
|
49
|
+
</View>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (snapshot.status === 'unavailable' || !snapshot.release) {
|
|
53
|
+
return (
|
|
54
|
+
<View style={[styles.center, { backgroundColor: theme.background }, style]}>
|
|
55
|
+
<Text style={[styles.body, { color: theme.text }]}>{t['widget.unavailable']}</Text>
|
|
56
|
+
{onClose ? <ChipButton label={t['widget.close']} theme={theme} onPress={onClose} /> : null}
|
|
57
|
+
</View>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const s = snapshot.release.settings;
|
|
62
|
+
return (
|
|
63
|
+
<KeyboardAvoidingView style={[styles.flex, { backgroundColor: theme.background }, style]} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
|
64
|
+
{hideHeader ? null : (
|
|
65
|
+
<View style={[styles.header, { backgroundColor: theme.primary }]}>
|
|
66
|
+
<Avatar text={s.avatarText || s.botName || ''} imageUrl={s.avatarImageUrl} theme={theme} />
|
|
67
|
+
<View style={styles.titles}>
|
|
68
|
+
<Text style={[styles.title, { color: theme.onPrimary }]} numberOfLines={1} accessibilityRole="header">
|
|
69
|
+
{s.botName}
|
|
70
|
+
</Text>
|
|
71
|
+
{s.subtitle ? (
|
|
72
|
+
<Text style={[styles.subtitle, { color: theme.onPrimary }]} numberOfLines={1}>
|
|
73
|
+
{s.subtitle}
|
|
74
|
+
</Text>
|
|
75
|
+
) : null}
|
|
76
|
+
</View>
|
|
77
|
+
{onClose ? (
|
|
78
|
+
<Pressable onPress={onClose} hitSlop={12} accessibilityRole="button" accessibilityLabel={t['widget.close']} style={styles.close}>
|
|
79
|
+
<Text style={[styles.closeText, { color: theme.onPrimary }]}>{'×'}</Text>
|
|
80
|
+
</Pressable>
|
|
81
|
+
) : null}
|
|
82
|
+
</View>
|
|
83
|
+
)}
|
|
84
|
+
{snapshot.offline ? (
|
|
85
|
+
<View style={styles.banner} accessibilityRole="alert">
|
|
86
|
+
<Text style={styles.bannerText}>{t['widget.offline']}</Text>
|
|
87
|
+
</View>
|
|
88
|
+
) : null}
|
|
89
|
+
{snapshot.view === 'chat' ? <ChatView /> : snapshot.view === 'form' ? <ContactForm /> : <SentView />}
|
|
90
|
+
{s.removeBranding ? null : (
|
|
91
|
+
// בתוכנית החינמית הסמל חובה
|
|
92
|
+
<Pressable onPress={() => Linking.openURL(BRANDING_URL).catch(() => undefined)} accessibilityRole="link" style={styles.badge}>
|
|
93
|
+
<Text style={[styles.badgeText, { color: theme.muted }]}>{t['widget.poweredBy']}</Text>
|
|
94
|
+
</Pressable>
|
|
95
|
+
)}
|
|
96
|
+
</KeyboardAvoidingView>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ChatView() {
|
|
101
|
+
const { controller, snapshot, theme } = useKavchatContext();
|
|
102
|
+
const t = snapshot.texts;
|
|
103
|
+
const [text, setText] = useState('');
|
|
104
|
+
const scroll = useRef<ComponentRef<typeof ScrollView>>(null);
|
|
105
|
+
const conv = snapshot.conversation;
|
|
106
|
+
|
|
107
|
+
const typing = snapshot.typing;
|
|
108
|
+
const send = () => {
|
|
109
|
+
if (typing) return;
|
|
110
|
+
const value = text;
|
|
111
|
+
setText('');
|
|
112
|
+
void controller.sendText(value);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<View style={styles.flex}>
|
|
117
|
+
<ScrollView
|
|
118
|
+
ref={scroll}
|
|
119
|
+
style={styles.flex}
|
|
120
|
+
contentContainerStyle={styles.list}
|
|
121
|
+
onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: true })}
|
|
122
|
+
keyboardShouldPersistTaps="handled"
|
|
123
|
+
>
|
|
124
|
+
{(conv?.messages ?? []).map((m) => (
|
|
125
|
+
<Bubble key={m.id} message={m} theme={theme} aiLabel={t['chat.aiLabel']} />
|
|
126
|
+
))}
|
|
127
|
+
{typing ? (
|
|
128
|
+
<View style={[styles.bubble, styles.botBubble, { backgroundColor: theme.botBubble }]} accessibilityLabel={t['widget.loading']} accessibilityLiveRegion="polite">
|
|
129
|
+
<ActivityIndicator size="small" color={theme.accent} />
|
|
130
|
+
</View>
|
|
131
|
+
) : null}
|
|
132
|
+
{conv && conv.chips.length > 0 ? (
|
|
133
|
+
<View style={styles.chips} accessibilityRole="menu">
|
|
134
|
+
{conv.chips.map((chip) => (
|
|
135
|
+
<ChipButton key={chip.id} label={chip.label} theme={theme} fixed={isFixed(chip)} onPress={() => controller.pressChip(chip)} />
|
|
136
|
+
))}
|
|
137
|
+
</View>
|
|
138
|
+
) : null}
|
|
139
|
+
</ScrollView>
|
|
140
|
+
<View style={styles.composer}>
|
|
141
|
+
<TextInput
|
|
142
|
+
value={text}
|
|
143
|
+
onChangeText={setText}
|
|
144
|
+
onSubmitEditing={send}
|
|
145
|
+
placeholder={t['widget.placeholder']}
|
|
146
|
+
accessibilityLabel={t['widget.placeholder']}
|
|
147
|
+
placeholderTextColor={theme.muted}
|
|
148
|
+
maxLength={500}
|
|
149
|
+
returnKeyType="send"
|
|
150
|
+
blurOnSubmit={false}
|
|
151
|
+
style={[styles.input, { color: theme.text }]}
|
|
152
|
+
/>
|
|
153
|
+
<Pressable
|
|
154
|
+
onPress={send}
|
|
155
|
+
disabled={!text.trim() || typing}
|
|
156
|
+
accessibilityRole="button"
|
|
157
|
+
accessibilityLabel={t['widget.send']}
|
|
158
|
+
style={[styles.send, { backgroundColor: theme.primary }, (!text.trim() || typing) && styles.disabled]}
|
|
159
|
+
>
|
|
160
|
+
<SendIcon color={theme.onPrimary} />
|
|
161
|
+
</Pressable>
|
|
162
|
+
</View>
|
|
163
|
+
</View>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const isFixed = (chip: BotChip) => chip.kind === 'back' || chip.kind === 'home';
|
|
168
|
+
|
|
169
|
+
function Bubble({ message, theme, aiLabel }: { message: BotMessage; theme: KavchatTheme; aiLabel: string }) {
|
|
170
|
+
const bot = message.from === 'bot';
|
|
171
|
+
const img = bot && message.imageUrl ? safeUrl(message.imageUrl, ['https']) : null;
|
|
172
|
+
const ai = bot && message.ai === true;
|
|
173
|
+
return (
|
|
174
|
+
<View style={[styles.bubble, bot ? [styles.botBubble, { backgroundColor: theme.botBubble }] : [styles.userBubble, { backgroundColor: theme.userBubble }]]}>
|
|
175
|
+
{img ? <Image source={{ uri: img }} style={styles.image} resizeMode="cover" accessibilityIgnoresInvertColors /> : null}
|
|
176
|
+
{ai ? (
|
|
177
|
+
// תווית קטנה: המשתמש צריך לדעת שהתשובה נוצרה ב-AI ולא נכתבה בתסריט
|
|
178
|
+
<Text style={[styles.aiLabel, { color: theme.muted, borderColor: theme.muted }]}>{aiLabel}</Text>
|
|
179
|
+
) : null}
|
|
180
|
+
<Text style={[styles.body, { color: theme.text }]} selectable>
|
|
181
|
+
{ai
|
|
182
|
+
? // תשובת AI היא טקסט פשוט בלבד, בלי קישורים ובלי עיבוד
|
|
183
|
+
message.text
|
|
184
|
+
: bot
|
|
185
|
+
? splitLinks(message.text).map((p, i) =>
|
|
186
|
+
p.href ? (
|
|
187
|
+
<Text key={i} style={[styles.link, { color: theme.accent }]} accessibilityRole="link" onPress={() => Linking.openURL(p.href!).catch(() => undefined)}>
|
|
188
|
+
{p.text}
|
|
189
|
+
</Text>
|
|
190
|
+
) : (
|
|
191
|
+
p.text
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
: // הודעות המשתמש לא הופכות לקישורים
|
|
195
|
+
message.text}
|
|
196
|
+
</Text>
|
|
197
|
+
</View>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function ChipButton({ label, theme, fixed, onPress }: { label: string; theme: KavchatTheme; fixed?: boolean; onPress: () => void }) {
|
|
202
|
+
return (
|
|
203
|
+
<Pressable
|
|
204
|
+
onPress={onPress}
|
|
205
|
+
accessibilityRole="button"
|
|
206
|
+
style={({ pressed }) => [styles.chip, { borderColor: theme.accent }, fixed && styles.chipFixed, pressed && { backgroundColor: theme.userBubble }]}
|
|
207
|
+
>
|
|
208
|
+
<Text style={[styles.chipText, { color: theme.accent }, fixed && styles.chipTextFixed]}>{label}</Text>
|
|
209
|
+
</Pressable>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function ContactForm() {
|
|
214
|
+
const { controller, snapshot, theme } = useKavchatContext();
|
|
215
|
+
const t = snapshot.texts;
|
|
216
|
+
const effect = snapshot.contact;
|
|
217
|
+
const topics = snapshot.release?.settings.contactTopics ?? [];
|
|
218
|
+
const initialTopic = effect?.contactTopicId && topics.some((x) => x.id === effect.contactTopicId) ? effect.contactTopicId : '';
|
|
219
|
+
const [name, setName] = useState(snapshot.userName ?? '');
|
|
220
|
+
const [email, setEmail] = useState('');
|
|
221
|
+
const [phone, setPhone] = useState('');
|
|
222
|
+
const [topicId, setTopicId] = useState(initialTopic);
|
|
223
|
+
const [message, setMessage] = useState(effect?.prefill ?? '');
|
|
224
|
+
const [error, setError] = useState('');
|
|
225
|
+
const [sending, setSending] = useState(false);
|
|
226
|
+
|
|
227
|
+
const submit = async () => {
|
|
228
|
+
if (sending) return;
|
|
229
|
+
setSending(true);
|
|
230
|
+
setError('');
|
|
231
|
+
const problem = await controller.submitTicket({ name, email, phone, topicId, message });
|
|
232
|
+
setSending(false);
|
|
233
|
+
if (problem) setError(problem);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const inputStyle = [styles.field, { color: theme.text }];
|
|
237
|
+
return (
|
|
238
|
+
<ScrollView style={styles.flex} contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">
|
|
239
|
+
<Text style={[styles.formTitle, { color: theme.text }]} accessibilityRole="header">
|
|
240
|
+
{t['form.title']}
|
|
241
|
+
</Text>
|
|
242
|
+
<Label text={t['form.name']} theme={theme} />
|
|
243
|
+
<TextInput value={name} onChangeText={setName} maxLength={100} autoComplete="name" textContentType="name" style={inputStyle} accessibilityLabel={t['form.name']} />
|
|
244
|
+
<Label text={t['form.email']} theme={theme} />
|
|
245
|
+
<TextInput
|
|
246
|
+
value={email}
|
|
247
|
+
onChangeText={setEmail}
|
|
248
|
+
keyboardType="email-address"
|
|
249
|
+
autoCapitalize="none"
|
|
250
|
+
autoComplete="email"
|
|
251
|
+
textContentType="emailAddress"
|
|
252
|
+
style={inputStyle}
|
|
253
|
+
accessibilityLabel={t['form.email']}
|
|
254
|
+
/>
|
|
255
|
+
<Label text={t['form.phone']} theme={theme} />
|
|
256
|
+
<TextInput value={phone} onChangeText={setPhone} keyboardType="phone-pad" autoComplete="tel" textContentType="telephoneNumber" style={inputStyle} accessibilityLabel={t['form.phone']} />
|
|
257
|
+
{topics.length > 0 ? (
|
|
258
|
+
<>
|
|
259
|
+
<Label text={t['form.topic']} theme={theme} />
|
|
260
|
+
{/* אין select מובנה ב-React Native, ולכן הנושאים מוצגים ככפתורי בחירה */}
|
|
261
|
+
<View style={styles.chips} accessibilityRole="radiogroup">
|
|
262
|
+
{topics.map((tp) => {
|
|
263
|
+
const on = tp.id === topicId;
|
|
264
|
+
return (
|
|
265
|
+
<Pressable
|
|
266
|
+
key={tp.id}
|
|
267
|
+
onPress={() => setTopicId(on ? '' : tp.id)}
|
|
268
|
+
accessibilityRole="radio"
|
|
269
|
+
accessibilityState={{ checked: on }}
|
|
270
|
+
style={[styles.chip, { borderColor: theme.accent }, on && { backgroundColor: theme.primary, borderColor: theme.primary }]}
|
|
271
|
+
>
|
|
272
|
+
<Text style={[styles.chipText, { color: on ? theme.onPrimary : theme.accent }]}>{tp.name}</Text>
|
|
273
|
+
</Pressable>
|
|
274
|
+
);
|
|
275
|
+
})}
|
|
276
|
+
</View>
|
|
277
|
+
</>
|
|
278
|
+
) : null}
|
|
279
|
+
<Label text={t['form.message']} theme={theme} />
|
|
280
|
+
<TextInput value={message} onChangeText={setMessage} multiline maxLength={2000} style={[inputStyle, styles.message]} accessibilityLabel={t['form.message']} />
|
|
281
|
+
{error ? (
|
|
282
|
+
<Text style={[styles.error, { color: theme.error }]} accessibilityRole="alert" accessibilityLiveRegion="polite">
|
|
283
|
+
{error}
|
|
284
|
+
</Text>
|
|
285
|
+
) : null}
|
|
286
|
+
<View style={styles.actions}>
|
|
287
|
+
<Pressable
|
|
288
|
+
onPress={submit}
|
|
289
|
+
disabled={sending}
|
|
290
|
+
accessibilityRole="button"
|
|
291
|
+
accessibilityState={{ disabled: sending, busy: sending }}
|
|
292
|
+
style={[styles.btn, { backgroundColor: theme.primary }, sending && styles.disabled]}
|
|
293
|
+
>
|
|
294
|
+
<Text style={[styles.btnText, { color: theme.onPrimary }]}>{sending ? t['form.sending'] : t['form.submit']}</Text>
|
|
295
|
+
</Pressable>
|
|
296
|
+
<Pressable onPress={() => controller.backToChat()} accessibilityRole="button" style={[styles.btn, styles.btnGhost, { borderColor: theme.accent }]}>
|
|
297
|
+
<Text style={[styles.btnText, { color: theme.accent }]}>{t['form.cancel']}</Text>
|
|
298
|
+
</Pressable>
|
|
299
|
+
</View>
|
|
300
|
+
</ScrollView>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function SentView() {
|
|
305
|
+
const { controller, snapshot, theme } = useKavchatContext();
|
|
306
|
+
const t = snapshot.texts;
|
|
307
|
+
return (
|
|
308
|
+
<View style={styles.center}>
|
|
309
|
+
<Text style={[styles.body, styles.sent, { color: theme.text }]} accessibilityLiveRegion="polite">
|
|
310
|
+
{format(t['form.sent'], { number: snapshot.ticketNumber ?? '' })}
|
|
311
|
+
</Text>
|
|
312
|
+
<Pressable onPress={() => controller.backToChat()} accessibilityRole="button" style={[styles.btn, { backgroundColor: theme.primary }]}>
|
|
313
|
+
<Text style={[styles.btnText, { color: theme.onPrimary }]}>{t['form.backToChat']}</Text>
|
|
314
|
+
</Pressable>
|
|
315
|
+
</View>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function Label({ text, theme }: { text: string; theme: KavchatTheme }) {
|
|
320
|
+
return <Text style={[styles.label, { color: theme.muted }]}>{text}</Text>;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function Avatar({ text, imageUrl, theme }: { text: string; imageUrl: string | null; theme: KavchatTheme }) {
|
|
324
|
+
const img = imageUrl ? safeUrl(imageUrl, ['https']) : null;
|
|
325
|
+
return (
|
|
326
|
+
<View style={[styles.avatar, { backgroundColor: theme.onPrimary }]} importantForAccessibility="no-hide-descendants" accessibilityElementsHidden>
|
|
327
|
+
{img ? <Image source={{ uri: img }} style={styles.avatarImage} /> : <Text style={[styles.avatarText, { color: theme.primary }]}>{text.slice(0, 2)}</Text>}
|
|
328
|
+
</View>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** אייקון שליחה בלי react-native-svg: משולש מגבולות. בכיוון RTL הוא מתהפך יחד עם הפריסה */
|
|
333
|
+
function SendIcon({ color }: { color: string }) {
|
|
334
|
+
return <View style={[styles.sendIcon, { borderStartColor: color }]} />;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const styles = StyleSheet.create({
|
|
338
|
+
flex: { flex: 1 },
|
|
339
|
+
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24, gap: 16 },
|
|
340
|
+
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 10, gap: 10 },
|
|
341
|
+
titles: { flex: 1 },
|
|
342
|
+
title: { fontSize: 16, fontWeight: '600' },
|
|
343
|
+
subtitle: { fontSize: 12, opacity: 0.85 },
|
|
344
|
+
close: { padding: 4 },
|
|
345
|
+
closeText: { fontSize: 26, lineHeight: 28 },
|
|
346
|
+
avatar: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
|
|
347
|
+
avatarImage: { width: 36, height: 36 },
|
|
348
|
+
avatarText: { fontSize: 13, fontWeight: '700' },
|
|
349
|
+
banner: { backgroundColor: '#fef3c7', paddingHorizontal: 12, paddingVertical: 6 },
|
|
350
|
+
bannerText: { color: '#92400e', fontSize: 13 },
|
|
351
|
+
list: { padding: 12, gap: 6 },
|
|
352
|
+
bubble: { maxWidth: '85%', borderRadius: 14, paddingHorizontal: 12, paddingVertical: 8 },
|
|
353
|
+
// הבוט בצד ההתחלה והמשתמש בצד הסוף. flex-start מתהפך לבד ב-RTL
|
|
354
|
+
botBubble: { alignSelf: 'flex-start', borderWidth: StyleSheet.hairlineWidth, borderColor: '#e5e7eb' },
|
|
355
|
+
userBubble: { alignSelf: 'flex-end' },
|
|
356
|
+
body: { fontSize: 15, lineHeight: 21 },
|
|
357
|
+
link: { textDecorationLine: 'underline' },
|
|
358
|
+
aiLabel: { alignSelf: 'flex-start', fontSize: 11, fontWeight: '600', borderWidth: StyleSheet.hairlineWidth, borderRadius: 999, paddingHorizontal: 6, paddingVertical: 1, marginBottom: 4 },
|
|
359
|
+
image: { width: 220, height: 140, borderRadius: 8, marginBottom: 6 },
|
|
360
|
+
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 6 },
|
|
361
|
+
chip: { borderWidth: 1.5, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: '#ffffff' },
|
|
362
|
+
chipFixed: { borderStyle: 'dashed' },
|
|
363
|
+
chipText: { fontSize: 14, fontWeight: '600' },
|
|
364
|
+
chipTextFixed: { fontWeight: '400' },
|
|
365
|
+
composer: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 8, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#e5e7eb', backgroundColor: '#ffffff' },
|
|
366
|
+
input: { flex: 1, borderRadius: 20, backgroundColor: '#f3f4f6', paddingHorizontal: 14, paddingVertical: Platform.OS === 'ios' ? 10 : 6, fontSize: 15 },
|
|
367
|
+
send: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
|
|
368
|
+
sendIcon: { width: 0, height: 0, borderTopWidth: 8, borderBottomWidth: 8, borderStartWidth: 14, borderTopColor: 'transparent', borderBottomColor: 'transparent', marginStart: 4 },
|
|
369
|
+
disabled: { opacity: 0.5 },
|
|
370
|
+
form: { padding: 16, gap: 6 },
|
|
371
|
+
formTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
|
|
372
|
+
label: { fontSize: 13, marginTop: 6 },
|
|
373
|
+
field: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, paddingHorizontal: 10, paddingVertical: Platform.OS === 'ios' ? 10 : 6, fontSize: 15, backgroundColor: '#ffffff' },
|
|
374
|
+
message: { minHeight: 110, textAlignVertical: 'top' },
|
|
375
|
+
error: { fontSize: 14, marginTop: 6 },
|
|
376
|
+
actions: { flexDirection: 'row', gap: 10, marginTop: 12 },
|
|
377
|
+
btn: { borderRadius: 8, paddingHorizontal: 18, paddingVertical: 10, alignItems: 'center' },
|
|
378
|
+
btnGhost: { borderWidth: 1, backgroundColor: 'transparent' },
|
|
379
|
+
btnText: { fontSize: 15, fontWeight: '600' },
|
|
380
|
+
sent: { textAlign: 'center' },
|
|
381
|
+
badge: { alignItems: 'center', paddingVertical: 6, backgroundColor: '#ffffff' },
|
|
382
|
+
badgeText: { fontSize: 11 },
|
|
383
|
+
});
|