@upstep/react-native 0.1.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.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@upstep/react-native",
3
+ "version": "0.1.0",
4
+ "description": "Upstep feedback SDK for React Native",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./src/index.ts",
10
+ "require": "./src/index.ts",
11
+ "types": "./src/index.ts"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "@upstep/types": "0.0.1"
16
+ },
17
+ "peerDependencies": {
18
+ "@gorhom/bottom-sheet": ">=4",
19
+ "react": ">=18",
20
+ "react-native": ">=0.73",
21
+ "react-native-gesture-handler": ">=2",
22
+ "react-native-reanimated": ">=3"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "^19.0.7",
26
+ "@types/react-native": "^0.73.0",
27
+ "typescript": "^5.7.3"
28
+ },
29
+ "keywords": [
30
+ "react-native",
31
+ "feedback",
32
+ "upstep"
33
+ ],
34
+ "license": "MIT",
35
+ "scripts": {
36
+ "type-check": "tsc --noEmit"
37
+ }
38
+ }
@@ -0,0 +1,42 @@
1
+ import React from "react";
2
+ import { TouchableOpacity, Text, StyleSheet } from "react-native";
3
+ import { useUpstep } from "./FeedbackContext";
4
+
5
+ interface Props {
6
+ position?: "bottom-right" | "bottom-left";
7
+ label?: string;
8
+ }
9
+
10
+ export function FeedbackButton({ position = "bottom-right", label = "Feedback" }: Props) {
11
+ const { openSheet } = useUpstep();
12
+
13
+ return (
14
+ <TouchableOpacity
15
+ onPress={openSheet}
16
+ style={[styles.btn, position === "bottom-left" ? styles.left : styles.right]}
17
+ activeOpacity={0.85}
18
+ >
19
+ <Text style={styles.label}>{label}</Text>
20
+ </TouchableOpacity>
21
+ );
22
+ }
23
+
24
+ const styles = StyleSheet.create({
25
+ btn: {
26
+ position: "absolute",
27
+ bottom: 24,
28
+ backgroundColor: "#6366f1",
29
+ borderRadius: 9999,
30
+ paddingVertical: 12,
31
+ paddingHorizontal: 20,
32
+ shadowColor: "#000",
33
+ shadowOffset: { width: 0, height: 4 },
34
+ shadowOpacity: 0.2,
35
+ shadowRadius: 6,
36
+ elevation: 6,
37
+ zIndex: 100,
38
+ },
39
+ right: { right: 20 },
40
+ left: { left: 20 },
41
+ label: { color: "#fff", fontSize: 14, fontWeight: "600" },
42
+ });
@@ -0,0 +1,72 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useState,
6
+ useCallback,
7
+ useRef,
8
+ type ReactNode,
9
+ } from "react";
10
+ import type { Feedback, FeedbackType, UpstepConfig, VoteValue } from "@upstep/types";
11
+ import { UpstepApiClient } from "./api";
12
+ import type BottomSheet from "@gorhom/bottom-sheet";
13
+
14
+ interface UpstepContextValue {
15
+ client: UpstepApiClient;
16
+ feedItems: Feedback[];
17
+ loadFeed: () => Promise<void>;
18
+ submit: (content: string, type?: FeedbackType) => Promise<void>;
19
+ vote: (feedbackId: string, value: VoteValue) => Promise<void>;
20
+ openSheet: () => void;
21
+ sheetRef: React.RefObject<BottomSheet | null>;
22
+ }
23
+
24
+ const UpstepContext = createContext<UpstepContextValue | null>(null);
25
+
26
+ export function FeedbackProvider({
27
+ children,
28
+ ...config
29
+ }: UpstepConfig & { children: ReactNode }) {
30
+ const [client] = useState(() => new UpstepApiClient(config));
31
+ const [feedItems, setFeedItems] = useState<Feedback[]>([]);
32
+ const sheetRef = useRef<BottomSheet>(null);
33
+
34
+ const loadFeed = useCallback(async () => {
35
+ try {
36
+ const data = await client.listFeedback({ sort: "votes", limit: 20 });
37
+ setFeedItems(data.items);
38
+ } catch {
39
+ // non-critical
40
+ }
41
+ }, [client]);
42
+
43
+ useEffect(() => { loadFeed(); }, [loadFeed]);
44
+
45
+ const submit = useCallback(async (content: string, type?: FeedbackType) => {
46
+ const payload: Parameters<typeof client.submitFeedback>[0] = { content };
47
+ if (type !== undefined) payload.type = type;
48
+ await client.submitFeedback(payload);
49
+ await loadFeed();
50
+ }, [client, loadFeed]);
51
+
52
+ const vote = useCallback(async (feedbackId: string, value: VoteValue) => {
53
+ await client.vote(feedbackId, value);
54
+ await loadFeed();
55
+ }, [client, loadFeed]);
56
+
57
+ const openSheet = useCallback(() => {
58
+ sheetRef.current?.expand();
59
+ }, []);
60
+
61
+ return (
62
+ <UpstepContext.Provider value={{ client, feedItems, loadFeed, submit, vote, openSheet, sheetRef }}>
63
+ {children}
64
+ </UpstepContext.Provider>
65
+ );
66
+ }
67
+
68
+ export function useUpstep(): UpstepContextValue {
69
+ const ctx = useContext(UpstepContext);
70
+ if (!ctx) throw new Error("useUpstep must be used inside <FeedbackProvider>");
71
+ return ctx;
72
+ }
@@ -0,0 +1,179 @@
1
+ import React, { useState, useMemo } from "react";
2
+ import {
3
+ View,
4
+ Text,
5
+ TextInput,
6
+ TouchableOpacity,
7
+ StyleSheet,
8
+ ScrollView,
9
+ ActivityIndicator,
10
+ } from "react-native";
11
+ import BottomSheet, { BottomSheetView, BottomSheetScrollView } from "@gorhom/bottom-sheet";
12
+ import type { Feedback, FeedbackType } from "@upstep/types";
13
+ import { useUpstep } from "./FeedbackContext";
14
+
15
+ type Tab = "submit" | "feed";
16
+
17
+ const TYPES: FeedbackType[] = ["BUG", "FEATURE", "GENERAL"];
18
+
19
+ export function FeedbackSheet() {
20
+ const { feedItems, submit, vote, sheetRef } = useUpstep();
21
+ const snapPoints = useMemo(() => ["75%", "95%"], []);
22
+ const [tab, setTab] = useState<Tab>("submit");
23
+ const [content, setContent] = useState("");
24
+ const [type, setType] = useState<FeedbackType>("GENERAL");
25
+ const [msg, setMsg] = useState("");
26
+ const [loading, setLoading] = useState(false);
27
+
28
+ async function handleSubmit() {
29
+ if (!content.trim() || loading) return;
30
+ setLoading(true);
31
+ try {
32
+ await submit(content.trim(), type);
33
+ setContent("");
34
+ setMsg("Thanks for your feedback!");
35
+ } catch {
36
+ setMsg("Something went wrong. Please try again.");
37
+ } finally {
38
+ setLoading(false);
39
+ }
40
+ }
41
+
42
+ return (
43
+ <BottomSheet
44
+ ref={sheetRef}
45
+ index={-1}
46
+ snapPoints={snapPoints}
47
+ enablePanDownToClose
48
+ backgroundStyle={styles.sheet}
49
+ handleIndicatorStyle={styles.handle}
50
+ >
51
+ <BottomSheetView style={styles.header}>
52
+ <Text style={styles.title}>Share feedback</Text>
53
+ </BottomSheetView>
54
+
55
+ <View style={styles.tabs}>
56
+ {(["submit", "feed"] as Tab[]).map((t) => (
57
+ <TouchableOpacity
58
+ key={t}
59
+ onPress={() => setTab(t)}
60
+ style={[styles.tab, tab === t && styles.activeTab]}
61
+ >
62
+ <Text style={[styles.tabText, tab === t && styles.activeTabText]}>
63
+ {t === "submit" ? "Submit" : "Votes & Ideas"}
64
+ </Text>
65
+ </TouchableOpacity>
66
+ ))}
67
+ </View>
68
+
69
+ {tab === "submit" ? (
70
+ <BottomSheetView style={styles.body}>
71
+ <TextInput
72
+ value={content}
73
+ onChangeText={setContent}
74
+ placeholder="What's on your mind?"
75
+ placeholderTextColor="#9ca3af"
76
+ multiline
77
+ maxLength={2000}
78
+ style={styles.textarea}
79
+ />
80
+ <View style={styles.typeRow}>
81
+ {TYPES.map((t) => (
82
+ <TouchableOpacity
83
+ key={t}
84
+ onPress={() => setType(t)}
85
+ style={[styles.typeBtn, type === t && styles.selectedTypeBtn]}
86
+ >
87
+ <Text style={[styles.typeBtnText, type === t && styles.selectedTypeBtnText]}>
88
+ {t.charAt(0) + t.slice(1).toLowerCase()}
89
+ </Text>
90
+ </TouchableOpacity>
91
+ ))}
92
+ </View>
93
+ <TouchableOpacity
94
+ onPress={handleSubmit}
95
+ disabled={loading || !content.trim()}
96
+ style={[styles.submitBtn, (loading || !content.trim()) && styles.disabledBtn]}
97
+ >
98
+ {loading ? (
99
+ <ActivityIndicator color="#fff" />
100
+ ) : (
101
+ <Text style={styles.submitBtnText}>Send feedback</Text>
102
+ )}
103
+ </TouchableOpacity>
104
+ {msg ? <Text style={styles.msgText}>{msg}</Text> : null}
105
+ </BottomSheetView>
106
+ ) : (
107
+ <BottomSheetScrollView contentContainerStyle={styles.body}>
108
+ {feedItems.length === 0 ? (
109
+ <Text style={styles.emptyText}>No feedback yet. Be the first!</Text>
110
+ ) : (
111
+ feedItems.map((f) => (
112
+ <FeedItem key={f.id} item={f} onVote={vote} />
113
+ ))
114
+ )}
115
+ </BottomSheetScrollView>
116
+ )}
117
+ </BottomSheet>
118
+ );
119
+ }
120
+
121
+ function FeedItem({ item, onVote }: { item: Feedback; onVote: (id: string, v: "UP" | "DOWN") => void }) {
122
+ return (
123
+ <View style={styles.feedItem}>
124
+ <Text style={styles.feedContent}>{item.content}</Text>
125
+ <View style={styles.feedMeta}>
126
+ <View style={[styles.badge, item.type === "BUG" ? styles.badgeBug : item.type === "FEATURE" ? styles.badgeFeature : styles.badgeGeneral]}>
127
+ <Text style={[styles.badgeText, item.type === "BUG" ? { color: "#dc2626" } : item.type === "FEATURE" ? { color: "#2563eb" } : { color: "#6b7280" }]}>
128
+ {item.type}
129
+ </Text>
130
+ </View>
131
+ <Text style={styles.feedDate}>{new Date(item.createdAt).toLocaleDateString()}</Text>
132
+ </View>
133
+ <View style={styles.voteRow}>
134
+ <TouchableOpacity style={styles.voteBtn} onPress={() => onVote(item.id, "UP")}>
135
+ <Text style={styles.voteBtnText}>▲ {item.upvotes}</Text>
136
+ </TouchableOpacity>
137
+ <TouchableOpacity style={styles.voteBtn} onPress={() => onVote(item.id, "DOWN")}>
138
+ <Text style={styles.voteBtnText}>▼ {item.downvotes}</Text>
139
+ </TouchableOpacity>
140
+ </View>
141
+ </View>
142
+ );
143
+ }
144
+
145
+ const styles = StyleSheet.create({
146
+ sheet: { backgroundColor: "#fff", borderRadius: 16 },
147
+ handle: { backgroundColor: "#d1d5db", width: 36, height: 4 },
148
+ header: { paddingHorizontal: 20, paddingTop: 8, paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: "#e5e7eb" },
149
+ title: { fontSize: 16, fontWeight: "700", color: "#111827" },
150
+ tabs: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: "#e5e7eb" },
151
+ tab: { flex: 1, paddingVertical: 12, alignItems: "center", borderBottomWidth: 2, borderBottomColor: "transparent" },
152
+ activeTab: { borderBottomColor: "#6366f1" },
153
+ tabText: { fontSize: 13, fontWeight: "500", color: "#6b7280" },
154
+ activeTabText: { color: "#6366f1" },
155
+ body: { padding: 16 },
156
+ textarea: { borderWidth: 1, borderColor: "#d1d5db", borderRadius: 8, padding: 10, fontSize: 14, minHeight: 100, textAlignVertical: "top", color: "#111827" },
157
+ typeRow: { flexDirection: "row", gap: 8, marginVertical: 10 },
158
+ typeBtn: { paddingVertical: 5, paddingHorizontal: 12, borderRadius: 9999, borderWidth: 1, borderColor: "#d1d5db" },
159
+ selectedTypeBtn: { backgroundColor: "#6366f1", borderColor: "#6366f1" },
160
+ typeBtnText: { fontSize: 12, color: "#374151" },
161
+ selectedTypeBtnText: { color: "#fff" },
162
+ submitBtn: { backgroundColor: "#6366f1", borderRadius: 8, padding: 12, alignItems: "center", marginTop: 8 },
163
+ disabledBtn: { opacity: 0.5 },
164
+ submitBtnText: { color: "#fff", fontSize: 14, fontWeight: "600" },
165
+ msgText: { textAlign: "center", fontSize: 13, color: "#6b7280", marginTop: 8 },
166
+ emptyText: { textAlign: "center", color: "#9ca3af", fontSize: 14, paddingVertical: 32 },
167
+ feedItem: { paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: "#f3f4f6" },
168
+ feedContent: { fontSize: 14, color: "#1f2937", marginBottom: 6 },
169
+ feedMeta: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 },
170
+ badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 9999 },
171
+ badgeBug: { backgroundColor: "#fee2e2" },
172
+ badgeFeature: { backgroundColor: "#dbeafe" },
173
+ badgeGeneral: { backgroundColor: "#f3f4f6" },
174
+ badgeText: { fontSize: 11, fontWeight: "500" },
175
+ feedDate: { fontSize: 11, color: "#9ca3af" },
176
+ voteRow: { flexDirection: "row", gap: 8 },
177
+ voteBtn: { flexDirection: "row", alignItems: "center", paddingHorizontal: 10, paddingVertical: 4, borderRadius: 9999, borderWidth: 1, borderColor: "#e5e7eb" },
178
+ voteBtnText: { fontSize: 12, color: "#374151" },
179
+ });
package/src/api.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type {
2
+ Feedback,
3
+ FeedbackListResponse,
4
+ SubmitFeedbackPayload,
5
+ UpstepConfig,
6
+ VoteValue,
7
+ } from "@upstep/types";
8
+
9
+ export class UpstepApiClient {
10
+ private readonly baseUrl: string;
11
+ private readonly apiKey: string;
12
+ readonly userId?: string;
13
+
14
+ constructor(config: UpstepConfig) {
15
+ this.baseUrl = (config.baseUrl ?? "https://upstep.io").replace(/\/$/, "");
16
+ this.apiKey = config.apiKey;
17
+ if (config.userId !== undefined) this.userId = config.userId;
18
+ }
19
+
20
+ private headers(): Record<string, string> {
21
+ return { "Content-Type": "application/json", "x-api-key": this.apiKey };
22
+ }
23
+
24
+ async listFeedback(params?: { cursor?: string; limit?: number; sort?: "date" | "votes" }): Promise<FeedbackListResponse> {
25
+ const qs = new URLSearchParams();
26
+ if (params?.cursor) qs.set("cursor", params.cursor);
27
+ if (params?.limit) qs.set("limit", String(params.limit));
28
+ if (params?.sort) qs.set("sort", params.sort);
29
+ const res = await fetch(`${this.baseUrl}/api/sdk/feedback?${qs}`, { headers: this.headers() });
30
+ if (!res.ok) throw new Error(`Upstep: ${res.status}`);
31
+ return res.json() as Promise<FeedbackListResponse>;
32
+ }
33
+
34
+ async submitFeedback(payload: SubmitFeedbackPayload): Promise<Feedback> {
35
+ const res = await fetch(`${this.baseUrl}/api/sdk/feedback`, {
36
+ method: "POST",
37
+ headers: this.headers(),
38
+ body: JSON.stringify({ ...payload, endUserId: payload.endUserId ?? this.userId }),
39
+ });
40
+ if (!res.ok) throw new Error(`Upstep: ${res.status}`);
41
+ return res.json() as Promise<Feedback>;
42
+ }
43
+
44
+ async vote(feedbackId: string, value: VoteValue): Promise<void> {
45
+ const res = await fetch(`${this.baseUrl}/api/sdk/feedback/${feedbackId}/vote`, {
46
+ method: "POST",
47
+ headers: this.headers(),
48
+ body: JSON.stringify({ value, endUserId: this.userId }),
49
+ });
50
+ if (!res.ok) throw new Error(`Upstep: ${res.status}`);
51
+ }
52
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { FeedbackProvider, useUpstep } from "./FeedbackContext";
2
+ export { FeedbackSheet } from "./FeedbackSheet";
3
+ export { FeedbackButton } from "./FeedbackButton";
4
+ export { useShakeToFeedback } from "./useShakeToFeedback";
5
+ export type { UpstepConfig, Feedback, FeedbackType, FeedbackStatus, VoteValue } from "@upstep/types";
@@ -0,0 +1,42 @@
1
+ import { useEffect } from "react";
2
+ import { useUpstep } from "./FeedbackContext";
3
+
4
+ // expo-sensors is a peer dependency when using Expo; skip if not available
5
+ let Accelerometer: {
6
+ setUpdateInterval: (ms: number) => void;
7
+ addListener: (cb: (data: { x: number; y: number; z: number }) => void) => { remove: () => void };
8
+ } | undefined;
9
+
10
+ try {
11
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
12
+ Accelerometer = (require("expo-sensors") as { Accelerometer: typeof Accelerometer }).Accelerometer;
13
+ } catch {
14
+ // expo-sensors not installed — shake detection unavailable
15
+ }
16
+
17
+ const SHAKE_THRESHOLD = 2.5;
18
+ const COOLDOWN_MS = 2000;
19
+
20
+ export function useShakeToFeedback() {
21
+ const { openSheet } = useUpstep();
22
+
23
+ useEffect(() => {
24
+ if (!Accelerometer) return;
25
+
26
+ let lastShake = 0;
27
+ Accelerometer.setUpdateInterval(100);
28
+
29
+ const subscription = Accelerometer.addListener(({ x, y, z }) => {
30
+ const total = Math.sqrt(x * x + y * y + z * z);
31
+ if (total > SHAKE_THRESHOLD) {
32
+ const now = Date.now();
33
+ if (now - lastShake > COOLDOWN_MS) {
34
+ lastShake = now;
35
+ openSheet();
36
+ }
37
+ }
38
+ });
39
+
40
+ return () => subscription.remove();
41
+ }, [openSheet]);
42
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-native",
5
+ "lib": ["ES2020"],
6
+ "noEmit": true
7
+ },
8
+ "include": ["src"]
9
+ }