@upstep/react-native 0.6.15 → 0.7.0

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,531 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import {
3
+ ActivityIndicator,
4
+ AppState,
5
+ Linking,
6
+ ScrollView,
7
+ StyleSheet,
8
+ Text,
9
+ TextInput,
10
+ TouchableOpacity,
11
+ View,
12
+ } from "react-native";
13
+ import { SupportClient, supportMessageId } from "@upstep/js/support";
14
+ import type {
15
+ SupportConfiguration,
16
+ SupportConversation,
17
+ SupportWidget,
18
+ } from "@upstep/types";
19
+ export function SupportView({
20
+ client,
21
+ config,
22
+ dark = false,
23
+ }: {
24
+ client: SupportClient;
25
+ config: SupportConfiguration;
26
+ dark?: boolean;
27
+ }) {
28
+ const [chat, setChat] = useState<SupportConversation | null>(null);
29
+ const [text, setText] = useState("");
30
+ const [busy, setBusy] = useState(false);
31
+ const [error, setError] = useState("");
32
+ const list = useRef<React.ElementRef<typeof ScrollView>>(null);
33
+ const active = useRef(true);
34
+ const pending = useRef<{ text: string; id: string } | null>(null);
35
+ const colors = {
36
+ bg: dark ? "#000000" : "#ffffff",
37
+ soft: dark ? "#191d1b" : "#f4f6f4",
38
+ text: dark ? "#e5e7e5" : "#37413c",
39
+ muted: dark ? "#a2aaa5" : "#69756d",
40
+ line: dark ? "#303833" : "#dde4de",
41
+ };
42
+ async function refresh(id: string) {
43
+ const next = await client.conversation(id);
44
+ if (active.current) setChat(next);
45
+ }
46
+ useEffect(() => {
47
+ active.current = true;
48
+ void client
49
+ .conversations()
50
+ .then((items) => {
51
+ if (items[0]) return refresh(items[0].id);
52
+ })
53
+ .catch((e) => setError(e.message));
54
+ return () => {
55
+ active.current = false;
56
+ };
57
+ }, [client]);
58
+ useEffect(() => {
59
+ if (!chat) return;
60
+ const id = chat.id;
61
+ const timer = setInterval(() => {
62
+ if (AppState.currentState === "active")
63
+ void refresh(id).catch((e) => {
64
+ if (active.current) setError(e.message);
65
+ });
66
+ }, 2500);
67
+ return () => clearInterval(timer);
68
+ }, [chat?.id, client]);
69
+ async function perform(fn: () => Promise<void>) {
70
+ if (busy) return false;
71
+ setBusy(true);
72
+ setError("");
73
+ try {
74
+ await fn();
75
+ return true;
76
+ } catch (e) {
77
+ setError((e as Error).message);
78
+ return false;
79
+ } finally {
80
+ if (active.current) setBusy(false);
81
+ }
82
+ }
83
+ async function send(value = text) {
84
+ return perform(async () => {
85
+ if (!value.trim()) return;
86
+ const c =
87
+ !chat || chat.state === "RESOLVED"
88
+ ? await client.startConversation()
89
+ : chat;
90
+ if (!pending.current || pending.current.text !== value)
91
+ pending.current = { text: value, id: supportMessageId() };
92
+ await client.send(c.id, value, pending.current.id);
93
+ pending.current = null;
94
+ if (active.current) setText("");
95
+ await refresh(c.id);
96
+ });
97
+ }
98
+ return (
99
+ <View style={[styles.root, { backgroundColor: colors.bg }]}>
100
+ <View style={[styles.heading, { borderColor: colors.line }]}>
101
+ <Text style={[styles.title, { color: colors.text }]}>
102
+ {config.businessName}
103
+ </Text>
104
+ <Text style={[styles.meta, { color: colors.muted }]}>
105
+ {chat?.state === "HUMAN"
106
+ ? "The team is here to help"
107
+ : "AI support for your questions"}
108
+ </Text>
109
+ </View>
110
+ {chat?.ticketReference && (
111
+ <View style={[styles.ticket, { backgroundColor: colors.soft }]}>
112
+ <Text selectable style={[styles.label, { color: colors.text }]}>
113
+ Ticket {chat.ticketReference}
114
+ </Text>
115
+ {chat.supportEmail && (
116
+ <TouchableOpacity
117
+ onPress={() =>
118
+ void Linking.openURL(
119
+ `mailto:${chat.supportEmail}?subject=${encodeURIComponent(chat.ticketReference || "")}`,
120
+ )
121
+ }
122
+ >
123
+ <Text style={[styles.meta, { color: colors.muted }]}>
124
+ {chat.supportEmail}
125
+ </Text>
126
+ </TouchableOpacity>
127
+ )}
128
+ </View>
129
+ )}
130
+ <ScrollView
131
+ ref={list}
132
+ style={{ flex: 1 }}
133
+ contentContainerStyle={{ padding: 16, gap: 16 }}
134
+ keyboardShouldPersistTaps="handled"
135
+ onContentSizeChange={() => {
136
+ if (chat) list.current?.scrollToEnd({ animated: true });
137
+ }}
138
+ >
139
+ {!chat && (
140
+ <View style={{ gap: 16, paddingVertical: 12 }}>
141
+ <Text style={[styles.hero, { color: colors.text }]}>
142
+ How can we help?
143
+ </Text>
144
+ <Text style={[styles.body, { color: colors.muted }]}>
145
+ {config.welcomeMessage}
146
+ </Text>
147
+ {[
148
+ "What can you help me with?",
149
+ `I need help using ${config.businessName}`,
150
+ ].map((q) => (
151
+ <TouchableOpacity
152
+ key={q}
153
+ style={[styles.option, { borderColor: colors.line }]}
154
+ onPress={() => setText(q)}
155
+ >
156
+ <Text style={[styles.body, { color: colors.text }]}>{q}</Text>
157
+ </TouchableOpacity>
158
+ ))}
159
+ </View>
160
+ )}
161
+ {chat?.messages.map((m) => (
162
+ <View
163
+ key={m.id}
164
+ style={{
165
+ alignSelf: m.role === "CUSTOMER" ? "flex-end" : "stretch",
166
+ maxWidth: m.role === "CUSTOMER" ? "94%" : "100%",
167
+ }}
168
+ >
169
+ <Text
170
+ style={[styles.meta, { color: colors.muted, marginBottom: 6 }]}
171
+ >
172
+ {m.role === "CUSTOMER" ? "You" : m.authorName}
173
+ </Text>
174
+ <View
175
+ style={[
176
+ styles.bubble,
177
+ {
178
+ backgroundColor:
179
+ m.role === "SYSTEM" ? "transparent" : colors.soft,
180
+ },
181
+ ]}
182
+ >
183
+ {m.content.split(/\n\s*\n/).map((paragraph, i) => (
184
+ <Text
185
+ key={i}
186
+ selectable
187
+ style={[styles.body, { color: colors.text, marginBottom: 8 }]}
188
+ >
189
+ {paragraph}
190
+ </Text>
191
+ ))}
192
+ {m.metadata?.citations?.map((c) => (
193
+ <TouchableOpacity
194
+ key={c.url}
195
+ onPress={() => void Linking.openURL(c.url)}
196
+ >
197
+ <Text
198
+ numberOfLines={1}
199
+ style={[styles.meta, { color: colors.muted, marginTop: 8 }]}
200
+ >
201
+ ↗ {c.title}
202
+ </Text>
203
+ </TouchableOpacity>
204
+ ))}
205
+ </View>
206
+ {m.id === chat.activeWidgetMessageId &&
207
+ m.metadata?.widgets
208
+ ?.filter((w) => w.type !== "contact_form" || chat.canEscalate)
209
+ .map((w) => (
210
+ <NativeSupportWidget
211
+ key={w.id}
212
+ widget={w}
213
+ busy={busy}
214
+ color={colors}
215
+ name={chat.customer.name || ""}
216
+ email={chat.customer.email || ""}
217
+ verified={chat.customer.verified}
218
+ onReply={send}
219
+ onContact={(name, email) =>
220
+ perform(async () => {
221
+ await client.handoff(chat.id, name, email);
222
+ await refresh(chat.id);
223
+ })
224
+ }
225
+ onDetails={(values) =>
226
+ perform(async () => {
227
+ await client.details(chat.id, values);
228
+ await refresh(chat.id);
229
+ })
230
+ }
231
+ />
232
+ ))}
233
+ </View>
234
+ ))}
235
+ {chat?.canEscalate && !chat.activeWidgetMessageId && (
236
+ <NativeSupportWidget
237
+ widget={{
238
+ id: `contact:${chat.id}`,
239
+ type: "contact_form",
240
+ title: "Let the team follow up",
241
+ summary: chat.escalationSummary || "",
242
+ }}
243
+ busy={busy}
244
+ color={colors}
245
+ name={chat.customer.name || ""}
246
+ email={chat.customer.email || ""}
247
+ verified={chat.customer.verified}
248
+ onReply={send}
249
+ onContact={(name, email) =>
250
+ perform(async () => {
251
+ await client.handoff(chat.id, name, email);
252
+ await refresh(chat.id);
253
+ })
254
+ }
255
+ onDetails={(values) =>
256
+ perform(async () => {
257
+ await client.details(chat.id, values);
258
+ await refresh(chat.id);
259
+ })
260
+ }
261
+ />
262
+ )}
263
+ {chat?.aiPending && (
264
+ <View style={{ flexDirection: "row", gap: 10, alignItems: "center" }}>
265
+ <ActivityIndicator color="#7c9386" />
266
+ <Text style={[styles.meta, { color: colors.muted }]}>
267
+ Thinking…
268
+ </Text>
269
+ </View>
270
+ )}
271
+ </ScrollView>
272
+ {error.length > 0 && (
273
+ <Text
274
+ accessibilityRole="alert"
275
+ style={{
276
+ color: dark ? "#e8aaa4" : "#a44e45",
277
+ paddingHorizontal: 16,
278
+ paddingBottom: 8,
279
+ fontSize: 14,
280
+ }}
281
+ >
282
+ {error}
283
+ </Text>
284
+ )}
285
+ {chat?.state === "RESOLVED" ? (
286
+ <TouchableOpacity style={styles.send} onPress={() => setChat(null)}>
287
+ <Text style={styles.sendText}>Start a new conversation</Text>
288
+ </TouchableOpacity>
289
+ ) : (
290
+ <View style={[styles.composer, { borderColor: colors.line }]}>
291
+ <TextInput
292
+ accessibilityLabel="Your message"
293
+ multiline
294
+ value={text}
295
+ onChangeText={setText}
296
+ placeholder="Type your message…"
297
+ placeholderTextColor={colors.muted}
298
+ maxLength={4000}
299
+ style={[styles.input, { color: colors.text }]}
300
+ />
301
+ <TouchableOpacity
302
+ accessibilityRole="button"
303
+ accessibilityLabel="Send message"
304
+ disabled={busy || !text.trim()}
305
+ onPress={() => void send()}
306
+ style={[styles.send, { opacity: busy || !text.trim() ? 0.45 : 1 }]}
307
+ >
308
+ <Text style={styles.sendText}>Send</Text>
309
+ </TouchableOpacity>
310
+ </View>
311
+ )}
312
+ {config.showBranding !== false && (
313
+ <Text
314
+ style={[
315
+ styles.meta,
316
+ { color: colors.muted, textAlign: "center", padding: 10 },
317
+ ]}
318
+ >
319
+ Powered by UpStep
320
+ </Text>
321
+ )}
322
+ </View>
323
+ );
324
+ }
325
+ function NativeSupportWidget({
326
+ widget,
327
+ busy,
328
+ color,
329
+ name,
330
+ email,
331
+ verified,
332
+ onReply,
333
+ onContact,
334
+ onDetails,
335
+ }: {
336
+ widget: SupportWidget;
337
+ busy: boolean;
338
+ color: { soft: string; text: string; muted: string; line: string };
339
+ name: string;
340
+ email: string;
341
+ verified: boolean;
342
+ onReply: (text: string) => Promise<boolean>;
343
+ onContact: (name: string, email: string) => Promise<boolean>;
344
+ onDetails: (data: {
345
+ widgetId: string;
346
+ summary: string;
347
+ details: string;
348
+ reference: string;
349
+ clientMessageId: string;
350
+ }) => Promise<boolean>;
351
+ }) {
352
+ const [open, setOpen] = useState(false);
353
+ const [fullName, setName] = useState(name);
354
+ const [address, setEmail] = useState(email);
355
+ const [summary, setSummary] = useState("");
356
+ const [details, setDetails] = useState("");
357
+ const [reference, setReference] = useState("");
358
+ const id = useRef(supportMessageId());
359
+ if (widget.type === "quick_replies" || widget.type === "confirmation")
360
+ return (
361
+ <View style={{ gap: 8, marginTop: 12 }}>
362
+ <Text style={[styles.label, { color: color.text }]}>
363
+ {widget.title}
364
+ </Text>
365
+ {widget.options.map((option) => (
366
+ <TouchableOpacity
367
+ key={option}
368
+ disabled={busy}
369
+ style={[styles.option, { borderColor: color.line }]}
370
+ onPress={() => void onReply(option)}
371
+ >
372
+ <Text style={[styles.body, { color: color.text }]}>{option}</Text>
373
+ </TouchableOpacity>
374
+ ))}
375
+ </View>
376
+ );
377
+ const field = (
378
+ label: string,
379
+ value: string,
380
+ set: (s: string) => void,
381
+ kind: "email-address" | "default" = "default",
382
+ readOnly = false,
383
+ ) => (
384
+ <View style={{ gap: 6 }}>
385
+ <Text style={[styles.meta, { color: color.muted }]}>{label}</Text>
386
+ <TextInput
387
+ accessibilityLabel={label}
388
+ value={value}
389
+ onChangeText={set}
390
+ editable={!readOnly && !busy}
391
+ keyboardType={kind}
392
+ autoCapitalize={kind === "email-address" ? "none" : "sentences"}
393
+ style={[
394
+ styles.formInput,
395
+ { color: color.text, borderColor: color.line },
396
+ ]}
397
+ />
398
+ </View>
399
+ );
400
+ return (
401
+ <View
402
+ style={[
403
+ styles.form,
404
+ { backgroundColor: color.soft, borderColor: color.line },
405
+ ]}
406
+ >
407
+ <Text style={[styles.title, { color: color.text }]}>{widget.title}</Text>
408
+ {widget.type === "contact_form" ? (
409
+ <>
410
+ <Text style={[styles.body, { color: color.muted }]}>
411
+ {widget.summary}
412
+ </Text>
413
+ {!open ? (
414
+ <TouchableOpacity
415
+ disabled={busy}
416
+ style={styles.send}
417
+ onPress={() => setOpen(true)}
418
+ >
419
+ <Text style={styles.sendText}>Ask the team</Text>
420
+ </TouchableOpacity>
421
+ ) : (
422
+ <>
423
+ {field("Your name", fullName, setName)}
424
+ {field(
425
+ "Email address",
426
+ address,
427
+ setEmail,
428
+ "email-address",
429
+ verified && !!email,
430
+ )}
431
+ <Text style={[styles.meta, { color: color.muted }]}>
432
+ The team will use these details to follow up.
433
+ </Text>
434
+ <TouchableOpacity
435
+ disabled={busy || !fullName.trim() || !address.trim()}
436
+ style={[
437
+ styles.send,
438
+ {
439
+ opacity:
440
+ busy || !fullName.trim() || !address.trim() ? 0.5 : 1,
441
+ },
442
+ ]}
443
+ onPress={() => void onContact(fullName, address)}
444
+ >
445
+ <Text style={styles.sendText}>Send to the team</Text>
446
+ </TouchableOpacity>
447
+ </>
448
+ )}
449
+ </>
450
+ ) : (
451
+ <>
452
+ {field("What is happening?", summary, setSummary)}
453
+ {field("What have you tried? (optional)", details, setDetails)}
454
+ {field(
455
+ "Order or issue reference (optional)",
456
+ reference,
457
+ setReference,
458
+ )}
459
+ <TouchableOpacity
460
+ disabled={busy || !summary.trim()}
461
+ style={styles.send}
462
+ onPress={() =>
463
+ void onDetails({
464
+ widgetId: widget.id,
465
+ summary,
466
+ details,
467
+ reference,
468
+ clientMessageId: id.current,
469
+ })
470
+ }
471
+ >
472
+ <Text style={styles.sendText}>Send details</Text>
473
+ </TouchableOpacity>
474
+ </>
475
+ )}
476
+ </View>
477
+ );
478
+ }
479
+ const styles = StyleSheet.create({
480
+ root: { flex: 1 },
481
+ heading: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth },
482
+ title: { fontSize: 18, fontWeight: "600" },
483
+ hero: { fontSize: 28, fontWeight: "600" },
484
+ body: { fontSize: 16, lineHeight: 25 },
485
+ meta: { fontSize: 14, lineHeight: 21 },
486
+ label: { fontSize: 15, fontWeight: "600" },
487
+ bubble: { padding: 14, borderRadius: 16 },
488
+ ticket: { padding: 12, gap: 4 },
489
+ composer: {
490
+ margin: 12,
491
+ borderWidth: 1,
492
+ borderRadius: 16,
493
+ padding: 10,
494
+ flexDirection: "row",
495
+ alignItems: "flex-end",
496
+ gap: 8,
497
+ },
498
+ input: {
499
+ flex: 1,
500
+ fontSize: 16,
501
+ lineHeight: 24,
502
+ minHeight: 44,
503
+ maxHeight: 120,
504
+ padding: 8,
505
+ },
506
+ send: {
507
+ minHeight: 44,
508
+ backgroundColor: "#5b7168",
509
+ borderRadius: 12,
510
+ paddingHorizontal: 16,
511
+ paddingVertical: 12,
512
+ alignItems: "center",
513
+ justifyContent: "center",
514
+ },
515
+ sendText: { fontSize: 16, fontWeight: "600", color: "white" },
516
+ option: { minHeight: 44, borderWidth: 1, borderRadius: 12, padding: 12 },
517
+ form: {
518
+ marginTop: 12,
519
+ borderWidth: 1,
520
+ borderRadius: 16,
521
+ padding: 14,
522
+ gap: 12,
523
+ },
524
+ formInput: {
525
+ borderWidth: 1,
526
+ borderRadius: 10,
527
+ padding: 12,
528
+ minHeight: 48,
529
+ fontSize: 16,
530
+ },
531
+ });
package/src/api.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {SupportClient} from "@upstep/js/support";
1
2
  import AsyncStorage from "@react-native-async-storage/async-storage";
2
3
  import type {
3
4
  Feedback,
@@ -18,6 +19,7 @@ export class UpstepApiClient {
18
19
  private readonly baseUrl: string;
19
20
  private readonly apiKey: string;
20
21
  userId: string | undefined;
22
+ readonly support:SupportClient;
21
23
  // Set synchronously to a throwaway id so there's always something to send;
22
24
  // swapped for the persisted value (or backfilled into storage) once
23
25
  // AsyncStorage resolves. Callers await anonymousIdReady first so requests
@@ -28,6 +30,7 @@ export class UpstepApiClient {
28
30
  constructor(config: UpstepConfig) {
29
31
  this.baseUrl = (config.baseUrl ?? "https://upstep.dev").replace(/\/$/, "");
30
32
  this.apiKey = config.apiKey;
33
+ this.support = new SupportClient(config,AsyncStorage);
31
34
  if (config.userId !== undefined) this.userId = config.userId;
32
35
  this.anonymousId = generateId();
33
36
  this.anonymousIdReady = this.loadAnonymousId();
package/src/index.ts CHANGED
@@ -2,4 +2,20 @@ export { FeedbackProvider, useUpstep } from "./FeedbackContext";
2
2
  export { FeedbackSheet } from "./FeedbackSheet";
3
3
  export { FeedbackButton } from "./FeedbackButton";
4
4
  export { useShakeToFeedback } from "./useShakeToFeedback";
5
- export type { UpstepConfig, Feedback, FeedbackType, FeedbackStatus, VoteValue } from "@upstep/types";
5
+ export type {
6
+ UpstepConfig,
7
+ Feedback,
8
+ FeedbackType,
9
+ FeedbackStatus,
10
+ VoteValue,
11
+ } from "@upstep/types";
12
+
13
+ export { SupportView } from "./SupportView";
14
+
15
+ export { SupportClient, SupportApiError } from "@upstep/js/support";
16
+ export type {
17
+ SupportConfiguration,
18
+ SupportConversation,
19
+ SupportMessage,
20
+ SupportWidget,
21
+ } from "@upstep/types";
package/tsconfig.json DELETED
@@ -1,9 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.base.json",
3
- "compilerOptions": {
4
- "jsx": "react-native",
5
- "lib": ["ES2020"],
6
- "noEmit": true
7
- },
8
- "include": ["src"]
9
- }