@upstep/react-native 0.6.16 → 0.7.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.
@@ -0,0 +1,641 @@
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
+ useWindowDimensions,
13
+ } from "react-native";
14
+ import { SupportClient, supportMessageId } from "@upstep/js/support";
15
+ import type {
16
+ SupportConfiguration,
17
+ SupportConversation,
18
+ SupportWidget,
19
+ } from "@upstep/types";
20
+ export function SupportView({
21
+ client,
22
+ config,
23
+ dark = false,
24
+ accentColor = "#D97757",
25
+ }: {
26
+ client: SupportClient;
27
+ config: SupportConfiguration;
28
+ dark?: boolean;
29
+ accentColor?: string;
30
+ }) {
31
+ const { height } = useWindowDimensions();
32
+ const [chat, setChat] = useState<SupportConversation | null>(null);
33
+ const [text, setText] = useState("");
34
+ const [busy, setBusy] = useState(false);
35
+ const [error, setError] = useState("");
36
+ const list = useRef<React.ElementRef<typeof ScrollView>>(null);
37
+ const active = useRef(true);
38
+ const pending = useRef<{ text: string; id: string } | null>(null);
39
+ const colors = {
40
+ bg: dark ? "#000000" : "#ffffff",
41
+ soft: dark ? "#171717" : "#f5f5f5",
42
+ text: dark ? "#e5e5e5" : "#404040",
43
+ muted: dark ? "#a3a3a3" : "#737373",
44
+ line: dark ? "#404040" : "#e5e5e5",
45
+ };
46
+ async function refresh(id: string) {
47
+ const next = await client.conversation(id);
48
+ if (active.current) setChat(next);
49
+ }
50
+ useEffect(() => {
51
+ active.current = true;
52
+ void client
53
+ .conversations()
54
+ .then((items) => {
55
+ if (items[0]) return refresh(items[0].id);
56
+ })
57
+ .catch((e) => setError(e.message));
58
+ return () => {
59
+ active.current = false;
60
+ };
61
+ }, [client]);
62
+ useEffect(() => {
63
+ if (!chat) return;
64
+ const id = chat.id;
65
+ const timer = setInterval(() => {
66
+ if (AppState.currentState === "active")
67
+ void refresh(id).catch((e) => {
68
+ if (active.current) setError(e.message);
69
+ });
70
+ }, 2500);
71
+ return () => clearInterval(timer);
72
+ }, [chat?.id, client]);
73
+ async function perform(fn: () => Promise<void>) {
74
+ if (busy) return false;
75
+ setBusy(true);
76
+ setError("");
77
+ try {
78
+ await fn();
79
+ return true;
80
+ } catch (e) {
81
+ setError((e as Error).message);
82
+ return false;
83
+ } finally {
84
+ if (active.current) setBusy(false);
85
+ }
86
+ }
87
+ async function send(value = text) {
88
+ return perform(async () => {
89
+ if (!value.trim()) return;
90
+ const c =
91
+ !chat || chat.state === "RESOLVED"
92
+ ? await client.startConversation()
93
+ : chat;
94
+ if (!pending.current || pending.current.text !== value)
95
+ pending.current = { text: value, id: supportMessageId() };
96
+ await client.send(c.id, value, pending.current.id);
97
+ pending.current = null;
98
+ if (active.current) setText("");
99
+ await refresh(c.id);
100
+ });
101
+ }
102
+ const widgets: SupportWidget[] = chat?.activeWidgetMessageId
103
+ ? (
104
+ chat.messages.find((m) => m.id === chat.activeWidgetMessageId)?.metadata
105
+ ?.widgets || []
106
+ ).filter((w) => w.type !== "contact_form" || chat.canEscalate)
107
+ : chat?.canEscalate
108
+ ? [
109
+ {
110
+ id: `contact:${chat.id}`,
111
+ type: "contact_form",
112
+ title: "Let the team follow up",
113
+ summary: chat.escalationSummary || "",
114
+ },
115
+ ]
116
+ : [];
117
+ return (
118
+ <View style={[styles.root, { backgroundColor: colors.bg }]}>
119
+ <View style={[styles.heading, { borderColor: colors.line }]}>
120
+ <Text style={[styles.title, { color: colors.text }]}>
121
+ {config.businessName}
122
+ </Text>
123
+ <Text style={[styles.meta, { color: colors.muted }]}>
124
+ {chat?.state === "HUMAN"
125
+ ? "The team is here to help"
126
+ : "AI support for your questions"}
127
+ </Text>
128
+ </View>
129
+ {chat?.ticketReference && (
130
+ <View style={[styles.ticket, { backgroundColor: colors.soft }]}>
131
+ <Text selectable style={[styles.label, { color: colors.text }]}>
132
+ Ticket {chat.ticketReference}
133
+ </Text>
134
+ {chat.supportEmail && (
135
+ <TouchableOpacity
136
+ onPress={() =>
137
+ void Linking.openURL(
138
+ `mailto:${chat.supportEmail}?subject=${encodeURIComponent(chat.ticketReference || "")}`,
139
+ )
140
+ }
141
+ >
142
+ <Text style={[styles.meta, { color: colors.muted }]}>
143
+ {chat.supportEmail}
144
+ </Text>
145
+ </TouchableOpacity>
146
+ )}
147
+ </View>
148
+ )}
149
+ <ScrollView
150
+ ref={list}
151
+ style={{ flex: 1 }}
152
+ contentContainerStyle={{ padding: 16, gap: 16 }}
153
+ keyboardShouldPersistTaps="handled"
154
+ onContentSizeChange={() => {
155
+ if (chat) list.current?.scrollToEnd({ animated: true });
156
+ }}
157
+ >
158
+ {!chat && (
159
+ <View style={{ gap: 16, paddingVertical: 12 }}>
160
+ <Text style={[styles.hero, { color: colors.text }]}>
161
+ How can we help?
162
+ </Text>
163
+ <Text style={[styles.body, { color: colors.muted }]}>
164
+ {config.welcomeMessage}
165
+ </Text>
166
+ {[
167
+ "What can you help me with?",
168
+ `I need help using ${config.businessName}`,
169
+ ].map((q) => (
170
+ <TouchableOpacity
171
+ key={q}
172
+ style={[styles.option, { borderColor: colors.line }]}
173
+ onPress={() => setText(q)}
174
+ >
175
+ <Text style={[styles.body, { color: colors.text }]}>{q}</Text>
176
+ </TouchableOpacity>
177
+ ))}
178
+ </View>
179
+ )}
180
+ {chat?.messages.map((m) => (
181
+ <View
182
+ key={m.id}
183
+ style={{
184
+ alignSelf: m.role === "CUSTOMER" ? "flex-end" : "stretch",
185
+ maxWidth: m.role === "CUSTOMER" ? "94%" : "100%",
186
+ }}
187
+ >
188
+ <Text
189
+ style={[styles.meta, { color: colors.muted, marginBottom: 6 }]}
190
+ >
191
+ {m.role === "CUSTOMER" ? "You" : m.authorName}
192
+ </Text>
193
+ <View
194
+ style={[
195
+ styles.bubble,
196
+ {
197
+ backgroundColor:
198
+ m.role === "SYSTEM" ? "transparent" : colors.soft,
199
+ },
200
+ ]}
201
+ >
202
+ {m.content.split(/\n\s*\n/).map((paragraph, i) => (
203
+ <Text
204
+ key={i}
205
+ selectable
206
+ style={[styles.body, { color: colors.text, marginBottom: 8 }]}
207
+ >
208
+ {paragraph}
209
+ </Text>
210
+ ))}
211
+ {m.metadata?.citations?.map((c) => (
212
+ <TouchableOpacity
213
+ key={c.url}
214
+ onPress={() => void Linking.openURL(c.url)}
215
+ >
216
+ <Text
217
+ numberOfLines={1}
218
+ style={[styles.meta, { color: colors.muted, marginTop: 8 }]}
219
+ >
220
+ ↗ {c.title}
221
+ </Text>
222
+ </TouchableOpacity>
223
+ ))}
224
+ </View>
225
+ </View>
226
+ ))}
227
+ {chat?.aiPending && (
228
+ <View style={{ flexDirection: "row", gap: 10, alignItems: "center" }}>
229
+ <ActivityIndicator color={accentColor} />
230
+ <Text style={[styles.meta, { color: colors.muted }]}>
231
+ Thinking…
232
+ </Text>
233
+ </View>
234
+ )}
235
+ </ScrollView>
236
+ {chat && widgets.length > 0 && (
237
+ <ScrollView
238
+ keyboardShouldPersistTaps="handled"
239
+ style={{
240
+ maxHeight: height * 0.38,
241
+ flexGrow: 0,
242
+ marginHorizontal: 12,
243
+ }}
244
+ >
245
+ <NativeSupportActions
246
+ key={chat.activeWidgetMessageId || chat.id}
247
+ widgets={widgets}
248
+ online={config.online.length > 0}
249
+ busy={busy}
250
+ color={colors}
251
+ accentColor={accentColor}
252
+ name={chat.customer.name || ""}
253
+ email={chat.customer.email || ""}
254
+ verified={chat.customer.verified}
255
+ onReply={send}
256
+ onContact={(name, email) =>
257
+ perform(async () => {
258
+ await client.handoff(chat.id, name, email);
259
+ await refresh(chat.id);
260
+ })
261
+ }
262
+ onDetails={(values) =>
263
+ perform(async () => {
264
+ await client.details(chat.id, values);
265
+ await refresh(chat.id);
266
+ })
267
+ }
268
+ />
269
+ </ScrollView>
270
+ )}
271
+ {error.length > 0 && (
272
+ <Text
273
+ accessibilityRole="alert"
274
+ style={{
275
+ color: dark ? "#e8aaa4" : "#a44e45",
276
+ paddingHorizontal: 16,
277
+ paddingBottom: 8,
278
+ fontSize: 14,
279
+ }}
280
+ >
281
+ {error}
282
+ </Text>
283
+ )}
284
+ {chat?.state === "RESOLVED" ? (
285
+ <TouchableOpacity
286
+ style={[styles.send, { backgroundColor: accentColor }]}
287
+ onPress={() => setChat(null)}
288
+ >
289
+ <Text style={styles.sendText}>Start a new conversation</Text>
290
+ </TouchableOpacity>
291
+ ) : (
292
+ <View style={[styles.composer, { borderColor: colors.line }]}>
293
+ <TextInput
294
+ accessibilityLabel="Your message"
295
+ multiline
296
+ value={text}
297
+ onChangeText={setText}
298
+ placeholder="Type your message…"
299
+ placeholderTextColor={colors.muted}
300
+ maxLength={4000}
301
+ style={[styles.input, { color: colors.text }]}
302
+ />
303
+ <TouchableOpacity
304
+ accessibilityRole="button"
305
+ accessibilityLabel="Send message"
306
+ disabled={busy || !text.trim()}
307
+ onPress={() => void send()}
308
+ style={[
309
+ styles.send,
310
+ {
311
+ backgroundColor: accentColor,
312
+ opacity: busy || !text.trim() ? 0.45 : 1,
313
+ },
314
+ ]}
315
+ >
316
+ <Text style={styles.sendText}>Send</Text>
317
+ </TouchableOpacity>
318
+ </View>
319
+ )}
320
+ {config.showBranding !== false && (
321
+ <Text
322
+ style={[
323
+ styles.meta,
324
+ { color: colors.muted, textAlign: "center", padding: 10 },
325
+ ]}
326
+ >
327
+ Powered by UpStep
328
+ </Text>
329
+ )}
330
+ </View>
331
+ );
332
+ }
333
+ type ActionProps = {
334
+ online: boolean;
335
+ busy: boolean;
336
+ color: { soft: string; text: string; muted: string; line: string };
337
+ accentColor: string;
338
+ name: string;
339
+ email: string;
340
+ verified: boolean;
341
+ onReply: (text: string) => Promise<boolean>;
342
+ onContact: (name: string, email: string) => Promise<boolean>;
343
+ onDetails: (data: {
344
+ widgetId: string;
345
+ summary: string;
346
+ details: string;
347
+ reference: string;
348
+ clientMessageId: string;
349
+ }) => Promise<boolean>;
350
+ };
351
+ function NativeSupportActions({
352
+ widgets,
353
+ ...props
354
+ }: ActionProps & { widgets: SupportWidget[] }) {
355
+ const [selected, setSelected] = useState(
356
+ widgets.find((w) => w.type === "quick_replies")?.id || widgets[0]?.id,
357
+ );
358
+ const widget = widgets.find((w) => w.id === selected) || widgets[0];
359
+ return (
360
+ <View style={{ gap: 8, paddingBottom: 4 }}>
361
+ {widgets.length > 1 && (
362
+ <View style={{ flexDirection: "row", gap: 8 }}>
363
+ {widgets.map((w) => (
364
+ <TouchableOpacity
365
+ key={w.id}
366
+ onPress={() => setSelected(w.id)}
367
+ style={[
368
+ styles.option,
369
+ { borderColor: props.color.line, padding: 8 },
370
+ ]}
371
+ >
372
+ <Text style={{ fontSize: 13, color: props.color.text }}>
373
+ {w.type === "quick_replies" ? "Quick replies" : "Issue details"}
374
+ </Text>
375
+ </TouchableOpacity>
376
+ ))}
377
+ </View>
378
+ )}
379
+ {widget && (
380
+ <NativeSupportWidget key={widget.id} {...props} widget={widget} />
381
+ )}
382
+ </View>
383
+ );
384
+ }
385
+ function NativeSupportWidget({
386
+ widget,
387
+ busy,
388
+ color,
389
+ accentColor,
390
+ name,
391
+ email,
392
+ verified,
393
+ online,
394
+ onReply,
395
+ onContact,
396
+ onDetails,
397
+ }: ActionProps & { widget: SupportWidget }) {
398
+ const [step, setStep] = useState(0),
399
+ [issueOpen, setIssueOpen] = useState(false),
400
+ [more, setMore] = useState(false);
401
+ const [fullName, setName] = useState(name),
402
+ [address, setEmail] = useState(email),
403
+ [summary, setSummary] = useState(""),
404
+ [details, setDetails] = useState(""),
405
+ [reference, setReference] = useState("");
406
+ const id = useRef(supportMessageId());
407
+ const action = (title: string, press: () => void, disabled = false) => (
408
+ <TouchableOpacity
409
+ disabled={disabled || busy}
410
+ style={[
411
+ styles.send,
412
+ { backgroundColor: accentColor, opacity: disabled || busy ? 0.5 : 1 },
413
+ ]}
414
+ onPress={press}
415
+ >
416
+ <Text style={styles.sendText}>{title}</Text>
417
+ </TouchableOpacity>
418
+ );
419
+ if (widget.type === "quick_replies" || widget.type === "confirmation")
420
+ return (
421
+ <View style={{ gap: 8 }}>
422
+ <Text style={[styles.meta, { color: color.muted }]}>
423
+ {widget.title}
424
+ </Text>
425
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
426
+ {widget.options.map((option) => (
427
+ <TouchableOpacity
428
+ key={option}
429
+ disabled={busy}
430
+ style={[styles.option, { borderColor: color.line }]}
431
+ onPress={() => void onReply(option)}
432
+ >
433
+ <Text style={{ fontSize: 13, color: color.text }}>{option}</Text>
434
+ </TouchableOpacity>
435
+ ))}
436
+ </View>
437
+ </View>
438
+ );
439
+ const field = (
440
+ label: string,
441
+ value: string,
442
+ set: (v: string) => void,
443
+ emailField = false,
444
+ readOnly = false,
445
+ maxLength = 160,
446
+ ) => (
447
+ <View style={{ gap: 4 }}>
448
+ <Text style={[styles.meta, { color: color.muted }]}>{label}</Text>
449
+ <TextInput
450
+ accessibilityLabel={label}
451
+ value={value}
452
+ onChangeText={set}
453
+ editable={!busy && !readOnly}
454
+ maxLength={maxLength}
455
+ keyboardType={emailField ? "email-address" : "default"}
456
+ autoCapitalize={emailField ? "none" : "sentences"}
457
+ style={[
458
+ styles.formInput,
459
+ { color: color.text, borderColor: color.line },
460
+ ]}
461
+ />
462
+ </View>
463
+ );
464
+ return (
465
+ <View
466
+ style={[
467
+ styles.form,
468
+ { backgroundColor: color.soft, borderColor: color.line },
469
+ ]}
470
+ >
471
+ <View
472
+ style={{
473
+ flexDirection: "row",
474
+ justifyContent: "space-between",
475
+ gap: 8,
476
+ }}
477
+ >
478
+ <Text style={[styles.label, { color: color.text }]}>
479
+ {widget.title}
480
+ </Text>
481
+ {widget.type === "contact_form" && step > 0 && (
482
+ <Text style={[styles.meta, { color: color.muted }]}>{step} of 2</Text>
483
+ )}
484
+ </View>
485
+ {widget.type === "contact_form" ? (
486
+ step === 0 ? (
487
+ <>
488
+ <Text
489
+ numberOfLines={2}
490
+ style={[styles.meta, { color: color.muted }]}
491
+ >
492
+ {widget.summary}
493
+ </Text>
494
+ {action("Ask the team", () => setStep(1))}
495
+ </>
496
+ ) : (
497
+ <>
498
+ {step === 1
499
+ ? field("Your name", fullName, setName, false, false, 100)
500
+ : field(
501
+ "Email for support follow-up",
502
+ address,
503
+ setEmail,
504
+ true,
505
+ verified && !!email,
506
+ 254,
507
+ )}
508
+ <View
509
+ style={{ flexDirection: "row", gap: 8, alignItems: "center" }}
510
+ >
511
+ <TouchableOpacity
512
+ style={styles.option}
513
+ disabled={busy}
514
+ onPress={() => setStep(step - 1)}
515
+ >
516
+ <Text style={{ color: color.text, fontSize: 13 }}>Back</Text>
517
+ </TouchableOpacity>
518
+ <View style={{ flex: 1 }}>
519
+ {action(
520
+ step === 1
521
+ ? "Continue"
522
+ : online
523
+ ? "Request transfer"
524
+ : "Send to the team",
525
+ () => {
526
+ if (step === 1) setStep(2);
527
+ else void onContact(fullName, address);
528
+ },
529
+ !fullName.trim() || (step === 2 && !address.trim()),
530
+ )}
531
+ </View>
532
+ </View>
533
+ </>
534
+ )
535
+ ) : !issueOpen ? (
536
+ action("Add issue details", () => setIssueOpen(true))
537
+ ) : (
538
+ <>
539
+ {field("What is happening?", summary, setSummary)}
540
+ <TouchableOpacity onPress={() => setMore(!more)}>
541
+ <Text
542
+ style={[
543
+ styles.meta,
544
+ { color: color.muted, textDecorationLine: "underline" },
545
+ ]}
546
+ >
547
+ {more
548
+ ? "Hide optional details"
549
+ : "Add notes or a reference (optional)"}
550
+ </Text>
551
+ </TouchableOpacity>
552
+ {more && (
553
+ <>
554
+ {field(
555
+ "What have you tried?",
556
+ details,
557
+ setDetails,
558
+ false,
559
+ false,
560
+ 2000,
561
+ )}
562
+ {field(
563
+ "Order or issue reference",
564
+ reference,
565
+ setReference,
566
+ false,
567
+ false,
568
+ 100,
569
+ )}
570
+ </>
571
+ )}
572
+ {action(
573
+ "Send details",
574
+ () =>
575
+ void onDetails({
576
+ widgetId: widget.id,
577
+ summary,
578
+ details,
579
+ reference,
580
+ clientMessageId: id.current,
581
+ }),
582
+ !summary.trim(),
583
+ )}
584
+ </>
585
+ )}
586
+ </View>
587
+ );
588
+ }
589
+ const styles = StyleSheet.create({
590
+ root: { flex: 1 },
591
+ heading: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth },
592
+ title: { fontSize: 18, fontWeight: "600" },
593
+ hero: { fontSize: 28, fontWeight: "600" },
594
+ body: { fontSize: 15, lineHeight: 23 },
595
+ meta: { fontSize: 13, lineHeight: 19 },
596
+ label: { fontSize: 15, fontWeight: "600" },
597
+ bubble: { padding: 12, borderRadius: 16 },
598
+ ticket: { padding: 12, gap: 4 },
599
+ composer: {
600
+ margin: 12,
601
+ borderWidth: 1,
602
+ borderRadius: 16,
603
+ padding: 10,
604
+ flexDirection: "row",
605
+ alignItems: "flex-end",
606
+ gap: 8,
607
+ },
608
+ input: {
609
+ flex: 1,
610
+ fontSize: 16,
611
+ lineHeight: 24,
612
+ minHeight: 44,
613
+ maxHeight: 120,
614
+ padding: 8,
615
+ },
616
+ send: {
617
+ minHeight: 44,
618
+
619
+ borderRadius: 12,
620
+ paddingHorizontal: 16,
621
+ paddingVertical: 10,
622
+ alignItems: "center",
623
+ justifyContent: "center",
624
+ },
625
+ sendText: { fontSize: 14, fontWeight: "600", color: "white" },
626
+ option: { minHeight: 44, borderWidth: 1, borderRadius: 12, padding: 12 },
627
+ form: {
628
+ marginTop: 0,
629
+ borderWidth: 1,
630
+ borderRadius: 16,
631
+ padding: 12,
632
+ gap: 8,
633
+ },
634
+ formInput: {
635
+ borderWidth: 1,
636
+ borderRadius: 10,
637
+ padding: 12,
638
+ minHeight: 48,
639
+ fontSize: 16,
640
+ },
641
+ });
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";
@@ -1,4 +0,0 @@
1
-
2
- > @upstep/react-native@0.6.15 type-check /Users/sandhyasandy/Dev/upstep/packages/sdk-react-native
3
- > tsc --noEmit
4
-
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
- }