@upstep/react-native 0.5.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.5.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",
@@ -22,6 +22,7 @@ const DEFAULT_ACCENT = "#D97757";
22
22
  interface UpstepContextValue {
23
23
  client: UpstepApiClient;
24
24
  feedItems: Feedback[];
25
+ feedLoading: boolean;
25
26
  loadFeed: () => Promise<void>;
26
27
  submit: (title: string, content: string, type?: FeedbackType) => Promise<void>;
27
28
  vote: (feedbackId: string, value: VoteValue) => Promise<void>;
@@ -43,6 +44,7 @@ export function FeedbackProvider({
43
44
  }: UpstepConfig & { children: ReactNode }) {
44
45
  const [client] = useState(() => new UpstepApiClient(config));
45
46
  const [feedItems, setFeedItems] = useState<Feedback[]>([]);
47
+ const [feedLoading, setFeedLoading] = useState(true);
46
48
  const sheetRef = useRef<BottomSheet>(null);
47
49
 
48
50
  useEffect(() => {
@@ -50,11 +52,14 @@ export function FeedbackProvider({
50
52
  }, [client, config.userId]);
51
53
 
52
54
  const loadFeed = useCallback(async () => {
55
+ setFeedLoading(true);
53
56
  try {
54
57
  const data = await client.listFeedback({ sort: "votes", limit: 30 });
55
58
  setFeedItems(data.items);
56
59
  } catch {
57
60
  // non-critical
61
+ } finally {
62
+ setFeedLoading(false);
58
63
  }
59
64
  }, [client]);
60
65
 
@@ -93,7 +98,7 @@ export function FeedbackProvider({
93
98
  return (
94
99
  <UpstepContext.Provider
95
100
  value={{
96
- client, feedItems, loadFeed, submit, vote, getItem,
101
+ client, feedItems, feedLoading, loadFeed, submit, vote, getItem,
97
102
  openSheet, identify, sheetRef,
98
103
  accentColor: config.accentColor ?? DEFAULT_ACCENT,
99
104
  theme: config.theme ?? "auto",
@@ -1,4 +1,4 @@
1
- import React, { useState, useMemo, useCallback } from "react";
1
+ import React, { useState, useMemo, useCallback, useEffect } from "react";
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -6,11 +6,15 @@ import {
6
6
  TouchableOpacity,
7
7
  StyleSheet,
8
8
  ActivityIndicator,
9
- ScrollView,
10
9
  useColorScheme,
11
10
  Platform,
12
11
  } from "react-native";
13
- import BottomSheet, { BottomSheetView, BottomSheetScrollView } from "@gorhom/bottom-sheet";
12
+ import BottomSheet, {
13
+ BottomSheetView,
14
+ BottomSheetScrollView,
15
+ BottomSheetBackdrop,
16
+ } from "@gorhom/bottom-sheet";
17
+ import type { BottomSheetBackdropProps } from "@gorhom/bottom-sheet";
14
18
  import type { Comment, Feedback, FeedbackType, FeedbackWithComments } from "@upstep/types";
15
19
  import { useUpstep } from "./FeedbackContext";
16
20
 
@@ -19,37 +23,31 @@ import { useUpstep } from "./FeedbackContext";
19
23
  interface Palette {
20
24
  bg: string;
21
25
  bgSoft: string;
22
- bgHover: string;
23
26
  text: string;
24
27
  textSoft: string;
25
28
  textFaint: string;
26
29
  border: string;
27
30
  handle: string;
28
- shadow: string;
29
31
  }
30
32
 
31
33
  const LIGHT: Palette = {
32
34
  bg: "#ffffff",
33
35
  bgSoft: "#f6f5f2",
34
- bgHover: "#efede8",
35
36
  text: "#1a1915",
36
37
  textSoft: "#56544d",
37
38
  textFaint: "#9b9890",
38
39
  border: "#e8e6df",
39
40
  handle: "#dad7ce",
40
- shadow: "#00000014",
41
41
  };
42
42
 
43
43
  const DARK: Palette = {
44
44
  bg: "#1c1b19",
45
45
  bgSoft: "#262522",
46
- bgHover: "#302e2a",
47
46
  text: "#f5f4f0",
48
47
  textSoft: "#b4b1a8",
49
48
  textFaint: "#7d7a72",
50
49
  border: "#33312c",
51
50
  handle: "#48453f",
52
- shadow: "#0000003a",
53
51
  };
54
52
 
55
53
  // ─── Navigation ───────────────────────────────────────────────────────────────
@@ -60,9 +58,14 @@ type Nav =
60
58
  | { screen: "create" };
61
59
 
62
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.
63
66
 
64
67
  export function FeedbackSheet() {
65
- const { sheetRef, accentColor, theme } = useUpstep();
68
+ const { sheetRef, accentColor, theme, loadFeed } = useUpstep();
66
69
  const scheme = useColorScheme();
67
70
  const isDark = theme === "dark" || (theme === "auto" && scheme === "dark");
68
71
  const p = isDark ? DARK : LIGHT;
@@ -70,9 +73,18 @@ export function FeedbackSheet() {
70
73
  const [nav, setNav] = useState<Nav>({ screen: "list" });
71
74
  const snapPoints = useMemo(() => ["90%"], []);
72
75
 
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" }), []);
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
+ );
76
88
 
77
89
  return (
78
90
  <BottomSheet
@@ -80,64 +92,126 @@ export function FeedbackSheet() {
80
92
  index={-1}
81
93
  snapPoints={snapPoints}
82
94
  enablePanDownToClose
95
+ backdropComponent={renderBackdrop}
83
96
  backgroundStyle={{ backgroundColor: p.bg, borderRadius: 24 }}
84
97
  handleIndicatorStyle={{ backgroundColor: p.handle, width: 40, height: 4 }}
98
+ onChange={(index) => { if (index >= 0) loadFeed(); }}
85
99
  onClose={() => setNav({ screen: "list" })}
86
100
  >
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
- )}
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
+ ) : (
132
+ <TouchableOpacity
133
+ onPress={() => setNav({ screen: "list" })}
134
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 16 }}
135
+ >
136
+ <Text style={[styles.navBtn, { color: accentColor }]}>← Back</Text>
137
+ </TouchableOpacity>
138
+ )}
139
+ </View>
140
+
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
+ />
167
+ )}
168
+ </BottomSheetScrollView>
169
+ </BottomSheetView>
96
170
  </BottomSheet>
97
171
  );
98
172
  }
99
173
 
100
- // ─── FeedList ─────────────────────────────────────────────────────────────────
174
+ // ─── FeedListBody ─────────────────────────────────────────────────────────────
101
175
 
102
- function FeedList({
103
- p, accent, onSelect, onCreate,
176
+ function FeedListBody({
177
+ p, accent, onSelect,
104
178
  }: {
105
179
  p: Palette; accent: string;
106
180
  onSelect: (id: string) => void;
107
- onCreate: () => void;
108
181
  }) {
109
- const { feedItems, vote } = useUpstep();
182
+ const { feedItems, feedLoading, vote } = useUpstep();
110
183
 
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>
184
+ if (feedLoading) {
185
+ return (
186
+ <View style={styles.centered}>
187
+ <ActivityIndicator color={accent} />
188
+ </View>
189
+ );
190
+ }
123
191
 
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
- </>
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>
141
215
  );
142
216
  }
143
217
 
@@ -150,7 +224,6 @@ function FeedCard({
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,8 +251,8 @@ 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
  )}
@@ -184,7 +260,7 @@ function FeedCard({
184
260
  <TypeBadge type={item.type} p={p} />
185
261
  <StatusDot status={item.status} />
186
262
  <Text style={[styles.cardDate, { color: p.textFaint }]}>
187
- {formatDate(item.createdAt)}
263
+ {fmtDate(item.createdAt)}
188
264
  </Text>
189
265
  </View>
190
266
  </View>
@@ -194,26 +270,24 @@ function FeedCard({
194
270
  );
195
271
  }
196
272
 
197
- // ─── FeedDetail ───────────────────────────────────────────────────────────────
273
+ // ─── FeedDetailBody ───────────────────────────────────────────────────────────
198
274
 
199
- function FeedDetail({
200
- p, accent, feedbackId, onBack,
275
+ function FeedDetailBody({
276
+ p, accent, feedbackId,
201
277
  }: {
202
- p: Palette; accent: string; feedbackId: string; onBack: () => void;
278
+ p: Palette; accent: string; feedbackId: string;
203
279
  }) {
204
280
  const { vote, getItem } = useUpstep();
205
281
  const [item, setItem] = useState<FeedbackWithComments | null>(null);
206
282
  const [loading, setLoading] = useState(true);
207
283
  const [voting, setVoting] = useState(false);
208
284
 
209
- React.useEffect(() => {
285
+ useEffect(() => {
210
286
  let cancelled = false;
211
287
  setLoading(true);
212
- getItem(feedbackId).then((data) => {
213
- if (!cancelled) { setItem(data); setLoading(false); }
214
- }).catch(() => {
215
- if (!cancelled) setLoading(false);
216
- });
288
+ getItem(feedbackId)
289
+ .then((data) => { if (!cancelled) { setItem(data); setLoading(false); } })
290
+ .catch(() => { if (!cancelled) setLoading(false); });
217
291
  return () => { cancelled = true; };
218
292
  }, [feedbackId, getItem]);
219
293
 
@@ -222,7 +296,6 @@ function FeedDetail({
222
296
  setVoting(true);
223
297
  try {
224
298
  await vote(item.id, "UP");
225
- // Refresh
226
299
  const updated = await getItem(feedbackId);
227
300
  setItem(updated);
228
301
  } finally {
@@ -230,72 +303,62 @@ function FeedDetail({
230
303
  }
231
304
  }
232
305
 
306
+ if (loading || !item) {
307
+ return (
308
+ <View style={styles.centered}>
309
+ <ActivityIndicator color={accent} />
310
+ </View>
311
+ );
312
+ }
313
+
233
314
  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>
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>
240
323
 
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>
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>
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>
297
360
  )}
298
- </>
361
+ </View>
299
362
  );
300
363
  }
301
364
 
@@ -309,7 +372,7 @@ function CommentBubble({ comment, p, accent }: { comment: Comment; p: Palette; a
309
372
  </Text>
310
373
  </View>
311
374
  <Text style={[styles.commentDate, { color: p.textFaint }]}>
312
- {formatDate(comment.createdAt)}
375
+ {fmtDate(comment.createdAt)}
313
376
  </Text>
314
377
  </View>
315
378
  <Text style={[styles.commentContent, { color: p.textSoft }]}>{comment.content}</Text>
@@ -317,7 +380,7 @@ function CommentBubble({ comment, p, accent }: { comment: Comment; p: Palette; a
317
380
  );
318
381
  }
319
382
 
320
- // ─── FeedCreate ───────────────────────────────────────────────────────────────
383
+ // ─── FeedCreateBody ───────────────────────────────────────────────────────────
321
384
 
322
385
  const TYPES: FeedbackType[] = ["BUG", "FEATURE", "GENERAL"];
323
386
  const TYPE_LABELS: Record<FeedbackType, string> = {
@@ -326,149 +389,120 @@ const TYPE_LABELS: Record<FeedbackType, string> = {
326
389
  GENERAL: "General",
327
390
  };
328
391
 
329
- function FeedCreate({ p, accent, onBack }: { p: Palette; accent: string; onBack: () => void }) {
392
+ function FeedCreateBody({
393
+ p, accent, onDone,
394
+ }: {
395
+ p: Palette; accent: string; onDone: () => void;
396
+ }) {
330
397
  const { submit } = useUpstep();
331
398
  const [title, setTitle] = useState("");
332
399
  const [description, setDescription] = useState("");
333
400
  const [type, setType] = useState<FeedbackType>("GENERAL");
334
- const [loading, setLoading] = useState(false);
401
+ const [submitting, setSubmitting] = useState(false);
335
402
  const [success, setSuccess] = useState(false);
336
403
  const [error, setError] = useState("");
337
404
 
338
405
  async function handleSubmit() {
339
- if (!title.trim() || loading) return;
340
- setLoading(true);
406
+ if (!title.trim() || submitting) return;
407
+ setSubmitting(true);
341
408
  setError("");
342
409
  try {
343
410
  await submit(title.trim(), description.trim() || title.trim(), type);
344
411
  setSuccess(true);
345
- // Return to list after a moment
346
- setTimeout(onBack, 1400);
412
+ setTimeout(onDone, 1400);
347
413
  } catch {
348
414
  setError("Something went wrong. Please try again.");
349
415
  } finally {
350
- setLoading(false);
416
+ setSubmitting(false);
351
417
  }
352
418
  }
353
419
 
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}
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
+ }
438
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 (
439
438
  <TouchableOpacity
440
- onPress={handleSubmit}
441
- disabled={loading || !title.trim()}
442
- activeOpacity={0.85}
439
+ key={t}
440
+ onPress={() => setType(t)}
441
+ activeOpacity={0.8}
443
442
  style={[
444
- styles.submitBtn,
445
- { backgroundColor: accent },
446
- (loading || !title.trim()) && styles.submitBtnDisabled,
443
+ styles.typeBtn,
444
+ {
445
+ borderColor: active ? accent : p.border,
446
+ backgroundColor: active ? accent : p.bg,
447
+ },
447
448
  ]}
448
449
  >
449
- {loading ? (
450
- <ActivityIndicator color="#fff" />
451
- ) : (
452
- <Text style={styles.submitBtnText}>Submit feedback</Text>
453
- )}
450
+ <Text style={[styles.typeBtnText, { color: active ? "#fff" : p.textSoft }]}>
451
+ {TYPE_LABELS[t]}
452
+ </Text>
454
453
  </TouchableOpacity>
455
- </>
456
- )}
457
- </BottomSheetScrollView>
458
- </>
459
- );
460
- }
454
+ );
455
+ })}
456
+ </View>
461
457
 
462
- // ─── Shared sub-components ────────────────────────────────────────────────────
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}
463
483
 
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>
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>
468
500
  </View>
469
501
  );
470
502
  }
471
503
 
504
+ // ─── Shared sub-components ────────────────────────────────────────────────────
505
+
472
506
  const TYPE_BG: Record<FeedbackType, string> = {
473
507
  BUG: "#fdecec", FEATURE: "#e9f0fd", GENERAL: "",
474
508
  };
@@ -479,14 +513,12 @@ const TYPE_FG: Record<FeedbackType, string> = {
479
513
  function TypeBadge({ type, p }: { type: FeedbackType; p: Palette }) {
480
514
  return (
481
515
  <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>
516
+ <Text style={[styles.badgeText, { color: TYPE_FG[type] || p.textSoft }]}>{type}</Text>
485
517
  </View>
486
518
  );
487
519
  }
488
520
 
489
- const STATUS_COLORS: Record<string, string> = {
521
+ const STATUS_COLOR: Record<string, string> = {
490
522
  OPEN: "#b45309",
491
523
  IN_PROGRESS: "#1d4ed8",
492
524
  DONE: "#15803d",
@@ -495,29 +527,30 @@ const STATUS_COLORS: Record<string, string> = {
495
527
  };
496
528
 
497
529
  function StatusDot({ status }: { status: string }) {
498
- const color = STATUS_COLORS[status] ?? "#9ca3af";
499
- return <View style={[styles.statusDot, { backgroundColor: color }]} />;
530
+ return <View style={[styles.statusDot, { backgroundColor: STATUS_COLOR[status] ?? "#9ca3af" }]} />;
500
531
  }
501
532
 
502
533
  function StatusBadge({ status, p }: { status: string; p: Palette }) {
503
- const label = status.replace("_", " ");
504
534
  return (
505
535
  <View style={[styles.statusBadge, { backgroundColor: p.bgSoft, borderColor: p.border }]}>
506
536
  <StatusDot status={status} />
507
- <Text style={[styles.statusBadgeText, { color: p.textSoft }]}>{label}</Text>
537
+ <Text style={[styles.statusBadgeText, { color: p.textSoft }]}>
538
+ {status.replace("_", " ")}
539
+ </Text>
508
540
  </View>
509
541
  );
510
542
  }
511
543
 
512
- function formatDate(iso: string) {
513
- const d = new Date(iso);
514
- return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
544
+ function fmtDate(iso: string) {
545
+ return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
515
546
  }
516
547
 
517
548
  // ─── Styles ───────────────────────────────────────────────────────────────────
518
549
 
519
550
  const styles = StyleSheet.create({
520
- // Header
551
+ // Sheet container — fills the sheet, stacks header above scroll
552
+ sheetContainer: { flex: 1 },
553
+
521
554
  header: {
522
555
  flexDirection: "row",
523
556
  alignItems: "center",
@@ -527,18 +560,18 @@ const styles = StyleSheet.create({
527
560
  borderBottomWidth: StyleSheet.hairlineWidth,
528
561
  },
529
562
  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
- },
563
+ navBtn: { fontSize: 14, fontWeight: "600" },
564
+ navBtnPlaceholder: { width: 60 },
565
+ newBtn: { borderRadius: 20, paddingHorizontal: 14, paddingVertical: 7 },
536
566
  newBtnText: { color: "#fff", fontSize: 13, fontWeight: "700" },
537
567
 
538
- // List
539
- listBody: { padding: 16, paddingBottom: 40 },
568
+ scrollView: { flex: 1 },
569
+ scrollBody: { flexGrow: 1 },
540
570
 
541
- // Card
571
+ // List
572
+ listPad: { padding: 16, paddingBottom: 40 },
573
+ centered: { alignItems: "center", justifyContent: "center", paddingVertical: 60 },
574
+ emptyText: { fontSize: 14 },
542
575
  card: {
543
576
  flexDirection: "row",
544
577
  alignItems: "center",
@@ -560,41 +593,30 @@ const styles = StyleSheet.create({
560
593
  voteCount: { fontSize: 15, fontWeight: "700", lineHeight: 18 },
561
594
  cardBody: { flex: 1 },
562
595
  cardTitle: { fontSize: 14, fontWeight: "600", lineHeight: 19, marginBottom: 2 },
563
- cardDescription: { fontSize: 12.5, lineHeight: 17, marginBottom: 6 },
596
+ cardDesc: { fontSize: 12.5, lineHeight: 17, marginBottom: 6 },
564
597
  cardMeta: { flexDirection: "row", alignItems: "center", gap: 6 },
565
598
  cardDate: { fontSize: 11 },
566
599
  chevron: { fontSize: 22, fontWeight: "300", marginLeft: 4 },
567
600
 
568
601
  // Detail
569
- detailBody: { padding: 20, paddingBottom: 50 },
602
+ detailPad: { padding: 20, paddingBottom: 50 },
570
603
  detailTitle: { fontSize: 22, fontWeight: "700", letterSpacing: -0.4, marginBottom: 10, lineHeight: 28 },
571
- detailBadgeRow: { flexDirection: "row", gap: 8, marginBottom: 16 },
604
+ badgeRow: { flexDirection: "row", gap: 8, marginBottom: 16 },
572
605
  detailContent: { fontSize: 15, lineHeight: 23, marginBottom: 24 },
573
606
  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",
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",
585
611
  },
586
612
  upvoteArrow: { fontSize: 13, lineHeight: 15 },
587
613
  upvoteCount: { fontSize: 18, fontWeight: "700" },
588
614
  upvoteLabel: { fontSize: 13, fontWeight: "500" },
589
-
590
- // Comments
591
615
  commentsSection: { marginTop: 28, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 20 },
592
616
  commentsLabel: { fontSize: 10, fontWeight: "700", letterSpacing: 0.8, marginBottom: 12 },
593
617
  commentBubble: {
594
- borderRadius: 12,
595
- borderWidth: StyleSheet.hairlineWidth,
596
- padding: 14,
597
- marginBottom: 10,
618
+ borderRadius: 12, borderWidth: StyleSheet.hairlineWidth,
619
+ padding: 14, marginBottom: 10,
598
620
  },
599
621
  commentMeta: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 },
600
622
  ownerBadge: { borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3 },
@@ -603,38 +625,38 @@ const styles = StyleSheet.create({
603
625
  commentContent: { fontSize: 14, lineHeight: 21 },
604
626
 
605
627
  // Create
606
- createBody: { padding: 20, paddingBottom: 50 },
628
+ createPad: { padding: 20, paddingBottom: 50 },
607
629
  fieldLabel: { fontSize: 10, fontWeight: "700", letterSpacing: 0.8, marginBottom: 8 },
608
630
  typeRow: { flexDirection: "row", gap: 8, marginBottom: 20, flexWrap: "wrap" },
609
631
  typeBtn: { paddingVertical: 9, paddingHorizontal: 14, borderRadius: 10, borderWidth: 1 },
610
632
  typeBtnText: { fontSize: 13, fontWeight: "600" },
611
633
  titleInput: {
612
- borderWidth: 1, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12,
634
+ borderWidth: 1, borderRadius: 12,
635
+ paddingHorizontal: 14, paddingVertical: 12,
613
636
  fontSize: 15, marginBottom: 20,
614
637
  ...(Platform.OS === "ios" ? {} : { paddingTop: 12 }),
615
638
  },
616
639
  descInput: {
617
- borderWidth: 1, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12,
640
+ borderWidth: 1, borderRadius: 12,
641
+ paddingHorizontal: 14, paddingVertical: 12,
618
642
  fontSize: 14, minHeight: 110, marginBottom: 20,
619
643
  ...(Platform.OS === "ios" ? {} : { paddingTop: 12 }),
620
644
  },
621
- errorText: { fontSize: 13, marginBottom: 12, fontWeight: "500" },
645
+ errorText: { fontSize: 13, marginBottom: 12, fontWeight: "500", color: "#d6453d" },
622
646
  submitBtn: { borderRadius: 12, padding: 14, alignItems: "center" },
623
647
  submitBtnDisabled: { opacity: 0.5 },
624
648
  submitBtnText: { color: "#fff", fontSize: 15, fontWeight: "700" },
625
649
  successTitle: { fontSize: 20, fontWeight: "700", textAlign: "center", marginBottom: 6 },
626
650
  successSub: { fontSize: 14, textAlign: "center" },
627
651
 
628
- // Shared
629
- empty: { alignItems: "center", paddingVertical: 60 },
630
- emptyText: { fontSize: 14 },
631
- centered: { flex: 1, alignItems: "center", justifyContent: "center", paddingVertical: 60 },
652
+ // Shared badges
632
653
  badge: { paddingHorizontal: 7, paddingVertical: 3, borderRadius: 6 },
633
654
  badgeText: { fontSize: 10, fontWeight: "700", letterSpacing: 0.2 },
634
655
  statusDot: { width: 6, height: 6, borderRadius: 3 },
635
656
  statusBadge: {
636
- flexDirection: "row", alignItems: "center", gap: 5, paddingHorizontal: 8,
637
- paddingVertical: 3, borderRadius: 6, borderWidth: StyleSheet.hairlineWidth,
657
+ flexDirection: "row", alignItems: "center", gap: 5,
658
+ paddingHorizontal: 8, paddingVertical: 3,
659
+ borderRadius: 6, borderWidth: StyleSheet.hairlineWidth,
638
660
  },
639
661
  statusBadgeText: { fontSize: 10, fontWeight: "600", textTransform: "capitalize" },
640
662
  });