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