@webotme/react-native 0.1.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/src/widget.tsx ADDED
@@ -0,0 +1,647 @@
1
+ import React, { useMemo, useState, useEffect, useRef, useCallback } from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ Alert,
5
+ Animated,
6
+ BackHandler,
7
+ Keyboard,
8
+ Linking,
9
+ Modal,
10
+ Pressable,
11
+ StyleSheet,
12
+ Text,
13
+ TextInput,
14
+ View,
15
+ ScrollView,
16
+ KeyboardAvoidingView,
17
+ Platform,
18
+ } from "react-native";
19
+ import { Audio } from "expo-av";
20
+ import { WebView } from "react-native-webview";
21
+ import { useWebotMe } from "./provider";
22
+ import { AutoFlowExecutor } from "./AutoFlowExecutor";
23
+ import type { WebotMeTheme, WebotMeAction, WebotMePendingWorkflow, WebotMeAutoTask, WebotMeScreenContext } from "./types";
24
+
25
+ type TextInputCompatProps = {
26
+ value: string;
27
+ onChange: (v: string) => void;
28
+ onSend: () => void;
29
+ placeholder: string;
30
+ placeholderColor: string;
31
+ disabled?: boolean;
32
+ colors: { text: string; primary: string };
33
+ };
34
+
35
+ export type WidgetMode = "modal" | "inline";
36
+
37
+ export type WebotMeWidgetProps = {
38
+ /** `modal` opens over your app (default). `inline` renders in place. */
39
+ mode?: WidgetMode;
40
+ /** `native` renders a native RN chat UI instead of the web chat page. */
41
+ variant?: "web" | "native";
42
+ /** modal only — the modal stays closed until this is true. */
43
+ visible?: boolean;
44
+ onClose?: () => void;
45
+ title?: string;
46
+ theme?: WebotMeTheme;
47
+ /** Set false to hide the header close button. */
48
+ showClose?: boolean;
49
+ /** Inline mode only — style passed to the wrapper View. */
50
+ style?: any;
51
+ /** Navigation function for AutoFlow navigate actions. */
52
+ navigate?: (routeName: string, params?: Record<string, any>) => void;
53
+ /** Go back function for AutoFlow. */
54
+ goBack?: () => void;
55
+ /** Push function for AutoFlow. */
56
+ push?: (routeName: string, params?: Record<string, any>) => void;
57
+ /** Current screen context for AutoFlow form filling. */
58
+ screenContext?: WebotMeScreenContext;
59
+ /** Called when an AutoFlow action is executed. */
60
+ onActionExecuted?: (action: any, success: boolean, error?: string) => void;
61
+ /** Called when workflow state changes. */
62
+ onWorkflowUpdate?: (workflow: any) => void;
63
+ };
64
+
65
+ const NativeModalChat: React.FC<{
66
+ palette: typeof DEFAULT_THEME;
67
+ title?: string;
68
+ showCloseModal: boolean;
69
+ onClose: () => void;
70
+ messages: ReturnType<typeof useWebotMe>["messages"];
71
+ send: (t: string) => Promise<void>;
72
+ sending: boolean;
73
+ error: string | null;
74
+ retry: () => void;
75
+ botId: string;
76
+ chatUrl: string;
77
+ pendingWorkflow?: WebotMePendingWorkflow | null;
78
+ autoTask?: WebotMeAutoTask | null;
79
+ navigate?: (routeName: string, params?: Record<string, any>) => void;
80
+ goBack?: () => void;
81
+ push?: (routeName: string, params?: Record<string, any>) => void;
82
+ screenContext?: WebotMeScreenContext;
83
+ onActionExecuted?: (action: any, success: boolean, error?: string) => void;
84
+ onWorkflowUpdate?: (workflow: any) => void;
85
+ draft: string;
86
+ setDraft: (v: string) => void;
87
+ sheetHeight: Animated.Value;
88
+ open: boolean;
89
+ close: () => void;
90
+ keyboardHeight: number;
91
+ }> = ({
92
+ palette,
93
+ title,
94
+ showCloseModal,
95
+ onClose,
96
+ messages,
97
+ send,
98
+ sending,
99
+ error,
100
+ retry,
101
+ botId,
102
+ chatUrl,
103
+ pendingWorkflow,
104
+ autoTask,
105
+ navigate,
106
+ goBack,
107
+ push,
108
+ screenContext,
109
+ onActionExecuted,
110
+ onWorkflowUpdate,
111
+ draft,
112
+ setDraft,
113
+ sheetHeight,
114
+ open,
115
+ close,
116
+ keyboardHeight,
117
+ }) => {
118
+ const handleBackPress = useCallback(() => {
119
+ if (open) {
120
+ close();
121
+ return true;
122
+ }
123
+ return false;
124
+ }, [open, close]);
125
+
126
+ useEffect(() => {
127
+ const subscription = BackHandler.addEventListener("hardwareBackPress", handleBackPress);
128
+ return () => subscription.remove();
129
+ }, [handleBackPress]);
130
+
131
+ return (
132
+ <Modal
133
+ visible={open}
134
+ animationType="none"
135
+ transparent
136
+ onRequestClose={close}
137
+ onShow={() => {
138
+ Animated.timing(sheetHeight, {
139
+ toValue: 1,
140
+ duration: 300,
141
+ useNativeDriver: true,
142
+ }).start();
143
+ }}
144
+ >
145
+ <Pressable
146
+ style={styles.backdrop}
147
+ onPress={close}
148
+ accessibilityLabel="Close chat"
149
+ />
150
+ <Animated.View style={[styles.sheet, { transform: [{ translateY: sheetHeight.interpolate({ inputRange: [0, 1], outputRange: ["100%", 0] as any }) }] }]}>
151
+ {showCloseModal && (
152
+ <View style={[styles.header, { backgroundColor: palette.surface }]}>
153
+ <View style={styles.grabber} />
154
+ <Text style={[styles.headerTitle, { color: palette.text }]}>
155
+ {title || "Chat with us"}
156
+ </Text>
157
+ <Pressable onPress={close} hitSlop={10} accessibilityLabel="Close chat">
158
+ <Text style={[styles.close, { color: palette.muted }]}>✕</Text>
159
+ </Pressable>
160
+ </View>
161
+ )}
162
+ <NativeChat
163
+ palette={palette}
164
+ title={title}
165
+ showClose={false}
166
+ onClose={close}
167
+ messages={messages}
168
+ send={send}
169
+ sending={sending}
170
+ error={error}
171
+ retry={retry}
172
+ botId={botId}
173
+ chatUrl={chatUrl}
174
+ pendingWorkflow={pendingWorkflow}
175
+ autoTask={autoTask}
176
+ navigate={navigate}
177
+ goBack={goBack}
178
+ push={push}
179
+ screenContext={screenContext}
180
+ onActionExecuted={onActionExecuted}
181
+ onWorkflowUpdate={onWorkflowUpdate}
182
+ draft={draft}
183
+ setDraft={setDraft}
184
+ />
185
+ <KeyboardAvoidingView
186
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
187
+ keyboardVerticalOffset={0}
188
+ style={styles.keyboardAvoiding}
189
+ >
190
+ <View style={[styles.composer, { borderTopColor: palette.surface }]}>
191
+ <TextInputCompat
192
+ value={draft}
193
+ onChange={setDraft}
194
+ onSend={() => {
195
+ const t = draft;
196
+ setDraft("");
197
+ void send(t);
198
+ }}
199
+ placeholder="Type a message…"
200
+ placeholderColor={palette.muted}
201
+ disabled={sending}
202
+ colors={{ text: palette.text, primary: palette.primary }}
203
+ />
204
+ </View>
205
+ </KeyboardAvoidingView>
206
+ </Animated.View>
207
+ </Modal>
208
+ );
209
+ };
210
+
211
+ const DEFAULT_THEME: Required<Pick<WebotMeTheme, "primary" | "background" | "surface" | "text" | "muted">> = {
212
+ primary: "#4f46e5",
213
+ background: "#0f172a",
214
+ surface: "#1e293b",
215
+ text: "#f8fafc",
216
+ muted: "#94a3b8",
217
+ };
218
+
219
+ export function WebotMeWidget({
220
+ mode = "modal",
221
+ variant = "web",
222
+ visible = false,
223
+ onClose,
224
+ title,
225
+ theme,
226
+ showClose = true,
227
+ style,
228
+ navigate,
229
+ goBack,
230
+ push,
231
+ screenContext,
232
+ onActionExecuted,
233
+ onWorkflowUpdate,
234
+ }: WebotMeWidgetProps) {
235
+ const { chatUrl, botId, messages, send, sending, error, retry, pendingWorkflow, autoTask } = useWebotMe();
236
+
237
+ const open = visible;
238
+ const palette = { ...DEFAULT_THEME, ...(theme || {}) };
239
+
240
+ const close = () => {
241
+ onClose?.();
242
+ };
243
+
244
+ // Shared modal state
245
+ const [draft, setDraft] = useState("");
246
+ const sheetHeight = useRef(new Animated.Value(0)).current;
247
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
248
+
249
+ useEffect(() => {
250
+ const showSubscription = Keyboard.addListener("keyboardDidShow", (e) => {
251
+ setKeyboardHeight(e.endCoordinates.height);
252
+ });
253
+ const hideSubscription = Keyboard.addListener("keyboardDidHide", () => {
254
+ setKeyboardHeight(0);
255
+ });
256
+ return () => {
257
+ showSubscription.remove();
258
+ hideSubscription.remove();
259
+ };
260
+ }, []);
261
+
262
+ useEffect(() => {
263
+ if (open) {
264
+ Animated.timing(sheetHeight, {
265
+ toValue: 1,
266
+ duration: 300,
267
+ useNativeDriver: true,
268
+ }).start();
269
+ } else {
270
+ Animated.timing(sheetHeight, {
271
+ toValue: 0,
272
+ duration: 250,
273
+ useNativeDriver: true,
274
+ }).start(() => {});
275
+ }
276
+ }, [open]);
277
+
278
+ // AutoFlow executor (headless)
279
+ const executorRef = React.useRef<{
280
+ executeAction: (action: any) => Promise<{ success: boolean; error?: string }>;
281
+ executeActions: (actions: any[]) => Promise<void>;
282
+ handleServerResponse: (response: any) => Promise<void>;
283
+ } | null>(null);
284
+
285
+ // Handle pending workflow UI
286
+ const [showWorkflowIndicator, setShowWorkflowIndicator] = useState(false);
287
+
288
+ if (variant === "native") {
289
+ if (mode === "modal") {
290
+ return (
291
+ <NativeModalChat
292
+ palette={palette}
293
+ title={title}
294
+ showCloseModal={showClose}
295
+ onClose={close}
296
+ messages={messages}
297
+ send={send}
298
+ sending={sending}
299
+ error={error}
300
+ retry={retry}
301
+ botId={botId}
302
+ chatUrl={chatUrl}
303
+ pendingWorkflow={pendingWorkflow}
304
+ autoTask={autoTask}
305
+ navigate={navigate}
306
+ goBack={goBack}
307
+ push={push}
308
+ screenContext={screenContext}
309
+ onActionExecuted={onActionExecuted}
310
+ onWorkflowUpdate={onWorkflowUpdate}
311
+ draft={draft}
312
+ setDraft={setDraft}
313
+ sheetHeight={sheetHeight}
314
+ open={open}
315
+ close={close}
316
+ keyboardHeight={keyboardHeight}
317
+ />
318
+ );
319
+ }
320
+ return (
321
+ <NativeChat
322
+ palette={palette}
323
+ title={title}
324
+ showClose={showClose}
325
+ onClose={close}
326
+ messages={messages}
327
+ send={send}
328
+ sending={sending}
329
+ error={error}
330
+ retry={retry}
331
+ botId={botId}
332
+ chatUrl={chatUrl}
333
+ pendingWorkflow={pendingWorkflow}
334
+ autoTask={autoTask}
335
+ navigate={navigate}
336
+ goBack={goBack}
337
+ push={push}
338
+ screenContext={screenContext}
339
+ onActionExecuted={onActionExecuted}
340
+ onWorkflowUpdate={onWorkflowUpdate}
341
+ />
342
+ );
343
+ }
344
+
345
+ const web = (
346
+ <WebView
347
+ source={{ uri: `${chatUrl}?embed=1&bot=${botId}` }}
348
+ style={{ flex: 1, backgroundColor: palette.background }}
349
+ originWhitelist={["*"]}
350
+ allowsInlineMediaPlayback
351
+ mediaPlaybackRequiresUserAction={false}
352
+ setSupportMultipleWindows={false}
353
+ onShouldStartLoadWithRequest={(req: { url: string }): boolean => {
354
+ // Let the chat open external links (whatsapp, checkout) in the browser.
355
+ if (req.url.startsWith("http") && !req.url.includes(chatUrl)) {
356
+ Linking.openURL(req.url).catch(() => {});
357
+ return false;
358
+ }
359
+ return true;
360
+ }}
361
+ />
362
+ );
363
+
364
+ if (mode === "inline") {
365
+ return (
366
+ <View style={[styles.inline, { backgroundColor: palette.background }, style]}>
367
+ {web}
368
+ </View>
369
+ );
370
+ }
371
+
372
+ // Bottom sheet modal for web variant
373
+ useEffect(() => {
374
+ const showSubscription = Keyboard.addListener("keyboardDidShow", (e) => {
375
+ setKeyboardHeight(e.endCoordinates.height);
376
+ });
377
+ const hideSubscription = Keyboard.addListener("keyboardDidHide", () => {
378
+ setKeyboardHeight(0);
379
+ });
380
+ return () => {
381
+ showSubscription.remove();
382
+ hideSubscription.remove();
383
+ };
384
+ }, []);
385
+
386
+ useEffect(() => {
387
+ if (open) {
388
+ Animated.timing(sheetHeight, {
389
+ toValue: 1,
390
+ duration: 300,
391
+ useNativeDriver: true,
392
+ }).start();
393
+ } else {
394
+ Animated.timing(sheetHeight, {
395
+ toValue: 0,
396
+ duration: 250,
397
+ useNativeDriver: true,
398
+ }).start(() => {});
399
+ }
400
+ }, [open]);
401
+
402
+ const handleBackPress = useCallback(() => {
403
+ if (open) {
404
+ close();
405
+ return true;
406
+ }
407
+ return false;
408
+ }, [open, close]);
409
+
410
+ useEffect(() => {
411
+ const subscription = BackHandler.addEventListener("hardwareBackPress", handleBackPress);
412
+ return () => subscription.remove();
413
+ }, [handleBackPress]);
414
+
415
+ const sheetStyle = {
416
+ transform: [
417
+ {
418
+ translateY: sheetHeight.interpolate({
419
+ inputRange: [0, 1],
420
+ outputRange: ["100%", 0] as any,
421
+ }),
422
+ },
423
+ ],
424
+ };
425
+
426
+ return (
427
+ <Modal
428
+ visible={open}
429
+ animationType="none"
430
+ transparent
431
+ onRequestClose={close}
432
+ onShow={() => {
433
+ Animated.timing(sheetHeight, {
434
+ toValue: 1,
435
+ duration: 300,
436
+ useNativeDriver: true,
437
+ }).start();
438
+ }}
439
+ >
440
+ <Pressable
441
+ style={styles.backdrop}
442
+ onPress={close}
443
+ accessibilityLabel="Close chat"
444
+ />
445
+ <Animated.View style={[styles.sheet, sheetStyle]}>
446
+ {showClose && (
447
+ <View style={[styles.header, { backgroundColor: palette.surface }]}>
448
+ <View style={styles.grabber} />
449
+ <Text style={[styles.headerTitle, { color: palette.text }]}>
450
+ {title || "Chat with us"}
451
+ </Text>
452
+ <Pressable onPress={close} hitSlop={10} accessibilityLabel="Close chat">
453
+ <Text style={[styles.close, { color: palette.muted }]}>✕</Text>
454
+ </Pressable>
455
+ </View>
456
+ )}
457
+ <View style={styles.webViewContainer}>
458
+ {web}
459
+ </View>
460
+ <KeyboardAvoidingView
461
+ behavior={Platform.OS === "ios" ? "padding" : "height"}
462
+ keyboardVerticalOffset={0}
463
+ style={styles.keyboardAvoiding}
464
+ >
465
+ <View style={[styles.composer, { borderTopColor: palette.surface }]}>
466
+ <TextInputCompat
467
+ value={draft}
468
+ onChange={setDraft}
469
+ onSend={() => {
470
+ const t = draft;
471
+ setDraft("");
472
+ void send(t);
473
+ }}
474
+ placeholder="Type a message…"
475
+ placeholderColor={palette.muted}
476
+ disabled={sending}
477
+ colors={{ text: palette.text, primary: palette.primary }}
478
+ />
479
+ </View>
480
+ </KeyboardAvoidingView>
481
+ </Animated.View>
482
+ </Modal>
483
+ );
484
+ }
485
+
486
+ type NativeChatProps = {
487
+ palette: typeof DEFAULT_THEME;
488
+ title?: string;
489
+ showClose: boolean;
490
+ onClose: () => void;
491
+ messages: ReturnType<typeof useWebotMe>["messages"];
492
+ send: (t: string) => Promise<void>;
493
+ sending: boolean;
494
+ error: string | null;
495
+ retry: () => void;
496
+ botId: string;
497
+ chatUrl: string;
498
+ pendingWorkflow?: WebotMePendingWorkflow | null;
499
+ autoTask?: WebotMeAutoTask | null;
500
+ navigate?: (routeName: string, params?: Record<string, any>) => void;
501
+ goBack?: () => void;
502
+ push?: (routeName: string, params?: Record<string, any>) => void;
503
+ screenContext?: WebotMeScreenContext;
504
+ onActionExecuted?: (action: any, success: boolean, error?: string) => void;
505
+ onWorkflowUpdate?: (workflow: any) => void;
506
+ /** External draft state (for modal mode) */
507
+ draft?: string;
508
+ setDraft?: (v: string) => void;
509
+ };
510
+
511
+ function NativeChat(props: NativeChatProps): React.ReactElement | null { return null; }
512
+ function TextInputCompat(props: TextInputCompatProps): React.ReactElement {
513
+ const { value, onChange, onSend, placeholder, placeholderColor, disabled, colors } = props;
514
+ return (
515
+ <View style={styles.composerRow}>
516
+ <TextInput
517
+ value={value}
518
+ onChangeText={onChange}
519
+ placeholder={placeholder}
520
+ placeholderTextColor={placeholderColor}
521
+ style={[styles.input, { color: colors.text }]}
522
+ onSubmitEditing={onSend}
523
+ returnKeyType="send"
524
+ editable={!disabled}
525
+ />
526
+ <Pressable
527
+ onPress={onSend}
528
+ disabled={disabled}
529
+ style={[styles.send, { backgroundColor: colors.primary, opacity: disabled ? 0.5 : 1 }]}
530
+ accessibilityLabel="Send message"
531
+ >
532
+ <Text style={styles.sendText}>↑</Text>
533
+ </Pressable>
534
+ </View>
535
+ );
536
+ }
537
+
538
+ const styles = StyleSheet.create({
539
+ flex: { flex: 1 },
540
+ inline: { flex: 1, overflow: "hidden" },
541
+ center: { flex: 1, alignItems: "center", justifyContent: "center" },
542
+ header: {
543
+ flexDirection: "row",
544
+ alignItems: "center",
545
+ justifyContent: "space-between",
546
+ paddingHorizontal: 16,
547
+ paddingVertical: 14,
548
+ },
549
+ headerTitle: { fontSize: 15, fontWeight: "700" },
550
+ close: { fontSize: 18, fontWeight: "700" },
551
+ messages: { flex: 1, padding: 12, gap: 8 },
552
+ row: { flexDirection: "row" },
553
+ rowUser: { justifyContent: "flex-end" },
554
+ rowBot: { justifyContent: "flex-start" },
555
+ bubble: { maxWidth: "80%", borderRadius: 14, paddingHorizontal: 12, paddingVertical: 8 },
556
+ bubbleText: { fontSize: 14, lineHeight: 20 },
557
+ actionBadge: {
558
+ marginTop: 4,
559
+ paddingHorizontal: 8,
560
+ paddingVertical: 2,
561
+ borderRadius: 4,
562
+ backgroundColor: "rgba(255,255,255,0.2)",
563
+ },
564
+ actionBadgeText: {
565
+ fontSize: 10,
566
+ color: "#fff",
567
+ },
568
+ workflowBanner: {
569
+ paddingHorizontal: 16,
570
+ paddingVertical: 8,
571
+ borderBottomWidth: 1,
572
+ borderBottomColor: "rgba(255,255,255,0.1)",
573
+ },
574
+ workflowBannerText: {
575
+ fontSize: 12,
576
+ color: "#fff",
577
+ textAlign: "center",
578
+ },
579
+ error: { margin: 12, borderWidth: 1, borderRadius: 10, padding: 10 },
580
+ errorText: { fontSize: 12, textAlign: "center" },
581
+ composer: { borderTopWidth: StyleSheet.hairlineWidth, padding: 10 },
582
+ composerRow: { flexDirection: "row", alignItems: "center", gap: 8 },
583
+ input: { flex: 1, fontSize: 14, paddingHorizontal: 12, paddingVertical: 10, borderRadius: 20 },
584
+ send: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" },
585
+ sendText: { color: "#fff", fontSize: 18, fontWeight: "700" },
586
+ debug: { fontSize: 9, textAlign: "center", paddingBottom: 4 },
587
+
588
+ // Voice styles
589
+ micButton: {
590
+ width: 40,
591
+ height: 40,
592
+ borderRadius: 20,
593
+ alignItems: "center",
594
+ justifyContent: "center",
595
+ borderWidth: 1,
596
+ borderColor: "#334155",
597
+ },
598
+ micButtonText: { fontSize: 20 },
599
+ recordingIndicator: {
600
+ position: "absolute",
601
+ bottom: -18,
602
+ left: 0,
603
+ right: 0,
604
+ textAlign: "center",
605
+ fontSize: 10,
606
+ color: "#ef4444",
607
+ fontWeight: "600",
608
+ },
609
+ ttsButton: {
610
+ marginTop: 4,
611
+ paddingHorizontal: 8,
612
+ paddingVertical: 2,
613
+ borderRadius: 4,
614
+ backgroundColor: "rgba(79, 70, 229, 0.2)",
615
+ },
616
+ ttsButtonText: {
617
+ fontSize: 12,
618
+ },
619
+
620
+ // Modal bottom sheet styles
621
+ backdrop: {
622
+ flex: 1,
623
+ backgroundColor: "rgba(0,0,0,0.5)",
624
+ },
625
+ sheet: {
626
+ flex: 1,
627
+ backgroundColor: "#0f172a",
628
+ borderTopLeftRadius: 24,
629
+ borderTopRightRadius: 24,
630
+ overflow: "hidden",
631
+ },
632
+ grabber: {
633
+ width: 40,
634
+ height: 4,
635
+ borderRadius: 2,
636
+ backgroundColor: "#334155",
637
+ alignSelf: "center",
638
+ marginTop: 12,
639
+ marginBottom: 8,
640
+ },
641
+ webViewContainer: {
642
+ flex: 1,
643
+ },
644
+ keyboardAvoiding: {
645
+ flex: 1,
646
+ },
647
+ });