@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 +1 -1
- package/src/FeedbackContext.tsx +6 -1
- package/src/FeedbackSheet.tsx +323 -301
package/package.json
CHANGED
package/src/FeedbackContext.tsx
CHANGED
|
@@ -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",
|
package/src/FeedbackSheet.tsx
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
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
|
-
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
<
|
|
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
|
-
// ───
|
|
174
|
+
// ─── FeedListBody ─────────────────────────────────────────────────────────────
|
|
101
175
|
|
|
102
|
-
function
|
|
103
|
-
p, accent, onSelect,
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
<
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
179
|
-
<Text style={[styles.
|
|
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
|
-
{
|
|
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
|
-
// ───
|
|
273
|
+
// ─── FeedDetailBody ───────────────────────────────────────────────────────────
|
|
198
274
|
|
|
199
|
-
function
|
|
200
|
-
p, accent, feedbackId,
|
|
275
|
+
function FeedDetailBody({
|
|
276
|
+
p, accent, feedbackId,
|
|
201
277
|
}: {
|
|
202
|
-
p: Palette; accent: string; feedbackId: string;
|
|
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
|
-
|
|
285
|
+
useEffect(() => {
|
|
210
286
|
let cancelled = false;
|
|
211
287
|
setLoading(true);
|
|
212
|
-
getItem(feedbackId)
|
|
213
|
-
if (!cancelled) { setItem(data); setLoading(false); }
|
|
214
|
-
|
|
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
|
-
|
|
236
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
{
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
{
|
|
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
|
-
// ───
|
|
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
|
|
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 [
|
|
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() ||
|
|
340
|
-
|
|
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
|
-
|
|
346
|
-
setTimeout(onBack, 1400);
|
|
412
|
+
setTimeout(onDone, 1400);
|
|
347
413
|
} catch {
|
|
348
414
|
setError("Something went wrong. Please try again.");
|
|
349
415
|
} finally {
|
|
350
|
-
|
|
416
|
+
setSubmitting(false);
|
|
351
417
|
}
|
|
352
418
|
}
|
|
353
419
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
<
|
|
357
|
-
<
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
activeOpacity={0.
|
|
439
|
+
key={t}
|
|
440
|
+
onPress={() => setType(t)}
|
|
441
|
+
activeOpacity={0.8}
|
|
443
442
|
style={[
|
|
444
|
-
styles.
|
|
445
|
-
{
|
|
446
|
-
|
|
443
|
+
styles.typeBtn,
|
|
444
|
+
{
|
|
445
|
+
borderColor: active ? accent : p.border,
|
|
446
|
+
backgroundColor: active ? accent : p.bg,
|
|
447
|
+
},
|
|
447
448
|
]}
|
|
448
449
|
>
|
|
449
|
-
{
|
|
450
|
-
|
|
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
|
-
</
|
|
458
|
-
</>
|
|
459
|
-
);
|
|
460
|
-
}
|
|
454
|
+
);
|
|
455
|
+
})}
|
|
456
|
+
</View>
|
|
461
457
|
|
|
462
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
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
|
-
|
|
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 }]}>
|
|
537
|
+
<Text style={[styles.statusBadgeText, { color: p.textSoft }]}>
|
|
538
|
+
{status.replace("_", " ")}
|
|
539
|
+
</Text>
|
|
508
540
|
</View>
|
|
509
541
|
);
|
|
510
542
|
}
|
|
511
543
|
|
|
512
|
-
function
|
|
513
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
539
|
-
|
|
568
|
+
scrollView: { flex: 1 },
|
|
569
|
+
scrollBody: { flexGrow: 1 },
|
|
540
570
|
|
|
541
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
602
|
+
detailPad: { padding: 20, paddingBottom: 50 },
|
|
570
603
|
detailTitle: { fontSize: 22, fontWeight: "700", letterSpacing: -0.4, marginBottom: 10, lineHeight: 28 },
|
|
571
|
-
|
|
604
|
+
badgeRow: { flexDirection: "row", gap: 8, marginBottom: 16 },
|
|
572
605
|
detailContent: { fontSize: 15, lineHeight: 23, marginBottom: 24 },
|
|
573
606
|
upvoteBtn: {
|
|
574
|
-
flexDirection: "row",
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
637
|
-
|
|
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
|
});
|