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