@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.
@@ -0,0 +1,308 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { ComponentRef } from 'react';
3
+ import { Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
4
+ import { LIVE_MESSAGE_MAX, liveLines, liveStatus } from '@kavchat/core';
5
+ import type { LiveLine, UiTexts } from '@kavchat/core';
6
+ import type { LiveSnapshot } from '../live/session';
7
+ import type { KavchatTheme } from '../theme';
8
+ import { useKavchatContext } from './KavchatProvider';
9
+
10
+ /**
11
+ * הצ'אט עם הנציג: שורת מצב (המתנה עם ספירה לאחור, שם הנציג, או המצב הסופי), הודעות, הקלדה,
12
+ * "להשאיר הודעה במקום", סיום, וחזרה לבוט. הטקסטים והשורות מ-@kavchat/core, כמו בווידג'ט לאתר.
13
+ * טקסט פשוט בלבד: הודעות הנציג לא הופכות לקישורים
14
+ */
15
+
16
+ export function LiveChatView() {
17
+ const { controller, snapshot, theme } = useKavchatContext();
18
+ const live = snapshot.live;
19
+ const t = snapshot.texts;
20
+ const [text, setText] = useState('');
21
+ const [error, setError] = useState('');
22
+ const [busy, setBusy] = useState(false);
23
+ const [now, setNow] = useState(Date.now());
24
+ const scroll = useRef<ComponentRef<typeof ScrollView>>(null);
25
+ const queued = live?.phase === 'queued';
26
+ // הספירה לאחור מתעדכנת כל שנייה, רק בזמן ההמתנה
27
+ useEffect(() => {
28
+ if (!queued) return;
29
+ const id = setInterval(() => setNow(Date.now()), 1000);
30
+ return () => clearInterval(id);
31
+ }, [queued]);
32
+ if (!live) return null;
33
+
34
+ const open = live.phase === 'queued' || live.phase === 'active';
35
+ const send = () => {
36
+ const problem = controller.liveSend(text);
37
+ if (problem) {
38
+ // הטקסט נשאר בשדה
39
+ setError(problem);
40
+ return;
41
+ }
42
+ setError('');
43
+ setText('');
44
+ };
45
+ const run = async (fn: () => Promise<unknown>) => {
46
+ if (busy) return;
47
+ setBusy(true);
48
+ try {
49
+ await fn();
50
+ } finally {
51
+ setBusy(false);
52
+ }
53
+ };
54
+
55
+ return (
56
+ <View style={styles.flex}>
57
+ <View style={styles.status}>
58
+ <Text style={[styles.statusText, { color: theme.text }]} accessibilityRole="header" accessibilityLiveRegion="polite">
59
+ {statusText(live, t, now)}
60
+ </Text>
61
+ {open ? (
62
+ <Pressable onPress={() => run(() => controller.liveEnd())} disabled={busy} accessibilityRole="button" hitSlop={8} style={[styles.small, { borderColor: theme.accent }]}>
63
+ <Text style={[styles.smallText, { color: theme.accent }]}>{t['live.end']}</Text>
64
+ </Pressable>
65
+ ) : null}
66
+ </View>
67
+ {live.connection === 'reconnecting' && open ? (
68
+ <View style={styles.banner} accessibilityRole="alert">
69
+ <Text style={styles.bannerText}>{t['live.reconnecting']}</Text>
70
+ </View>
71
+ ) : null}
72
+ <ScrollView
73
+ ref={scroll}
74
+ style={styles.flex}
75
+ contentContainerStyle={styles.list}
76
+ onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: true })}
77
+ keyboardShouldPersistTaps="handled"
78
+ >
79
+ {liveLines(live.chat, t).map((line) => (
80
+ <Line key={line.key} line={line} theme={theme} t={t} onRetry={line.clientId ? () => controller.liveRetry(line.clientId!) : undefined} />
81
+ ))}
82
+ {live.agentTyping !== null && live.phase === 'active' ? (
83
+ <Text style={[styles.typing, { color: theme.muted }]} accessibilityLiveRegion="polite">
84
+ {t['live.typing']}
85
+ </Text>
86
+ ) : null}
87
+ {queued ? (
88
+ <Pressable
89
+ onPress={() => run(() => controller.liveLeaveMessage())}
90
+ disabled={busy}
91
+ accessibilityRole="button"
92
+ style={[styles.btn, styles.btnGhost, styles.leave, { borderColor: theme.accent }, busy && styles.disabled]}
93
+ >
94
+ <Text style={[styles.btnText, { color: theme.accent }]}>{t['live.leave']}</Text>
95
+ </Pressable>
96
+ ) : null}
97
+ {!open ? (
98
+ <View style={styles.final}>
99
+ <Pressable onPress={() => controller.closeLive()} accessibilityRole="button" style={[styles.btn, { backgroundColor: theme.primary }]}>
100
+ <Text style={[styles.btnText, { color: theme.onPrimary }]}>{t['live.back']}</Text>
101
+ </Pressable>
102
+ </View>
103
+ ) : null}
104
+ </ScrollView>
105
+ {error ? (
106
+ <Text style={[styles.error, { color: theme.error }]} accessibilityRole="alert">
107
+ {error}
108
+ </Text>
109
+ ) : null}
110
+ {open ? (
111
+ <View style={styles.composer}>
112
+ <TextInput
113
+ value={text}
114
+ onChangeText={(v) => {
115
+ setText(v);
116
+ controller.liveTyping(v);
117
+ }}
118
+ onSubmitEditing={send}
119
+ placeholder={t['live.placeholder']}
120
+ accessibilityLabel={t['live.placeholder']}
121
+ placeholderTextColor={theme.muted}
122
+ maxLength={LIVE_MESSAGE_MAX}
123
+ returnKeyType="send"
124
+ blurOnSubmit={false}
125
+ style={[styles.input, { color: theme.text }]}
126
+ />
127
+ <Pressable
128
+ onPress={send}
129
+ disabled={!text.trim()}
130
+ accessibilityRole="button"
131
+ accessibilityLabel={t['widget.send']}
132
+ style={[styles.send, { backgroundColor: theme.primary }, !text.trim() && styles.disabled]}
133
+ >
134
+ <View style={[styles.sendIcon, { borderStartColor: theme.onPrimary }]} />
135
+ </Pressable>
136
+ </View>
137
+ ) : null}
138
+ </View>
139
+ );
140
+ }
141
+
142
+ /** שורת המצב של המכונה המשותפת, ושני מצבים שלא באים מהשרת: הוחלף במכשיר אחר, או פג */
143
+ function statusText(live: LiveSnapshot, t: UiTexts, now: number): string {
144
+ if (live.phase === 'replaced') return t['live.replaced'];
145
+ if (live.phase === 'expired') return t['live.ended'];
146
+ return liveStatus(live.conversation, t, now);
147
+ }
148
+
149
+ function Line({ line, theme, t, onRetry }: { line: LiveLine; theme: KavchatTheme; t: UiTexts; onRetry?: () => void }) {
150
+ if (line.kind === 'system') return <Text style={[styles.system, { color: theme.muted }]}>{line.text}</Text>;
151
+ const mine = line.kind === 'visitor';
152
+ const failed = line.status === 'failed';
153
+ const bubble = (
154
+ <>
155
+ {!mine && line.name ? <Text style={[styles.author, { color: theme.muted }]}>{line.name}</Text> : null}
156
+ <Text style={[styles.body, { color: theme.text }]} selectable={!line.status}>
157
+ {line.text}
158
+ </Text>
159
+ {failed ? <Text style={[styles.meta, { color: theme.error }]}>{t['live.failed']}</Text> : null}
160
+ </>
161
+ );
162
+ const style = [styles.bubble, mine ? [styles.mine, { backgroundColor: theme.userBubble }] : [styles.theirs, { backgroundColor: theme.botBubble }], line.status === 'sending' && styles.sending];
163
+ if (failed && onRetry) {
164
+ return (
165
+ <Pressable onPress={onRetry} accessibilityRole="button" accessibilityHint={t['live.failed']} style={style}>
166
+ {bubble}
167
+ </Pressable>
168
+ );
169
+ }
170
+ return <View style={style}>{bubble}</View>;
171
+ }
172
+
173
+ /** טופס הבקשה לנציג: אורח משאיר שם ומייל או טלפון, בחירת מחלקה אם יש יותר מאחת, והודעה ראשונה (לא חובה) */
174
+ export function LiveRequestForm() {
175
+ const { controller, snapshot, theme } = useKavchatContext();
176
+ const t = snapshot.texts;
177
+ const guest = !snapshot.identified;
178
+ const departments = snapshot.liveOffer?.departments ?? [];
179
+ const [name, setName] = useState(snapshot.userName ?? '');
180
+ const [email, setEmail] = useState('');
181
+ const [phone, setPhone] = useState('');
182
+ const [message, setMessage] = useState(snapshot.contact?.prefill ?? '');
183
+ const [departmentId, setDepartmentId] = useState(departments[0]?.id ?? '');
184
+ const [error, setError] = useState('');
185
+ const [sending, setSending] = useState(false);
186
+
187
+ const submit = async () => {
188
+ if (sending) return;
189
+ setSending(true);
190
+ setError('');
191
+ const problem = await controller.requestLive({ name, email, phone, message, departmentId: departmentId || undefined });
192
+ setSending(false);
193
+ if (problem) setError(problem);
194
+ };
195
+
196
+ const field = [styles.field, { color: theme.text }];
197
+ return (
198
+ <ScrollView style={styles.flex} contentContainerStyle={styles.form} keyboardShouldPersistTaps="handled">
199
+ <Text style={[styles.formTitle, { color: theme.text }]} accessibilityRole="header">
200
+ {t['live.talk']}
201
+ </Text>
202
+ {guest ? (
203
+ <>
204
+ <Text style={[styles.note, { color: theme.muted }]}>{t['live.intro']}</Text>
205
+ <Text style={[styles.label, { color: theme.muted }]}>{t['form.name']}</Text>
206
+ <TextInput value={name} onChangeText={setName} maxLength={100} autoComplete="name" textContentType="name" style={field} accessibilityLabel={t['form.name']} />
207
+ <Text style={[styles.label, { color: theme.muted }]}>{t['form.email']}</Text>
208
+ <TextInput
209
+ value={email}
210
+ onChangeText={setEmail}
211
+ keyboardType="email-address"
212
+ autoCapitalize="none"
213
+ autoComplete="email"
214
+ textContentType="emailAddress"
215
+ style={field}
216
+ accessibilityLabel={t['form.email']}
217
+ />
218
+ <Text style={[styles.label, { color: theme.muted }]}>{t['form.phone']}</Text>
219
+ <TextInput value={phone} onChangeText={setPhone} keyboardType="phone-pad" autoComplete="tel" textContentType="telephoneNumber" style={field} accessibilityLabel={t['form.phone']} />
220
+ </>
221
+ ) : null}
222
+ {departments.length > 1 ? (
223
+ <>
224
+ <Text style={[styles.label, { color: theme.muted }]}>{t['live.department']}</Text>
225
+ <View style={styles.chips} accessibilityRole="radiogroup">
226
+ {departments.map((d) => {
227
+ const on = d.id === departmentId;
228
+ return (
229
+ <Pressable
230
+ key={d.id}
231
+ onPress={() => setDepartmentId(d.id)}
232
+ accessibilityRole="radio"
233
+ accessibilityState={{ checked: on }}
234
+ style={[styles.chip, { borderColor: theme.accent }, on && { backgroundColor: theme.primary, borderColor: theme.primary }]}
235
+ >
236
+ <Text style={[styles.chipText, { color: on ? theme.onPrimary : theme.accent }]}>{d.name}</Text>
237
+ </Pressable>
238
+ );
239
+ })}
240
+ </View>
241
+ </>
242
+ ) : null}
243
+ <Text style={[styles.label, { color: theme.muted }]}>{t['live.message']}</Text>
244
+ <TextInput value={message} onChangeText={setMessage} multiline maxLength={LIVE_MESSAGE_MAX} style={[field, styles.message]} accessibilityLabel={t['live.message']} />
245
+ {error ? (
246
+ <Text style={[styles.error, { color: theme.error }]} accessibilityRole="alert" accessibilityLiveRegion="polite">
247
+ {error}
248
+ </Text>
249
+ ) : null}
250
+ <View style={styles.actions}>
251
+ <Pressable
252
+ onPress={submit}
253
+ disabled={sending}
254
+ accessibilityRole="button"
255
+ accessibilityState={{ disabled: sending, busy: sending }}
256
+ style={[styles.btn, { backgroundColor: theme.primary }, sending && styles.disabled]}
257
+ >
258
+ <Text style={[styles.btnText, { color: theme.onPrimary }]}>{sending ? t['live.connecting'] : t['live.start']}</Text>
259
+ </Pressable>
260
+ <Pressable onPress={() => controller.backToChat()} accessibilityRole="button" style={[styles.btn, styles.btnGhost, { borderColor: theme.accent }]}>
261
+ <Text style={[styles.btnText, { color: theme.accent }]}>{t['form.cancel']}</Text>
262
+ </Pressable>
263
+ </View>
264
+ </ScrollView>
265
+ );
266
+ }
267
+
268
+ const styles = StyleSheet.create({
269
+ flex: { flex: 1 },
270
+ status: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 12, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e5e7eb', backgroundColor: '#ffffff' },
271
+ statusText: { flex: 1, fontSize: 14, fontWeight: '600' },
272
+ small: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4 },
273
+ smallText: { fontSize: 13, fontWeight: '600' },
274
+ banner: { backgroundColor: '#fef3c7', paddingHorizontal: 12, paddingVertical: 6 },
275
+ bannerText: { color: '#92400e', fontSize: 13 },
276
+ list: { padding: 12, gap: 6 },
277
+ bubble: { maxWidth: '85%', borderRadius: 14, paddingHorizontal: 12, paddingVertical: 8 },
278
+ // המבקר בצד הסוף והנציג בצד ההתחלה, כמו בצ'אט עם הבוט. flex-start מתהפך לבד ב-RTL
279
+ theirs: { alignSelf: 'flex-start', borderWidth: StyleSheet.hairlineWidth, borderColor: '#e5e7eb' },
280
+ mine: { alignSelf: 'flex-end' },
281
+ sending: { opacity: 0.6 },
282
+ author: { fontSize: 12, fontWeight: '600', marginBottom: 2 },
283
+ body: { fontSize: 15, lineHeight: 21 },
284
+ meta: { fontSize: 12, marginTop: 2 },
285
+ system: { alignSelf: 'center', fontSize: 13, textAlign: 'center', paddingVertical: 4 },
286
+ typing: { alignSelf: 'flex-start', fontSize: 13, fontStyle: 'italic', paddingVertical: 2 },
287
+ leave: { alignSelf: 'center', marginTop: 8 },
288
+ final: { alignItems: 'center', paddingVertical: 12 },
289
+ error: { fontSize: 14, paddingHorizontal: 12, paddingVertical: 4 },
290
+ composer: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 8, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#e5e7eb', backgroundColor: '#ffffff' },
291
+ input: { flex: 1, borderRadius: 20, backgroundColor: '#f3f4f6', paddingHorizontal: 14, paddingVertical: Platform.OS === 'ios' ? 10 : 6, fontSize: 15 },
292
+ send: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
293
+ sendIcon: { width: 0, height: 0, borderTopWidth: 8, borderBottomWidth: 8, borderStartWidth: 14, borderTopColor: 'transparent', borderBottomColor: 'transparent', marginStart: 4 },
294
+ disabled: { opacity: 0.5 },
295
+ btn: { borderRadius: 8, paddingHorizontal: 18, paddingVertical: 10, alignItems: 'center' },
296
+ btnGhost: { borderWidth: 1, backgroundColor: 'transparent' },
297
+ btnText: { fontSize: 15, fontWeight: '600' },
298
+ form: { padding: 16, gap: 6 },
299
+ formTitle: { fontSize: 18, fontWeight: '600', marginBottom: 6 },
300
+ note: { fontSize: 14, marginBottom: 4 },
301
+ label: { fontSize: 13, marginTop: 6 },
302
+ field: { borderWidth: 1, borderColor: '#d1d5db', borderRadius: 8, paddingHorizontal: 10, paddingVertical: Platform.OS === 'ios' ? 10 : 6, fontSize: 15, backgroundColor: '#ffffff' },
303
+ message: { minHeight: 90, textAlignVertical: 'top' },
304
+ chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 6 },
305
+ chip: { borderWidth: 1.5, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 7, backgroundColor: '#ffffff' },
306
+ chipText: { fontSize: 14, fontWeight: '600' },
307
+ actions: { flexDirection: 'row', gap: 10, marginTop: 12 },
308
+ });