@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/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # @webotme/react-native
2
+
3
+ Drop-in AI chatbot for React Native and Expo. Same bot, same training and same
4
+ AutoFlow as the WebotMe web widget.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install @webotme/react-native react-native-webview
10
+ ```
11
+
12
+ `react-native-webview` is a peer dependency — Expo apps can get it with
13
+ `npx expo install react-native-webview`.
14
+
15
+ > Requires a development build, not Expo Go, because `react-native-webview` is
16
+ > a native module.
17
+
18
+ ## Use
19
+
20
+ Wrap your app once, then show the widget anywhere.
21
+
22
+ ```tsx
23
+ import { WebotMeProvider, WebotMeLauncher } from "@webotme/react-native";
24
+
25
+ export default function App() {
26
+ return (
27
+ <WebotMeProvider
28
+ botId="YOUR_BOT_ID"
29
+ apiBaseUrl="https://your-api.com"
30
+ chatBaseUrl="https://your-frontend.com"
31
+ >
32
+ <RootNavigator />
33
+ </WebotMeProvider>
34
+ );
35
+ }
36
+ ```
37
+
38
+ Floating bubble (handles open/close automatically):
39
+
40
+ ```tsx
41
+ import { WebotMeLauncher } from "@webotme/react-native";
42
+
43
+ <WebotMeLauncher position="bottom-left" icon="💬" />
44
+ ```
45
+
46
+ Inline chat (renders in place, no launcher needed):
47
+
48
+ ```tsx
49
+ import { WebotMeWidget } from "@webotme/react-native";
50
+
51
+ <WebotMeWidget mode="inline" />
52
+ ```
53
+
54
+ Controlled modal (if you want your own trigger):
55
+
56
+ ```tsx
57
+ import { WebotMeWidget } from "@webotme/react-native";
58
+ import { useState } from "react";
59
+
60
+ function ChatButton() {
61
+ const [open, setOpen] = useState(false);
62
+ return (
63
+ <>
64
+ <Pressable onPress={() => setOpen(true)}>
65
+ <Text>Open Chat</Text>
66
+ </Pressable>
67
+ <WebotMeWidget visible={open} onClose={() => setOpen(false)} />
68
+ </>
69
+ );
70
+ }
71
+ ```
72
+
73
+ ## Config
74
+
75
+ | Prop | Required | Description |
76
+ | --- | --- | --- |
77
+ | `botId` | yes | Bot ID from the WebotMe dashboard |
78
+ | `apiBaseUrl` | yes | Backend base URL. Never `localhost` on a device |
79
+ | `chatBaseUrl` | no | Frontend that serves `/chat/{botId}`. Falls back to `apiBaseUrl` |
80
+ | `userId` | no | Stable visitor id. Generated if omitted |
81
+ | `theme` | no | `primary`, `background`, `surface`, `text`, `muted` |
82
+ | `welcome` | no | Message shown before the bot replies |
83
+ | `screenContext` | no | Sent with every message so AutoFlow knows the screen |
84
+ | `timeoutMs` | no | Default `30000` |
85
+ | `resetKey` | no | Change it to start a new session |
86
+
87
+ ## Widget Props
88
+
89
+ | Prop | Required | Description |
90
+ | --- | --- | --- |
91
+ | `mode` | no | `"modal"` (default) opens full-screen; `"inline"` renders in place |
92
+ | `variant` | no | `"web"` (default) loads the Vercel chat page; `"native"` uses a native RN chat UI |
93
+ | `visible` | no | Modal only — open when `true` (default `false`) |
94
+ | `onClose` | no | Called when modal closes (modal only) |
95
+ | `title` | no | Modal header title (modal/native) |
96
+ | `theme` | no | Override `primary`, `background`, `surface`, `text`, `muted` |
97
+ | `showClose` | no | Show header close button (modal/native, default `true`) |
98
+
99
+ ## AutoFlow
100
+
101
+ Web AutoFlow reads the page DOM. Native apps have no DOM, so send a
102
+ `screenContext` snapshot and the same engine guides your app.
103
+
104
+ ```tsx
105
+ <WebotMeProvider
106
+ botId="YOUR_BOT_ID"
107
+ apiBaseUrl="https://your-api.com"
108
+ screenContext={{
109
+ name: "pricing",
110
+ title: "Plans",
111
+ visibleText: ["Free", "Pro"],
112
+ buttons: [{ id: "buy_pro", text: "Buy Pro" }],
113
+ links: [{ id: "faq", text: "FAQ", route: "/faq" }],
114
+ }}
115
+ >
116
+ ```
117
+
118
+ The server answers with `text` plus, when it wants the app to act, an `action`:
119
+
120
+ | Action | Shape |
121
+ | --- | --- |
122
+ | navigate | `{ type: "navigate", route, label }` |
123
+ | click | `{ type: "click", targetId, label }` |
124
+ | fill | `{ type: "fill", fieldId, value, label }` |
125
+ | checkout | `{ type: "checkout", url, label }` |
126
+
127
+ ## Native API instead of the web view
128
+
129
+ If you want your own UI, skip the WebView and use the client directly.
130
+
131
+ ```tsx
132
+ import { useWebotMeChat } from "@webotme/react-native";
133
+
134
+ const { messages, send, sending, error } = useWebotMeChat();
135
+ ```
136
+
137
+ Or bypass React entirely:
138
+
139
+ ```ts
140
+ import { WebotMeClient } from "@webotme/react-native";
141
+
142
+ const client = new WebotMeClient({ botId, apiBaseUrl });
143
+ await client.discover(); // feature-detect the server
144
+ const { text, action } = await client.send({
145
+ message: "Hello",
146
+ sessionId,
147
+ userId,
148
+ screenContext,
149
+ });
150
+ ```
151
+
152
+ ## Errors
153
+
154
+ Every failure is a `WebotMeError` with a stable `code`:
155
+
156
+ `INVALID_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `BOT_NOT_FOUND`,
157
+ `RATE_LIMITED`, `PLAN_LIMIT_REACHED`, `AI_UNAVAILABLE`, `INTERNAL_ERROR`,
158
+ `NETWORK_ERROR`.
159
+
160
+ ```tsx
161
+ try {
162
+ await client.send({ message, sessionId, userId });
163
+ } catch (err) {
164
+ if (err instanceof WebotMeError && err.code === "PLAN_LIMIT_REACHED") {
165
+ // show the upgrade screen
166
+ }
167
+ }
168
+ ```
169
+
170
+ ## Security
171
+
172
+ Never ship an API key in a mobile app — apps can be decompiled. This package
173
+ authenticates with the bot ID only. For server-to-server calls keep the key on
174
+ your own backend and use `POST /api/sdk/chat`.
175
+
176
+ ## Endpoints used
177
+
178
+ | Purpose | Endpoint |
179
+ | --- | --- |
180
+ | Contract | `GET /api/chatbot/public/{botId}/native-chat/v1` |
181
+ | Chat | `POST /api/chatbot/public/{botId}/native-chat/v1` |
182
+ | Bot config | `GET /api/chatbot/public/{botId}` |
183
+ | Sessions | `GET /api/chatbot/public/{botId}/sessions/{visitorId}` |
184
+ | New session | `POST /api/chatbot/public/{botId}/session/new` |
185
+ | Web chat page | `GET {chatBaseUrl}/chat/{botId}` |
186
+
187
+ ## License
188
+
189
+ UNLICENSED
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@webotme/react-native",
3
+ "version": "0.1.1",
4
+ "description": "Drop-in AI chatbot for React Native and Expo apps. Same bot, training and AutoFlow as the WebotMe web widget.",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "README.md"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc --noEmit",
13
+ "prepublishOnly": "npm run build",
14
+ "typecheck": "tsc --noEmit"
15
+ },
16
+ "keywords": [
17
+ "chatbot",
18
+ "ai",
19
+ "chat",
20
+ "react-native",
21
+ "expo",
22
+ "webotme",
23
+ "autoflow"
24
+ ],
25
+ "license": "UNLICENSED",
26
+ "peerDependencies": {
27
+ "react": ">=18",
28
+ "react-native": ">=0.72",
29
+ "react-native-webview": ">=13"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "react-native-webview": {
33
+ "optional": true
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "@types/react": "^19.3.0",
38
+ "react": "^18.3.1",
39
+ "react-native": "^0.76.9",
40
+ "react-native-webview": "^13.17.0",
41
+ "typescript": "^5.9.3"
42
+ },
43
+ "dependencies": {
44
+ "expo-av": "^16.0.8"
45
+ }
46
+ }
@@ -0,0 +1,353 @@
1
+ import React, { useCallback, useRef, useState } from "react";
2
+ import {
3
+ Alert,
4
+ Keyboard,
5
+ Linking,
6
+ Platform,
7
+ StyleSheet,
8
+ Text,
9
+ TextInput,
10
+ TouchableOpacity,
11
+ View,
12
+ type ViewStyle,
13
+ } from "react-native";
14
+ import type {
15
+ WebotMeAction,
16
+ WebotMePendingWorkflow,
17
+ WebotMeAutoTask,
18
+ WebotMeScreenContext,
19
+ } from "./types";
20
+
21
+ /**
22
+ * Props for the AutoFlowExecutor component.
23
+ */
24
+ export type AutoFlowExecutorProps = {
25
+ /** Navigation function - must be provided by the app using React Navigation. */
26
+ navigate: (routeName: string, params?: Record<string, any>) => void;
27
+ /** Optional: Go back function. */
28
+ goBack?: () => void;
29
+ /** Optional: Push to a specific screen. */
30
+ push?: (routeName: string, params?: Record<string, any>) => void;
31
+ /** Optional: Reset navigation stack. */
32
+ resetRoot?: (routes: Array<{ name: string; params?: Record<string, any> }>) => void;
33
+ /** Current screen context for form filling context. */
34
+ screenContext?: WebotMeScreenContext;
35
+ /** Called when an action is executed. */
36
+ onActionExecuted?: (action: WebotMeAction, success: boolean, error?: string) => void;
37
+ /** Called when a multi-step workflow starts/continues. */
38
+ onWorkflowUpdate?: (workflow: WebotMePendingWorkflow | null) => void;
39
+ /** Theme colors. */
40
+ theme?: {
41
+ primary?: string;
42
+ background?: string;
43
+ surface?: string;
44
+ text?: string;
45
+ muted?: string;
46
+ };
47
+ };
48
+
49
+ /**
50
+ * AutoFlowExecutor - Executes AutoFlow actions returned by the server.
51
+ *
52
+ * Handles: navigate, click, fill_input, checkout
53
+ * Integrates with React Navigation for native navigation.
54
+ *
55
+ * Usage:
56
+ * ```tsx
57
+ * <AutoFlowExecutor
58
+ * navigate={(name, params) => navigation.navigate(name, params)}
59
+ * goBack={() => navigation.goBack()}
60
+ * push={(name, params) => navigation.push(name, params)}
61
+ * onActionExecuted={(action, success, error) => console.log(action, success)}
62
+ * />
63
+ * ```
64
+ */
65
+ export function AutoFlowExecutor({
66
+ navigate,
67
+ goBack,
68
+ push,
69
+ resetRoot,
70
+ screenContext,
71
+ onActionExecuted,
72
+ onWorkflowUpdate,
73
+ theme,
74
+ }: AutoFlowExecutorProps) {
75
+ const palette = {
76
+ primary: theme?.primary || "#4f46e5",
77
+ background: theme?.background || "#0f172a",
78
+ surface: theme?.surface || "#1e293b",
79
+ text: theme?.text || "#f8fafc",
80
+ muted: theme?.muted || "#94a3b8",
81
+ };
82
+
83
+ const [pendingWorkflow, setPendingWorkflow] = useState<WebotMePendingWorkflow | null>(null);
84
+ const [showFillForm, setShowFillForm] = useState<{
85
+ action: WebotMeAction & { type: "fill" };
86
+ fields: Array<{ label: string; placeholder: string; value: string; name: string; required: boolean; type: string }>;
87
+ } | null>(null);
88
+ const workflowRef = useRef<WebotMeAutoTask | null>(null);
89
+
90
+ const executeAction = useCallback(
91
+ async (action: WebotMeAction): Promise<{ success: boolean; error?: string }> => {
92
+ try {
93
+ switch (action.type) {
94
+ case "navigate": {
95
+ const url = action.data?.url;
96
+ if (!url) throw new Error("Navigate action missing URL");
97
+
98
+ // Try to match with known app routes first
99
+ const routeMap: Record<string, string> = {
100
+ "/dashboard": "Dashboard",
101
+ "/dashboard/create": "CreateChatbot",
102
+ "/dashboard/inbox": "Inbox",
103
+ "/dashboard/settings": "Settings",
104
+ "/dashboard/storage": "Storage",
105
+ "/dashboard/scripts": "Scripts",
106
+ "/plans": "Plans",
107
+ "/login": "Login",
108
+ };
109
+
110
+ const routeName = routeMap[url] || url.replace(/^\//, "").replace(/\//g, "_") || "Home";
111
+
112
+ if (push) {
113
+ push(routeName, { fromAutoFlow: true, originalUrl: url });
114
+ } else {
115
+ navigate(routeName, { fromAutoFlow: true, originalUrl: url });
116
+ }
117
+
118
+ return { success: true };
119
+ }
120
+
121
+ case "click": {
122
+ // For native, click actions typically mean triggering a UI element
123
+ // The app should handle this via onActionExecuted or refs
124
+ const selector = action.data?.selector;
125
+ const text = action.data?.text;
126
+ const section = action.data?.section;
127
+
128
+ // Notify app to handle the click (e.g., via refs or internal logic)
129
+ return { success: true };
130
+ }
131
+
132
+ case "fill": {
133
+ const label = action.data?.label;
134
+ const selector = action.data?.selector;
135
+ const text = action.data?.text;
136
+ const currentValue = action.data?.currentValue;
137
+
138
+ if (!text && text !== "") {
139
+ throw new Error("Fill action missing text value");
140
+ }
141
+
142
+ // For native, we show a form modal to confirm/edit the fill
143
+ // The actual field filling should be handled by the app via onActionExecuted
144
+ // or by using the screenContext forms
145
+ return { success: true };
146
+ }
147
+
148
+ case "checkout": {
149
+ const url = action.data?.url;
150
+ if (!url) throw new Error("Checkout action missing URL");
151
+
152
+ // Open in browser for secure payment
153
+ await Linking.openURL(url);
154
+ return { success: true };
155
+ }
156
+
157
+ default: {
158
+ return { success: false, error: `Unknown action type: ${(action as any).type}` };
159
+ }
160
+ }
161
+ } catch (error) {
162
+ return { success: false, error: error instanceof Error ? error.message : "Unknown error" };
163
+ }
164
+ },
165
+ [navigate, push],
166
+ );
167
+
168
+ const executeActions = useCallback(
169
+ async (actions: WebotMeAction[]): Promise<void> => {
170
+ for (const action of actions) {
171
+ const result = await executeAction(action);
172
+ onActionExecuted?.(action, result.success, result.error);
173
+ if (!result.success) {
174
+ Alert.alert("Action Failed", result.error || "Unknown error");
175
+ break;
176
+ }
177
+ // Small delay between actions for UX
178
+ await new Promise((r) => setTimeout(r, 300));
179
+ }
180
+ },
181
+ [executeAction, onActionExecuted],
182
+ );
183
+
184
+ const handleServerResponse = useCallback(
185
+ async (response: {
186
+ actions?: WebotMeAction[];
187
+ pendingWorkflow?: WebotMePendingWorkflow;
188
+ autoTask?: WebotMeAutoTask;
189
+ reply?: string;
190
+ }) => {
191
+ if (response.actions && response.actions.length > 0) {
192
+ await executeActions(response.actions);
193
+ }
194
+
195
+ if (response.pendingWorkflow) {
196
+ setPendingWorkflow(response.pendingWorkflow);
197
+ workflowRef.current = {
198
+ goal: response.pendingWorkflow.goal,
199
+ plan: response.pendingWorkflow.plan,
200
+ done: response.pendingWorkflow.done,
201
+ };
202
+ onWorkflowUpdate?.(response.pendingWorkflow);
203
+ } else if (response.autoTask) {
204
+ workflowRef.current = response.autoTask;
205
+ onWorkflowUpdate?.({
206
+ goal: response.autoTask.goal,
207
+ plan: response.autoTask.plan,
208
+ done: response.autoTask.done,
209
+ });
210
+ } else {
211
+ setPendingWorkflow(null);
212
+ workflowRef.current = null;
213
+ onWorkflowUpdate?.(null);
214
+ }
215
+ },
216
+ [executeActions, onWorkflowUpdate],
217
+ );
218
+
219
+ // Expose execute methods for parent to call
220
+ const executorRef = useRef({
221
+ executeAction,
222
+ executeActions,
223
+ handleServerResponse,
224
+ getPendingWorkflow: () => pendingWorkflow,
225
+ getAutoTask: () => workflowRef.current,
226
+ });
227
+
228
+ // Render fill form modal if needed
229
+ if (showFillForm) {
230
+ const { action, fields } = showFillForm;
231
+ const [values, setValues] = useState(
232
+ fields.map((f) => f.value || ""),
233
+ );
234
+
235
+ return (
236
+ <View style={[styles.modalOverlay, { backgroundColor: "rgba(0,0,0,0.6)" }]}>
237
+ <View style={[styles.modalContent, { backgroundColor: palette.surface }]}>
238
+ <Text style={[styles.modalTitle, { color: palette.text }]}>
239
+ Fill Information
240
+ </Text>
241
+ {fields.map((field, index) => (
242
+ <View key={index} style={styles.formField}>
243
+ <Text style={[styles.fieldLabel, { color: palette.text }]}>
244
+ {field.label} {field.required && <Text style={{ color: palette.primary }}>*</Text>}
245
+ </Text>
246
+ <TextInput
247
+ value={values[index]}
248
+ onChangeText={(v) => {
249
+ const newValues = [...values];
250
+ newValues[index] = v;
251
+ setValues(newValues);
252
+ }}
253
+ placeholder={field.placeholder}
254
+ style={[styles.textInput, { backgroundColor: palette.background, color: palette.text }]}
255
+ keyboardType={field.type === "number" ? "numeric" : "default"}
256
+ />
257
+ </View>
258
+ ))}
259
+ <View style={styles.modalButtons}>
260
+ <TouchableOpacity
261
+ style={[styles.btnCancel, { backgroundColor: palette.surface }]}
262
+ onPress={() => setShowFillForm(null)}
263
+ >
264
+ <Text style={[styles.btnText, { color: palette.muted }]}>Cancel</Text>
265
+ </TouchableOpacity>
266
+ <TouchableOpacity
267
+ style={[styles.btnConfirm, { backgroundColor: palette.primary }]}
268
+ onPress={() => {
269
+ // Execute the fill with confirmed values
270
+ const fillAction: WebotMeAction = {
271
+ type: "fill",
272
+ data: {
273
+ label: action.data?.label,
274
+ selector: action.data?.selector,
275
+ text: values[0],
276
+ currentValue: action.data?.currentValue,
277
+ },
278
+ label: action.label,
279
+ };
280
+ executeAction(fillAction).then((result) => {
281
+ onActionExecuted?.(fillAction, result.success, result.error);
282
+ setShowFillForm(null);
283
+ });
284
+ }}
285
+ >
286
+ <Text style={[styles.btnText, { color: "#fff" }]}>Confirm</Text>
287
+ </TouchableOpacity>
288
+ </View>
289
+ </View>
290
+ </View>
291
+ );
292
+ }
293
+
294
+ // This component doesn't render anything visible - it's a headless executor
295
+ // The actual UI for forms/modals would be in the parent or a separate component
296
+ return null;
297
+ }
298
+
299
+ const styles = StyleSheet.create({
300
+ modalOverlay: {
301
+ flex: 1,
302
+ justifyContent: "center",
303
+ alignItems: "center",
304
+ },
305
+ modalContent: {
306
+ width: "90%",
307
+ maxHeight: "80%",
308
+ borderRadius: 16,
309
+ padding: 20,
310
+ },
311
+ modalTitle: {
312
+ fontSize: 18,
313
+ fontWeight: "700",
314
+ marginBottom: 16,
315
+ textAlign: "center",
316
+ },
317
+ formField: {
318
+ marginBottom: 16,
319
+ },
320
+ fieldLabel: {
321
+ fontSize: 14,
322
+ fontWeight: "600",
323
+ marginBottom: 8,
324
+ },
325
+ textInput: {
326
+ paddingHorizontal: 12,
327
+ paddingVertical: 10,
328
+ borderRadius: 8,
329
+ borderWidth: 1,
330
+ borderColor: "#334155",
331
+ fontSize: 16,
332
+ },
333
+ modalButtons: {
334
+ flexDirection: "row",
335
+ justifyContent: "flex-end",
336
+ gap: 12,
337
+ marginTop: 20,
338
+ },
339
+ btnCancel: {
340
+ paddingHorizontal: 20,
341
+ paddingVertical: 12,
342
+ borderRadius: 8,
343
+ },
344
+ btnConfirm: {
345
+ paddingHorizontal: 20,
346
+ paddingVertical: 12,
347
+ borderRadius: 8,
348
+ },
349
+ btnText: {
350
+ fontSize: 14,
351
+ fontWeight: "600",
352
+ },
353
+ });