@upstep/react-native 0.3.0 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@upstep/react-native",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Upstep feedback SDK for React Native",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -12,7 +12,7 @@
12
12
  }
13
13
  },
14
14
  "dependencies": {
15
- "@upstep/types": "0.2.0"
15
+ "@upstep/types": "0.5.0"
16
16
  },
17
17
  "peerDependencies": {
18
18
  "@gorhom/bottom-sheet": ">=4",
@@ -8,14 +8,19 @@ interface Props {
8
8
  }
9
9
 
10
10
  export function FeedbackButton({ position = "bottom-right", label = "Feedback" }: Props) {
11
- const { openSheet } = useUpstep();
11
+ const { openSheet, accentColor } = useUpstep();
12
12
 
13
13
  return (
14
14
  <TouchableOpacity
15
15
  onPress={openSheet}
16
- style={[styles.btn, position === "bottom-left" ? styles.left : styles.right]}
16
+ style={[
17
+ styles.btn,
18
+ { backgroundColor: accentColor },
19
+ position === "bottom-left" ? styles.left : styles.right,
20
+ ]}
17
21
  activeOpacity={0.85}
18
22
  >
23
+ <Text style={styles.icon}>💬</Text>
19
24
  <Text style={styles.label}>{label}</Text>
20
25
  </TouchableOpacity>
21
26
  );
@@ -25,18 +30,21 @@ const styles = StyleSheet.create({
25
30
  btn: {
26
31
  position: "absolute",
27
32
  bottom: 24,
28
- backgroundColor: "#6366f1",
33
+ flexDirection: "row",
34
+ alignItems: "center",
35
+ gap: 6,
29
36
  borderRadius: 9999,
30
37
  paddingVertical: 12,
31
- paddingHorizontal: 20,
38
+ paddingHorizontal: 18,
32
39
  shadowColor: "#000",
33
- shadowOffset: { width: 0, height: 4 },
40
+ shadowOffset: { width: 0, height: 5 },
34
41
  shadowOpacity: 0.2,
35
- shadowRadius: 6,
42
+ shadowRadius: 10,
36
43
  elevation: 6,
37
44
  zIndex: 100,
38
45
  },
39
46
  right: { right: 20 },
40
47
  left: { left: 20 },
48
+ icon: { fontSize: 14 },
41
49
  label: { color: "#fff", fontSize: 14, fontWeight: "600" },
42
50
  });
@@ -7,21 +7,32 @@ import React, {
7
7
  useRef,
8
8
  type ReactNode,
9
9
  } from "react";
10
- import type { Feedback, FeedbackType, UpstepConfig, VoteValue } from "@upstep/types";
10
+ import type {
11
+ Feedback,
12
+ FeedbackType,
13
+ FeedbackWithComments,
14
+ UpstepConfig,
15
+ VoteValue,
16
+ } from "@upstep/types";
11
17
  import { UpstepApiClient } from "./api";
12
18
  import type BottomSheet from "@gorhom/bottom-sheet";
13
19
 
20
+ const DEFAULT_ACCENT = "#D97757";
21
+
14
22
  interface UpstepContextValue {
15
23
  client: UpstepApiClient;
16
24
  feedItems: Feedback[];
17
25
  loadFeed: () => Promise<void>;
18
- submit: (content: string, type?: FeedbackType) => Promise<void>;
26
+ submit: (title: string, content: string, type?: FeedbackType) => Promise<void>;
19
27
  vote: (feedbackId: string, value: VoteValue) => Promise<void>;
28
+ getItem: (feedbackId: string) => Promise<FeedbackWithComments>;
20
29
  /** Open the feedback sheet — call from your own UI (settings, menu, etc.). */
21
30
  openSheet: () => void;
22
31
  /** Set / update the end-user id at runtime (e.g. after the user logs in). */
23
32
  identify: (userId: string | undefined) => void;
24
33
  sheetRef: React.RefObject<BottomSheet | null>;
34
+ accentColor: string;
35
+ theme: "light" | "dark" | "auto";
25
36
  }
26
37
 
27
38
  const UpstepContext = createContext<UpstepContextValue | null>(null);
@@ -34,14 +45,13 @@ export function FeedbackProvider({
34
45
  const [feedItems, setFeedItems] = useState<Feedback[]>([]);
35
46
  const sheetRef = useRef<BottomSheet>(null);
36
47
 
37
- // Keep the end-user id in sync if `userId` resolves after mount (async login).
38
48
  useEffect(() => {
39
49
  client.setUserId(config.userId);
40
50
  }, [client, config.userId]);
41
51
 
42
52
  const loadFeed = useCallback(async () => {
43
53
  try {
44
- const data = await client.listFeedback({ sort: "votes", limit: 20 });
54
+ const data = await client.listFeedback({ sort: "votes", limit: 30 });
45
55
  setFeedItems(data.items);
46
56
  } catch {
47
57
  // non-critical
@@ -50,26 +60,45 @@ export function FeedbackProvider({
50
60
 
51
61
  useEffect(() => { loadFeed(); }, [loadFeed]);
52
62
 
53
- const submit = useCallback(async (content: string, type?: FeedbackType) => {
54
- const payload: Parameters<typeof client.submitFeedback>[0] = { content };
55
- if (type !== undefined) payload.type = type;
56
- await client.submitFeedback(payload);
57
- await loadFeed();
58
- }, [client, loadFeed]);
63
+ const submit = useCallback(
64
+ async (title: string, content: string, type?: FeedbackType) => {
65
+ const payload: Parameters<typeof client.submitFeedback>[0] = { content };
66
+ if (title.trim()) payload.title = title.trim();
67
+ if (type !== undefined) payload.type = type;
68
+ await client.submitFeedback(payload);
69
+ await loadFeed();
70
+ },
71
+ [client, loadFeed]
72
+ );
59
73
 
60
74
  const vote = useCallback(async (feedbackId: string, value: VoteValue) => {
61
75
  await client.vote(feedbackId, value);
62
76
  await loadFeed();
63
77
  }, [client, loadFeed]);
64
78
 
79
+ const getItem = useCallback(
80
+ (feedbackId: string) => client.getItem(feedbackId),
81
+ [client]
82
+ );
83
+
65
84
  const openSheet = useCallback(() => {
66
85
  sheetRef.current?.expand();
67
86
  }, []);
68
87
 
69
- const identify = useCallback((userId: string | undefined) => client.setUserId(userId), [client]);
88
+ const identify = useCallback(
89
+ (userId: string | undefined) => client.setUserId(userId),
90
+ [client]
91
+ );
70
92
 
71
93
  return (
72
- <UpstepContext.Provider value={{ client, feedItems, loadFeed, submit, vote, openSheet, identify, sheetRef }}>
94
+ <UpstepContext.Provider
95
+ value={{
96
+ client, feedItems, loadFeed, submit, vote, getItem,
97
+ openSheet, identify, sheetRef,
98
+ accentColor: config.accentColor ?? DEFAULT_ACCENT,
99
+ theme: config.theme ?? "auto",
100
+ }}
101
+ >
73
102
  {children}
74
103
  </UpstepContext.Provider>
75
104
  );
@@ -1,43 +1,78 @@
1
- import React, { useState, useMemo } from "react";
1
+ import React, { useState, useMemo, useCallback } from "react";
2
2
  import {
3
3
  View,
4
4
  Text,
5
5
  TextInput,
6
6
  TouchableOpacity,
7
7
  StyleSheet,
8
- ScrollView,
9
8
  ActivityIndicator,
9
+ ScrollView,
10
+ useColorScheme,
11
+ Platform,
10
12
  } from "react-native";
11
13
  import BottomSheet, { BottomSheetView, BottomSheetScrollView } from "@gorhom/bottom-sheet";
12
- import type { Feedback, FeedbackType } from "@upstep/types";
14
+ import type { Comment, Feedback, FeedbackType, FeedbackWithComments } from "@upstep/types";
13
15
  import { useUpstep } from "./FeedbackContext";
14
16
 
15
- type Tab = "submit" | "feed";
17
+ // ─── Theme ────────────────────────────────────────────────────────────────────
16
18
 
17
- const TYPES: FeedbackType[] = ["BUG", "FEATURE", "GENERAL"];
19
+ interface Palette {
20
+ bg: string;
21
+ bgSoft: string;
22
+ bgHover: string;
23
+ text: string;
24
+ textSoft: string;
25
+ textFaint: string;
26
+ border: string;
27
+ handle: string;
28
+ shadow: string;
29
+ }
30
+
31
+ const LIGHT: Palette = {
32
+ bg: "#ffffff",
33
+ bgSoft: "#f6f5f2",
34
+ bgHover: "#efede8",
35
+ text: "#1a1915",
36
+ textSoft: "#56544d",
37
+ textFaint: "#9b9890",
38
+ border: "#e8e6df",
39
+ handle: "#dad7ce",
40
+ shadow: "#00000014",
41
+ };
42
+
43
+ const DARK: Palette = {
44
+ bg: "#1c1b19",
45
+ bgSoft: "#262522",
46
+ bgHover: "#302e2a",
47
+ text: "#f5f4f0",
48
+ textSoft: "#b4b1a8",
49
+ textFaint: "#7d7a72",
50
+ border: "#33312c",
51
+ handle: "#48453f",
52
+ shadow: "#0000003a",
53
+ };
54
+
55
+ // ─── Navigation ───────────────────────────────────────────────────────────────
56
+
57
+ type Nav =
58
+ | { screen: "list" }
59
+ | { screen: "detail"; feedbackId: string }
60
+ | { screen: "create" };
61
+
62
+ // ─── FeedbackSheet ────────────────────────────────────────────────────────────
18
63
 
19
64
  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);
65
+ const { sheetRef, accentColor, theme } = useUpstep();
66
+ const scheme = useColorScheme();
67
+ const isDark = theme === "dark" || (theme === "auto" && scheme === "dark");
68
+ const p = isDark ? DARK : LIGHT;
27
69
 
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
- }
70
+ const [nav, setNav] = useState<Nav>({ screen: "list" });
71
+ const snapPoints = useMemo(() => ["90%"], []);
72
+
73
+ const goList = useCallback(() => setNav({ screen: "list" }), []);
74
+ const goDetail = useCallback((id: string) => setNav({ screen: "detail", feedbackId: id }), []);
75
+ const goCreate = useCallback(() => setNav({ screen: "create" }), []);
41
76
 
42
77
  return (
43
78
  <BottomSheet
@@ -45,135 +80,561 @@ export function FeedbackSheet() {
45
80
  index={-1}
46
81
  snapPoints={snapPoints}
47
82
  enablePanDownToClose
48
- backgroundStyle={styles.sheet}
49
- handleIndicatorStyle={styles.handle}
83
+ backgroundStyle={{ backgroundColor: p.bg, borderRadius: 24 }}
84
+ handleIndicatorStyle={{ backgroundColor: p.handle, width: 40, height: 4 }}
85
+ onClose={() => setNav({ screen: "list" })}
50
86
  >
51
- <BottomSheetView style={styles.header}>
52
- <Text style={styles.title}>Share feedback</Text>
87
+ {nav.screen === "list" && (
88
+ <FeedList p={p} accent={accentColor} onSelect={goDetail} onCreate={goCreate} />
89
+ )}
90
+ {nav.screen === "detail" && (
91
+ <FeedDetail p={p} accent={accentColor} feedbackId={nav.feedbackId} onBack={goList} />
92
+ )}
93
+ {nav.screen === "create" && (
94
+ <FeedCreate p={p} accent={accentColor} onBack={goList} />
95
+ )}
96
+ </BottomSheet>
97
+ );
98
+ }
99
+
100
+ // ─── FeedList ─────────────────────────────────────────────────────────────────
101
+
102
+ function FeedList({
103
+ p, accent, onSelect, onCreate,
104
+ }: {
105
+ p: Palette; accent: string;
106
+ onSelect: (id: string) => void;
107
+ onCreate: () => void;
108
+ }) {
109
+ const { feedItems, vote } = useUpstep();
110
+
111
+ return (
112
+ <>
113
+ <BottomSheetView style={[styles.header, { borderBottomColor: p.border }]}>
114
+ <Text style={[styles.headerTitle, { color: p.text }]}>Feedback</Text>
115
+ <TouchableOpacity
116
+ onPress={onCreate}
117
+ activeOpacity={0.8}
118
+ style={[styles.newBtn, { backgroundColor: accent }]}
119
+ >
120
+ <Text style={styles.newBtnText}>+ New</Text>
121
+ </TouchableOpacity>
53
122
  </BottomSheetView>
54
123
 
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
- ))}
124
+ <BottomSheetScrollView contentContainerStyle={styles.listBody}>
125
+ {feedItems.length === 0 ? (
126
+ <EmptyState p={p} message="No feedback yet. Be the first to add one." />
127
+ ) : (
128
+ feedItems.map((f) => (
129
+ <FeedCard
130
+ key={f.id}
131
+ item={f}
132
+ p={p}
133
+ accent={accent}
134
+ onPress={() => onSelect(f.id)}
135
+ onVote={() => vote(f.id, "UP")}
136
+ />
137
+ ))
138
+ )}
139
+ </BottomSheetScrollView>
140
+ </>
141
+ );
142
+ }
143
+
144
+ function FeedCard({
145
+ item, p, accent, onPress, onVote,
146
+ }: {
147
+ item: Feedback; p: Palette; accent: string;
148
+ onPress: () => void;
149
+ onVote: () => void;
150
+ }) {
151
+ const voted = item.userVote === "UP";
152
+ const displayTitle = item.title ?? item.content;
153
+ const hasDescription = Boolean(item.title);
154
+
155
+ return (
156
+ <TouchableOpacity
157
+ onPress={onPress}
158
+ activeOpacity={0.85}
159
+ style={[styles.card, { borderColor: p.border, backgroundColor: p.bg }]}
160
+ >
161
+ <TouchableOpacity
162
+ onPress={onVote}
163
+ activeOpacity={0.8}
164
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 4 }}
165
+ style={[
166
+ styles.voteBox,
167
+ { borderColor: voted ? accent : p.border, backgroundColor: voted ? accent + "1a" : p.bgSoft },
168
+ ]}
169
+ >
170
+ <Text style={[styles.voteArrow, { color: accent }]}>▲</Text>
171
+ <Text style={[styles.voteCount, { color: p.text }]}>{item.upvotes}</Text>
172
+ </TouchableOpacity>
173
+
174
+ <View style={styles.cardBody}>
175
+ <Text style={[styles.cardTitle, { color: p.text }]} numberOfLines={2}>
176
+ {displayTitle}
177
+ </Text>
178
+ {hasDescription && (
179
+ <Text style={[styles.cardDescription, { color: p.textFaint }]} numberOfLines={1}>
180
+ {item.content}
181
+ </Text>
182
+ )}
183
+ <View style={styles.cardMeta}>
184
+ <TypeBadge type={item.type} p={p} />
185
+ <StatusDot status={item.status} />
186
+ <Text style={[styles.cardDate, { color: p.textFaint }]}>
187
+ {formatDate(item.createdAt)}
188
+ </Text>
189
+ </View>
67
190
  </View>
68
191
 
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
- ))}
192
+ <Text style={[styles.chevron, { color: p.textFaint }]}>›</Text>
193
+ </TouchableOpacity>
194
+ );
195
+ }
196
+
197
+ // ─── FeedDetail ───────────────────────────────────────────────────────────────
198
+
199
+ function FeedDetail({
200
+ p, accent, feedbackId, onBack,
201
+ }: {
202
+ p: Palette; accent: string; feedbackId: string; onBack: () => void;
203
+ }) {
204
+ const { vote, getItem } = useUpstep();
205
+ const [item, setItem] = useState<FeedbackWithComments | null>(null);
206
+ const [loading, setLoading] = useState(true);
207
+ const [voting, setVoting] = useState(false);
208
+
209
+ React.useEffect(() => {
210
+ let cancelled = false;
211
+ setLoading(true);
212
+ getItem(feedbackId).then((data) => {
213
+ if (!cancelled) { setItem(data); setLoading(false); }
214
+ }).catch(() => {
215
+ if (!cancelled) setLoading(false);
216
+ });
217
+ return () => { cancelled = true; };
218
+ }, [feedbackId, getItem]);
219
+
220
+ async function handleVote() {
221
+ if (!item || voting) return;
222
+ setVoting(true);
223
+ try {
224
+ await vote(item.id, "UP");
225
+ // Refresh
226
+ const updated = await getItem(feedbackId);
227
+ setItem(updated);
228
+ } finally {
229
+ setVoting(false);
230
+ }
231
+ }
232
+
233
+ return (
234
+ <>
235
+ <BottomSheetView style={[styles.header, { borderBottomColor: p.border }]}>
236
+ <TouchableOpacity onPress={onBack} hitSlop={{ top: 8, bottom: 8, left: 8, right: 16 }}>
237
+ <Text style={[styles.backBtn, { color: accent }]}>← Back</Text>
238
+ </TouchableOpacity>
239
+ </BottomSheetView>
240
+
241
+ {loading || !item ? (
242
+ <BottomSheetView style={styles.centered}>
243
+ <ActivityIndicator color={accent} />
244
+ </BottomSheetView>
245
+ ) : (
246
+ <BottomSheetScrollView contentContainerStyle={styles.detailBody}>
247
+ {/* Title + badges */}
248
+ {item.title && (
249
+ <Text style={[styles.detailTitle, { color: p.text }]}>{item.title}</Text>
250
+ )}
251
+ <View style={styles.detailBadgeRow}>
252
+ <TypeBadge type={item.type} p={p} />
253
+ <StatusBadge status={item.status} p={p} />
92
254
  </View>
255
+
256
+ {/* Description */}
257
+ <Text style={[styles.detailContent, { color: p.textSoft }]}>{item.content}</Text>
258
+
259
+ {/* Upvote */}
93
260
  <TouchableOpacity
94
- onPress={handleSubmit}
95
- disabled={loading || !content.trim()}
96
- style={[styles.submitBtn, (loading || !content.trim()) && styles.disabledBtn]}
261
+ onPress={handleVote}
262
+ disabled={voting}
263
+ activeOpacity={0.8}
264
+ style={[
265
+ styles.upvoteBtn,
266
+ {
267
+ borderColor: item.userVote === "UP" ? accent : p.border,
268
+ backgroundColor: item.userVote === "UP" ? accent + "15" : p.bgSoft,
269
+ },
270
+ ]}
97
271
  >
98
- {loading ? (
99
- <ActivityIndicator color="#fff" />
272
+ {voting ? (
273
+ <ActivityIndicator size="small" color={accent} />
100
274
  ) : (
101
- <Text style={styles.submitBtnText}>Send feedback</Text>
275
+ <>
276
+ <Text style={[styles.upvoteArrow, { color: accent }]}>▲</Text>
277
+ <Text style={[styles.upvoteCount, { color: p.text }]}>{item.upvotes}</Text>
278
+ <Text style={[styles.upvoteLabel, { color: p.textFaint }]}>
279
+ {item.userVote === "UP" ? "Upvoted" : "Upvote"}
280
+ </Text>
281
+ </>
102
282
  )}
103
283
  </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
- ))
284
+
285
+ {/* Developer comments */}
286
+ {item.comments.length > 0 && (
287
+ <View style={[styles.commentsSection, { borderTopColor: p.border }]}>
288
+ <Text style={[styles.commentsLabel, { color: p.textFaint }]}>
289
+ DEVELOPER RESPONSE
290
+ </Text>
291
+ {item.comments.map((c) => (
292
+ <CommentBubble key={c.id} comment={c} p={p} accent={accent} />
293
+ ))}
294
+ </View>
114
295
  )}
115
296
  </BottomSheetScrollView>
116
297
  )}
117
- </BottomSheet>
298
+ </>
118
299
  );
119
300
  }
120
301
 
121
- function FeedItem({ item, onVote }: { item: Feedback; onVote: (id: string, v: "UP" | "DOWN") => void }) {
302
+ function CommentBubble({ comment, p, accent }: { comment: Comment; p: Palette; accent: string }) {
122
303
  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}
304
+ <View style={[styles.commentBubble, { backgroundColor: p.bgSoft, borderColor: p.border }]}>
305
+ <View style={styles.commentMeta}>
306
+ <View style={[styles.ownerBadge, { backgroundColor: accent + "20" }]}>
307
+ <Text style={[styles.ownerBadgeText, { color: accent }]}>
308
+ {comment.authorName ?? "Team"}
129
309
  </Text>
130
310
  </View>
131
- <Text style={styles.feedDate}>{new Date(item.createdAt).toLocaleDateString()}</Text>
311
+ <Text style={[styles.commentDate, { color: p.textFaint }]}>
312
+ {formatDate(comment.createdAt)}
313
+ </Text>
132
314
  </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>
315
+ <Text style={[styles.commentContent, { color: p.textSoft }]}>{comment.content}</Text>
316
+ </View>
317
+ );
318
+ }
319
+
320
+ // ─── FeedCreate ───────────────────────────────────────────────────────────────
321
+
322
+ const TYPES: FeedbackType[] = ["BUG", "FEATURE", "GENERAL"];
323
+ const TYPE_LABELS: Record<FeedbackType, string> = {
324
+ BUG: "Bug report",
325
+ FEATURE: "Feature request",
326
+ GENERAL: "General",
327
+ };
328
+
329
+ function FeedCreate({ p, accent, onBack }: { p: Palette; accent: string; onBack: () => void }) {
330
+ const { submit } = useUpstep();
331
+ const [title, setTitle] = useState("");
332
+ const [description, setDescription] = useState("");
333
+ const [type, setType] = useState<FeedbackType>("GENERAL");
334
+ const [loading, setLoading] = useState(false);
335
+ const [success, setSuccess] = useState(false);
336
+ const [error, setError] = useState("");
337
+
338
+ async function handleSubmit() {
339
+ if (!title.trim() || loading) return;
340
+ setLoading(true);
341
+ setError("");
342
+ try {
343
+ await submit(title.trim(), description.trim() || title.trim(), type);
344
+ setSuccess(true);
345
+ // Return to list after a moment
346
+ setTimeout(onBack, 1400);
347
+ } catch {
348
+ setError("Something went wrong. Please try again.");
349
+ } finally {
350
+ setLoading(false);
351
+ }
352
+ }
353
+
354
+ return (
355
+ <>
356
+ <BottomSheetView style={[styles.header, { borderBottomColor: p.border }]}>
357
+ <TouchableOpacity onPress={onBack} hitSlop={{ top: 8, bottom: 8, left: 8, right: 16 }}>
358
+ <Text style={[styles.backBtn, { color: accent }]}>Cancel</Text>
139
359
  </TouchableOpacity>
140
- </View>
360
+ <Text style={[styles.headerTitle, { color: p.text }]}>New feedback</Text>
361
+ <View style={{ width: 60 }} />
362
+ </BottomSheetView>
363
+
364
+ <BottomSheetScrollView
365
+ contentContainerStyle={styles.createBody}
366
+ keyboardShouldPersistTaps="handled"
367
+ >
368
+ {success ? (
369
+ <View style={styles.centered}>
370
+ <Text style={[styles.successTitle, { color: p.text }]}>Submitted</Text>
371
+ <Text style={[styles.successSub, { color: p.textFaint }]}>
372
+ Thanks — we received your feedback.
373
+ </Text>
374
+ </View>
375
+ ) : (
376
+ <>
377
+ {/* Type selector */}
378
+ <Text style={[styles.fieldLabel, { color: p.textFaint }]}>TYPE</Text>
379
+ <View style={styles.typeRow}>
380
+ {TYPES.map((t) => {
381
+ const active = type === t;
382
+ return (
383
+ <TouchableOpacity
384
+ key={t}
385
+ onPress={() => setType(t)}
386
+ activeOpacity={0.8}
387
+ style={[
388
+ styles.typeBtn,
389
+ {
390
+ borderColor: active ? accent : p.border,
391
+ backgroundColor: active ? accent : p.bg,
392
+ },
393
+ ]}
394
+ >
395
+ <Text style={[styles.typeBtnText, { color: active ? "#fff" : p.textSoft }]}>
396
+ {TYPE_LABELS[t]}
397
+ </Text>
398
+ </TouchableOpacity>
399
+ );
400
+ })}
401
+ </View>
402
+
403
+ {/* Title */}
404
+ <Text style={[styles.fieldLabel, { color: p.textFaint }]}>TITLE</Text>
405
+ <TextInput
406
+ value={title}
407
+ onChangeText={setTitle}
408
+ placeholder="Short summary of the issue or idea"
409
+ placeholderTextColor={p.textFaint}
410
+ maxLength={200}
411
+ returnKeyType="next"
412
+ style={[
413
+ styles.titleInput,
414
+ { borderColor: p.border, backgroundColor: p.bgSoft, color: p.text },
415
+ ]}
416
+ />
417
+
418
+ {/* Description */}
419
+ <Text style={[styles.fieldLabel, { color: p.textFaint }]}>DESCRIPTION</Text>
420
+ <TextInput
421
+ value={description}
422
+ onChangeText={setDescription}
423
+ placeholder="Steps to reproduce, expected behaviour, or more context (optional)"
424
+ placeholderTextColor={p.textFaint}
425
+ multiline
426
+ numberOfLines={5}
427
+ maxLength={2000}
428
+ textAlignVertical="top"
429
+ style={[
430
+ styles.descInput,
431
+ { borderColor: p.border, backgroundColor: p.bgSoft, color: p.text },
432
+ ]}
433
+ />
434
+
435
+ {error ? (
436
+ <Text style={[styles.errorText, { color: "#d6453d" }]}>{error}</Text>
437
+ ) : null}
438
+
439
+ <TouchableOpacity
440
+ onPress={handleSubmit}
441
+ disabled={loading || !title.trim()}
442
+ activeOpacity={0.85}
443
+ style={[
444
+ styles.submitBtn,
445
+ { backgroundColor: accent },
446
+ (loading || !title.trim()) && styles.submitBtnDisabled,
447
+ ]}
448
+ >
449
+ {loading ? (
450
+ <ActivityIndicator color="#fff" />
451
+ ) : (
452
+ <Text style={styles.submitBtnText}>Submit feedback</Text>
453
+ )}
454
+ </TouchableOpacity>
455
+ </>
456
+ )}
457
+ </BottomSheetScrollView>
458
+ </>
459
+ );
460
+ }
461
+
462
+ // ─── Shared sub-components ────────────────────────────────────────────────────
463
+
464
+ function EmptyState({ p, message }: { p: Palette; message: string }) {
465
+ return (
466
+ <View style={styles.empty}>
467
+ <Text style={[styles.emptyText, { color: p.textFaint }]}>{message}</Text>
468
+ </View>
469
+ );
470
+ }
471
+
472
+ const TYPE_BG: Record<FeedbackType, string> = {
473
+ BUG: "#fdecec", FEATURE: "#e9f0fd", GENERAL: "",
474
+ };
475
+ const TYPE_FG: Record<FeedbackType, string> = {
476
+ BUG: "#d6453d", FEATURE: "#3b76d6", GENERAL: "",
477
+ };
478
+
479
+ function TypeBadge({ type, p }: { type: FeedbackType; p: Palette }) {
480
+ return (
481
+ <View style={[styles.badge, { backgroundColor: TYPE_BG[type] || p.bgSoft }]}>
482
+ <Text style={[styles.badgeText, { color: TYPE_FG[type] || p.textSoft }]}>
483
+ {type}
484
+ </Text>
485
+ </View>
486
+ );
487
+ }
488
+
489
+ const STATUS_COLORS: Record<string, string> = {
490
+ OPEN: "#b45309",
491
+ IN_PROGRESS: "#1d4ed8",
492
+ DONE: "#15803d",
493
+ PENDING: "#ea580c",
494
+ CLOSED: "#6b7280",
495
+ };
496
+
497
+ function StatusDot({ status }: { status: string }) {
498
+ const color = STATUS_COLORS[status] ?? "#9ca3af";
499
+ return <View style={[styles.statusDot, { backgroundColor: color }]} />;
500
+ }
501
+
502
+ function StatusBadge({ status, p }: { status: string; p: Palette }) {
503
+ const label = status.replace("_", " ");
504
+ return (
505
+ <View style={[styles.statusBadge, { backgroundColor: p.bgSoft, borderColor: p.border }]}>
506
+ <StatusDot status={status} />
507
+ <Text style={[styles.statusBadgeText, { color: p.textSoft }]}>{label}</Text>
141
508
  </View>
142
509
  );
143
510
  }
144
511
 
512
+ function formatDate(iso: string) {
513
+ const d = new Date(iso);
514
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
515
+ }
516
+
517
+ // ─── Styles ───────────────────────────────────────────────────────────────────
518
+
145
519
  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" },
520
+ // Header
521
+ header: {
522
+ flexDirection: "row",
523
+ alignItems: "center",
524
+ justifyContent: "space-between",
525
+ paddingHorizontal: 20,
526
+ paddingVertical: 14,
527
+ borderBottomWidth: StyleSheet.hairlineWidth,
528
+ },
529
+ headerTitle: { fontSize: 16, fontWeight: "700", letterSpacing: -0.3 },
530
+ backBtn: { fontSize: 14, fontWeight: "600" },
531
+ newBtn: {
532
+ borderRadius: 20,
533
+ paddingHorizontal: 14,
534
+ paddingVertical: 7,
535
+ },
536
+ newBtnText: { color: "#fff", fontSize: 13, fontWeight: "700" },
537
+
538
+ // List
539
+ listBody: { padding: 16, paddingBottom: 40 },
540
+
541
+ // Card
542
+ card: {
543
+ flexDirection: "row",
544
+ alignItems: "center",
545
+ gap: 12,
546
+ borderWidth: StyleSheet.hairlineWidth,
547
+ borderRadius: 16,
548
+ padding: 14,
549
+ marginBottom: 10,
550
+ },
551
+ voteBox: {
552
+ alignItems: "center",
553
+ justifyContent: "center",
554
+ width: 44,
555
+ paddingVertical: 8,
556
+ borderRadius: 11,
557
+ borderWidth: 1,
558
+ },
559
+ voteArrow: { fontSize: 11, lineHeight: 13 },
560
+ voteCount: { fontSize: 15, fontWeight: "700", lineHeight: 18 },
561
+ cardBody: { flex: 1 },
562
+ cardTitle: { fontSize: 14, fontWeight: "600", lineHeight: 19, marginBottom: 2 },
563
+ cardDescription: { fontSize: 12.5, lineHeight: 17, marginBottom: 6 },
564
+ cardMeta: { flexDirection: "row", alignItems: "center", gap: 6 },
565
+ cardDate: { fontSize: 11 },
566
+ chevron: { fontSize: 22, fontWeight: "300", marginLeft: 4 },
567
+
568
+ // Detail
569
+ detailBody: { padding: 20, paddingBottom: 50 },
570
+ detailTitle: { fontSize: 22, fontWeight: "700", letterSpacing: -0.4, marginBottom: 10, lineHeight: 28 },
571
+ detailBadgeRow: { flexDirection: "row", gap: 8, marginBottom: 16 },
572
+ detailContent: { fontSize: 15, lineHeight: 23, marginBottom: 24 },
573
+ upvoteBtn: {
574
+ flexDirection: "row",
575
+ alignItems: "center",
576
+ gap: 8,
577
+ alignSelf: "flex-start",
578
+ borderWidth: 1,
579
+ borderRadius: 12,
580
+ paddingHorizontal: 16,
581
+ paddingVertical: 10,
582
+ marginBottom: 8,
583
+ minWidth: 80,
584
+ justifyContent: "center",
585
+ },
586
+ upvoteArrow: { fontSize: 13, lineHeight: 15 },
587
+ upvoteCount: { fontSize: 18, fontWeight: "700" },
588
+ upvoteLabel: { fontSize: 13, fontWeight: "500" },
589
+
590
+ // Comments
591
+ commentsSection: { marginTop: 28, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 20 },
592
+ commentsLabel: { fontSize: 10, fontWeight: "700", letterSpacing: 0.8, marginBottom: 12 },
593
+ commentBubble: {
594
+ borderRadius: 12,
595
+ borderWidth: StyleSheet.hairlineWidth,
596
+ padding: 14,
597
+ marginBottom: 10,
598
+ },
599
+ commentMeta: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 },
600
+ ownerBadge: { borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3 },
601
+ ownerBadgeText: { fontSize: 11, fontWeight: "700" },
602
+ commentDate: { fontSize: 11 },
603
+ commentContent: { fontSize: 14, lineHeight: 21 },
604
+
605
+ // Create
606
+ createBody: { padding: 20, paddingBottom: 50 },
607
+ fieldLabel: { fontSize: 10, fontWeight: "700", letterSpacing: 0.8, marginBottom: 8 },
608
+ typeRow: { flexDirection: "row", gap: 8, marginBottom: 20, flexWrap: "wrap" },
609
+ typeBtn: { paddingVertical: 9, paddingHorizontal: 14, borderRadius: 10, borderWidth: 1 },
610
+ typeBtnText: { fontSize: 13, fontWeight: "600" },
611
+ titleInput: {
612
+ borderWidth: 1, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12,
613
+ fontSize: 15, marginBottom: 20,
614
+ ...(Platform.OS === "ios" ? {} : { paddingTop: 12 }),
615
+ },
616
+ descInput: {
617
+ borderWidth: 1, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12,
618
+ fontSize: 14, minHeight: 110, marginBottom: 20,
619
+ ...(Platform.OS === "ios" ? {} : { paddingTop: 12 }),
620
+ },
621
+ errorText: { fontSize: 13, marginBottom: 12, fontWeight: "500" },
622
+ submitBtn: { borderRadius: 12, padding: 14, alignItems: "center" },
623
+ submitBtnDisabled: { opacity: 0.5 },
624
+ submitBtnText: { color: "#fff", fontSize: 15, fontWeight: "700" },
625
+ successTitle: { fontSize: 20, fontWeight: "700", textAlign: "center", marginBottom: 6 },
626
+ successSub: { fontSize: 14, textAlign: "center" },
627
+
628
+ // Shared
629
+ empty: { alignItems: "center", paddingVertical: 60 },
630
+ emptyText: { fontSize: 14 },
631
+ centered: { flex: 1, alignItems: "center", justifyContent: "center", paddingVertical: 60 },
632
+ badge: { paddingHorizontal: 7, paddingVertical: 3, borderRadius: 6 },
633
+ badgeText: { fontSize: 10, fontWeight: "700", letterSpacing: 0.2 },
634
+ statusDot: { width: 6, height: 6, borderRadius: 3 },
635
+ statusBadge: {
636
+ flexDirection: "row", alignItems: "center", gap: 5, paddingHorizontal: 8,
637
+ paddingVertical: 3, borderRadius: 6, borderWidth: StyleSheet.hairlineWidth,
638
+ },
639
+ statusBadgeText: { fontSize: 10, fontWeight: "600", textTransform: "capitalize" },
179
640
  });
package/src/api.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  Feedback,
3
3
  FeedbackListResponse,
4
+ FeedbackWithComments,
4
5
  SubmitFeedbackPayload,
5
6
  UpstepConfig,
6
7
  VoteValue,
@@ -31,11 +32,21 @@ export class UpstepApiClient {
31
32
  if (params?.cursor) qs.set("cursor", params.cursor);
32
33
  if (params?.limit) qs.set("limit", String(params.limit));
33
34
  if (params?.sort) qs.set("sort", params.sort);
35
+ // Pass userId so the server can include the user's own pending items.
36
+ if (this.userId) qs.set("endUserId", this.userId);
34
37
  const res = await fetch(`${this.baseUrl}/api/sdk/feedback?${qs}`, { headers: this.headers() });
35
38
  if (!res.ok) throw new Error(`Upstep: ${res.status}`);
36
39
  return res.json() as Promise<FeedbackListResponse>;
37
40
  }
38
41
 
42
+ async getItem(feedbackId: string): Promise<FeedbackWithComments> {
43
+ const res = await fetch(`${this.baseUrl}/api/sdk/feedback/${feedbackId}`, {
44
+ headers: this.headers(),
45
+ });
46
+ if (!res.ok) throw new Error(`Upstep: ${res.status}`);
47
+ return res.json() as Promise<FeedbackWithComments>;
48
+ }
49
+
39
50
  async submitFeedback(payload: SubmitFeedbackPayload): Promise<Feedback> {
40
51
  const res = await fetch(`${this.baseUrl}/api/sdk/feedback`, {
41
52
  method: "POST",