@upstep/react-native 0.4.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.4.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.4.0"
15
+ "@upstep/types": "0.5.0"
16
16
  },
17
17
  "peerDependencies": {
18
18
  "@gorhom/bottom-sheet": ">=4",
@@ -7,7 +7,13 @@ 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
 
@@ -17,16 +23,15 @@ interface UpstepContextValue {
17
23
  client: UpstepApiClient;
18
24
  feedItems: Feedback[];
19
25
  loadFeed: () => Promise<void>;
20
- submit: (content: string, type?: FeedbackType) => Promise<void>;
26
+ submit: (title: string, content: string, type?: FeedbackType) => Promise<void>;
21
27
  vote: (feedbackId: string, value: VoteValue) => Promise<void>;
28
+ getItem: (feedbackId: string) => Promise<FeedbackWithComments>;
22
29
  /** Open the feedback sheet — call from your own UI (settings, menu, etc.). */
23
30
  openSheet: () => void;
24
31
  /** Set / update the end-user id at runtime (e.g. after the user logs in). */
25
32
  identify: (userId: string | undefined) => void;
26
33
  sheetRef: React.RefObject<BottomSheet | null>;
27
- /** Accent color from provider config (default applied). */
28
34
  accentColor: string;
29
- /** Theme mode from provider config (default "auto"). */
30
35
  theme: "light" | "dark" | "auto";
31
36
  }
32
37
 
@@ -40,14 +45,13 @@ export function FeedbackProvider({
40
45
  const [feedItems, setFeedItems] = useState<Feedback[]>([]);
41
46
  const sheetRef = useRef<BottomSheet>(null);
42
47
 
43
- // Keep the end-user id in sync if `userId` resolves after mount (async login).
44
48
  useEffect(() => {
45
49
  client.setUserId(config.userId);
46
50
  }, [client, config.userId]);
47
51
 
48
52
  const loadFeed = useCallback(async () => {
49
53
  try {
50
- const data = await client.listFeedback({ sort: "votes", limit: 20 });
54
+ const data = await client.listFeedback({ sort: "votes", limit: 30 });
51
55
  setFeedItems(data.items);
52
56
  } catch {
53
57
  // non-critical
@@ -56,28 +60,41 @@ export function FeedbackProvider({
56
60
 
57
61
  useEffect(() => { loadFeed(); }, [loadFeed]);
58
62
 
59
- const submit = useCallback(async (content: string, type?: FeedbackType) => {
60
- const payload: Parameters<typeof client.submitFeedback>[0] = { content };
61
- if (type !== undefined) payload.type = type;
62
- await client.submitFeedback(payload);
63
- await loadFeed();
64
- }, [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
+ );
65
73
 
66
74
  const vote = useCallback(async (feedbackId: string, value: VoteValue) => {
67
75
  await client.vote(feedbackId, value);
68
76
  await loadFeed();
69
77
  }, [client, loadFeed]);
70
78
 
79
+ const getItem = useCallback(
80
+ (feedbackId: string) => client.getItem(feedbackId),
81
+ [client]
82
+ );
83
+
71
84
  const openSheet = useCallback(() => {
72
85
  sheetRef.current?.expand();
73
86
  }, []);
74
87
 
75
- const identify = useCallback((userId: string | undefined) => client.setUserId(userId), [client]);
88
+ const identify = useCallback(
89
+ (userId: string | undefined) => client.setUserId(userId),
90
+ [client]
91
+ );
76
92
 
77
93
  return (
78
94
  <UpstepContext.Provider
79
95
  value={{
80
- client, feedItems, loadFeed, submit, vote, openSheet, identify, sheetRef,
96
+ client, feedItems, loadFeed, submit, vote, getItem,
97
+ openSheet, identify, sheetRef,
81
98
  accentColor: config.accentColor ?? DEFAULT_ACCENT,
82
99
  theme: config.theme ?? "auto",
83
100
  }}
@@ -1,4 +1,4 @@
1
- import React, { useState, useMemo } from "react";
1
+ import React, { useState, useMemo, useCallback } from "react";
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -6,65 +6,73 @@ import {
6
6
  TouchableOpacity,
7
7
  StyleSheet,
8
8
  ActivityIndicator,
9
+ ScrollView,
9
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";
16
-
17
- const TYPES: FeedbackType[] = ["BUG", "FEATURE", "GENERAL"];
18
- const TYPE_LABELS: Record<FeedbackType, string> = {
19
- BUG: "🐞 Bug", FEATURE: "✨ Feature", GENERAL: "💬 General",
20
- };
17
+ // ─── Theme ────────────────────────────────────────────────────────────────────
21
18
 
22
19
  interface Palette {
23
20
  bg: string;
24
21
  bgSoft: string;
22
+ bgHover: string;
25
23
  text: string;
26
24
  textSoft: string;
27
25
  textFaint: string;
28
26
  border: string;
29
27
  handle: string;
28
+ shadow: string;
30
29
  }
31
30
 
32
31
  const LIGHT: Palette = {
33
- bg: "#ffffff", bgSoft: "#f6f5f2", text: "#1a1915",
34
- textSoft: "#56544d", textFaint: "#9b9890", border: "#e8e6df", handle: "#dad7ce",
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",
35
41
  };
42
+
36
43
  const DARK: Palette = {
37
- bg: "#1c1b19", bgSoft: "#262522", text: "#f5f4f0",
38
- textSoft: "#b4b1a8", textFaint: "#7d7a72", border: "#33312c", handle: "#48453f",
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",
39
53
  };
40
54
 
55
+ // ─── Navigation ───────────────────────────────────────────────────────────────
56
+
57
+ type Nav =
58
+ | { screen: "list" }
59
+ | { screen: "detail"; feedbackId: string }
60
+ | { screen: "create" };
61
+
62
+ // ─── FeedbackSheet ────────────────────────────────────────────────────────────
63
+
41
64
  export function FeedbackSheet() {
42
- const { feedItems, submit, vote, sheetRef, accentColor, theme } = useUpstep();
65
+ const { sheetRef, accentColor, theme } = useUpstep();
43
66
  const scheme = useColorScheme();
44
67
  const isDark = theme === "dark" || (theme === "auto" && scheme === "dark");
45
68
  const p = isDark ? DARK : LIGHT;
46
- const accent = accentColor;
47
69
 
48
- const snapPoints = useMemo(() => ["80%"], []);
49
- const [tab, setTab] = useState<Tab>("submit");
50
- const [content, setContent] = useState("");
51
- const [type, setType] = useState<FeedbackType>("GENERAL");
52
- const [msg, setMsg] = useState("");
53
- const [loading, setLoading] = useState(false);
70
+ const [nav, setNav] = useState<Nav>({ screen: "list" });
71
+ const snapPoints = useMemo(() => ["90%"], []);
54
72
 
55
- async function handleSubmit() {
56
- if (!content.trim() || loading) return;
57
- setLoading(true);
58
- try {
59
- await submit(content.trim(), type);
60
- setContent("");
61
- setMsg("Thanks — we got your feedback! 🎉");
62
- } catch {
63
- setMsg("Something went wrong. Please try again.");
64
- } finally {
65
- setLoading(false);
66
- }
67
- }
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" }), []);
68
76
 
69
77
  return (
70
78
  <BottomSheet
@@ -74,166 +82,559 @@ export function FeedbackSheet() {
74
82
  enablePanDownToClose
75
83
  backgroundStyle={{ backgroundColor: p.bg, borderRadius: 24 }}
76
84
  handleIndicatorStyle={{ backgroundColor: p.handle, width: 40, height: 4 }}
85
+ onClose={() => setNav({ screen: "list" })}
77
86
  >
78
- <BottomSheetView style={styles.header}>
79
- <Text style={[styles.title, { color: p.text }]}>Share your feedback</Text>
80
- <Text style={[styles.subtitle, { color: p.textFaint }]}>
81
- Tell us what to build next, or vote on ideas.
82
- </Text>
83
- </BottomSheetView>
84
-
85
- {/* Segmented control */}
86
- <View style={[styles.segment, { backgroundColor: p.bgSoft }]}>
87
- {(["submit", "feed"] as Tab[]).map((t) => {
88
- const active = tab === t;
89
- return (
90
- <TouchableOpacity
91
- key={t}
92
- onPress={() => setTab(t)}
93
- activeOpacity={0.8}
94
- style={[styles.segmentBtn, active && { backgroundColor: p.bg, shadowOpacity: 0.08 }]}
95
- >
96
- <Text style={[styles.segmentText, { color: active ? p.text : p.textSoft }]}>
97
- {t === "submit" ? "Give feedback" : "Vote on ideas"}
98
- </Text>
99
- </TouchableOpacity>
100
- );
101
- })}
102
- </View>
103
-
104
- {tab === "submit" ? (
105
- <BottomSheetView style={styles.body}>
106
- <TextInput
107
- value={content}
108
- onChangeText={setContent}
109
- placeholder="What's on your mind?"
110
- placeholderTextColor={p.textFaint}
111
- multiline
112
- maxLength={2000}
113
- style={[styles.textarea, { borderColor: p.border, backgroundColor: p.bgSoft, color: p.text }]}
114
- />
115
- <View style={styles.typeRow}>
116
- {TYPES.map((t) => {
117
- const active = type === t;
118
- return (
119
- <TouchableOpacity
120
- key={t}
121
- onPress={() => setType(t)}
122
- activeOpacity={0.8}
123
- style={[
124
- styles.typeBtn,
125
- { borderColor: active ? accent : p.border, backgroundColor: active ? accent : p.bg },
126
- ]}
127
- >
128
- <Text style={[styles.typeBtnText, { color: active ? "#fff" : p.textSoft }]}>
129
- {TYPE_LABELS[t]}
130
- </Text>
131
- </TouchableOpacity>
132
- );
133
- })}
134
- </View>
135
- <TouchableOpacity
136
- onPress={handleSubmit}
137
- disabled={loading || !content.trim()}
138
- activeOpacity={0.85}
139
- style={[styles.submitBtn, { backgroundColor: accent }, (loading || !content.trim()) && styles.disabledBtn]}
140
- >
141
- {loading ? (
142
- <ActivityIndicator color="#fff" />
143
- ) : (
144
- <Text style={styles.submitBtnText}>Send feedback</Text>
145
- )}
146
- </TouchableOpacity>
147
- {msg ? <Text style={[styles.msgText, { color: accent }]}>{msg}</Text> : null}
148
- </BottomSheetView>
149
- ) : (
150
- <BottomSheetScrollView contentContainerStyle={styles.body}>
151
- {feedItems.length === 0 ? (
152
- <View style={styles.empty}>
153
- <Text style={[styles.emptyTitle, { color: p.textSoft }]}>No ideas yet</Text>
154
- <Text style={[styles.emptyText, { color: p.textFaint }]}>Be the first to share one.</Text>
155
- </View>
156
- ) : (
157
- feedItems.map((f) => (
158
- <FeedItem key={f.id} item={f} onVote={vote} accent={accent} p={p} />
159
- ))
160
- )}
161
- </BottomSheetScrollView>
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} />
162
95
  )}
163
96
  </BottomSheet>
164
97
  );
165
98
  }
166
99
 
167
- const BADGE_BG: Record<string, string> = { BUG: "#fdecec", FEATURE: "#e9f0fd" };
168
- const BADGE_FG: Record<string, string> = { BUG: "#d6453d", FEATURE: "#3b76d6" };
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>
122
+ </BottomSheetView>
123
+
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
+ }
169
143
 
170
- function FeedItem({
171
- item, onVote, accent, p,
144
+ function FeedCard({
145
+ item, p, accent, onPress, onVote,
172
146
  }: {
173
- item: Feedback;
174
- onVote: (id: string, v: "UP" | "DOWN") => void;
175
- accent: string;
176
- p: Palette;
147
+ item: Feedback; p: Palette; accent: string;
148
+ onPress: () => void;
149
+ onVote: () => void;
177
150
  }) {
178
151
  const voted = item.userVote === "UP";
152
+ const displayTitle = item.title ?? item.content;
153
+ const hasDescription = Boolean(item.title);
154
+
179
155
  return (
180
- <View style={[styles.feedItem, { borderColor: p.border, backgroundColor: p.bg }]}>
156
+ <TouchableOpacity
157
+ onPress={onPress}
158
+ activeOpacity={0.85}
159
+ style={[styles.card, { borderColor: p.border, backgroundColor: p.bg }]}
160
+ >
181
161
  <TouchableOpacity
182
- onPress={() => onVote(item.id, "UP")}
162
+ onPress={onVote}
183
163
  activeOpacity={0.8}
164
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 4 }}
184
165
  style={[
185
- styles.vote,
186
- { borderColor: voted ? accent : p.border, backgroundColor: voted ? `${accent}1a` : p.bgSoft },
166
+ styles.voteBox,
167
+ { borderColor: voted ? accent : p.border, backgroundColor: voted ? accent + "1a" : p.bgSoft },
187
168
  ]}
188
169
  >
189
170
  <Text style={[styles.voteArrow, { color: accent }]}>▲</Text>
190
171
  <Text style={[styles.voteCount, { color: p.text }]}>{item.upvotes}</Text>
191
172
  </TouchableOpacity>
192
- <View style={styles.feedMain}>
193
- <Text style={[styles.feedContent, { color: p.text }]}>{item.content}</Text>
194
- <View style={styles.feedMeta}>
195
- <View style={[styles.badge, { backgroundColor: BADGE_BG[item.type] ?? p.bgSoft }]}>
196
- <Text style={[styles.badgeText, { color: BADGE_FG[item.type] ?? p.textSoft }]}>{item.type}</Text>
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>
190
+ </View>
191
+
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} />
197
254
  </View>
198
- <Text style={[styles.feedDate, { color: p.textFaint }]}>
199
- {new Date(item.createdAt).toLocaleDateString()}
255
+
256
+ {/* Description */}
257
+ <Text style={[styles.detailContent, { color: p.textSoft }]}>{item.content}</Text>
258
+
259
+ {/* Upvote */}
260
+ <TouchableOpacity
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
+ ]}
271
+ >
272
+ {voting ? (
273
+ <ActivityIndicator size="small" color={accent} />
274
+ ) : (
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
+ </>
282
+ )}
283
+ </TouchableOpacity>
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>
295
+ )}
296
+ </BottomSheetScrollView>
297
+ )}
298
+ </>
299
+ );
300
+ }
301
+
302
+ function CommentBubble({ comment, p, accent }: { comment: Comment; p: Palette; accent: string }) {
303
+ return (
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"}
200
309
  </Text>
201
310
  </View>
311
+ <Text style={[styles.commentDate, { color: p.textFaint }]}>
312
+ {formatDate(comment.createdAt)}
313
+ </Text>
202
314
  </View>
315
+ <Text style={[styles.commentContent, { color: p.textSoft }]}>{comment.content}</Text>
203
316
  </View>
204
317
  );
205
318
  }
206
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>
359
+ </TouchableOpacity>
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>
508
+ </View>
509
+ );
510
+ }
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
+
207
519
  const styles = StyleSheet.create({
208
- header: { paddingHorizontal: 20, paddingTop: 4, paddingBottom: 4 },
209
- title: { fontSize: 17, fontWeight: "700", letterSpacing: -0.2 },
210
- subtitle: { fontSize: 12.5, marginTop: 3 },
211
- segment: { flexDirection: "row", gap: 4, marginHorizontal: 20, marginTop: 16, padding: 4, borderRadius: 12 },
212
- segmentBtn: {
213
- flex: 1, paddingVertical: 9, alignItems: "center", borderRadius: 9,
214
- shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowRadius: 3, shadowOpacity: 0,
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,
215
558
  },
216
- segmentText: { fontSize: 13, fontWeight: "600" },
217
- body: { padding: 20 },
218
- textarea: { borderWidth: 1, borderRadius: 12, padding: 12, fontSize: 14, minHeight: 110, textAlignVertical: "top", lineHeight: 21 },
219
- typeRow: { flexDirection: "row", gap: 7, marginVertical: 12 },
220
- typeBtn: { flex: 1, paddingVertical: 9, alignItems: "center", borderRadius: 9, borderWidth: 1 },
221
- typeBtnText: { fontSize: 12.5, fontWeight: "600" },
222
- submitBtn: { borderRadius: 11, padding: 13, alignItems: "center", marginTop: 4 },
223
- disabledBtn: { opacity: 0.5 },
224
- submitBtnText: { color: "#fff", fontSize: 14, fontWeight: "600" },
225
- msgText: { textAlign: "center", fontSize: 13, fontWeight: "500", marginTop: 12 },
226
- empty: { alignItems: "center", paddingVertical: 40 },
227
- emptyTitle: { fontSize: 15, fontWeight: "600", marginBottom: 4 },
228
- emptyText: { fontSize: 14 },
229
- feedItem: { flexDirection: "row", gap: 12, padding: 14, borderWidth: 1, borderRadius: 14, marginBottom: 10 },
230
- vote: { minWidth: 46, paddingVertical: 7, borderRadius: 11, borderWidth: 1, alignItems: "center", justifyContent: "center" },
231
559
  voteArrow: { fontSize: 11, lineHeight: 13 },
232
- voteCount: { fontSize: 14, fontWeight: "700", lineHeight: 17 },
233
- feedMain: { flex: 1 },
234
- feedContent: { fontSize: 13.5, lineHeight: 20, marginBottom: 8 },
235
- feedMeta: { flexDirection: "row", alignItems: "center", gap: 7 },
236
- badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 9999 },
237
- badgeText: { fontSize: 10.5, fontWeight: "700", letterSpacing: 0.2 },
238
- feedDate: { fontSize: 11 },
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" },
239
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",