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