@upstep/react-native 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/FeedbackContext.tsx +31 -14
- package/src/FeedbackSheet.tsx +566 -165
- 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.0",
|
|
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
|
|
|
@@ -17,16 +23,15 @@ interface UpstepContextValue {
|
|
|
17
23
|
client: UpstepApiClient;
|
|
18
24
|
feedItems: Feedback[];
|
|
19
25
|
loadFeed: () => Promise<void>;
|
|
20
|
-
submit: (content: string, type?: FeedbackType) => Promise<void>;
|
|
26
|
+
submit: (title: string, content: string, type?: FeedbackType) => Promise<void>;
|
|
21
27
|
vote: (feedbackId: string, value: VoteValue) => Promise<void>;
|
|
28
|
+
getItem: (feedbackId: string) => Promise<FeedbackWithComments>;
|
|
22
29
|
/** Open the feedback sheet — call from your own UI (settings, menu, etc.). */
|
|
23
30
|
openSheet: () => void;
|
|
24
31
|
/** Set / update the end-user id at runtime (e.g. after the user logs in). */
|
|
25
32
|
identify: (userId: string | undefined) => void;
|
|
26
33
|
sheetRef: React.RefObject<BottomSheet | null>;
|
|
27
|
-
/** Accent color from provider config (default applied). */
|
|
28
34
|
accentColor: string;
|
|
29
|
-
/** Theme mode from provider config (default "auto"). */
|
|
30
35
|
theme: "light" | "dark" | "auto";
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -40,14 +45,13 @@ export function FeedbackProvider({
|
|
|
40
45
|
const [feedItems, setFeedItems] = useState<Feedback[]>([]);
|
|
41
46
|
const sheetRef = useRef<BottomSheet>(null);
|
|
42
47
|
|
|
43
|
-
// Keep the end-user id in sync if `userId` resolves after mount (async login).
|
|
44
48
|
useEffect(() => {
|
|
45
49
|
client.setUserId(config.userId);
|
|
46
50
|
}, [client, config.userId]);
|
|
47
51
|
|
|
48
52
|
const loadFeed = useCallback(async () => {
|
|
49
53
|
try {
|
|
50
|
-
const data = await client.listFeedback({ sort: "votes", limit:
|
|
54
|
+
const data = await client.listFeedback({ sort: "votes", limit: 30 });
|
|
51
55
|
setFeedItems(data.items);
|
|
52
56
|
} catch {
|
|
53
57
|
// non-critical
|
|
@@ -56,28 +60,41 @@ export function FeedbackProvider({
|
|
|
56
60
|
|
|
57
61
|
useEffect(() => { loadFeed(); }, [loadFeed]);
|
|
58
62
|
|
|
59
|
-
const submit = useCallback(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
const submit = useCallback(
|
|
64
|
+
async (title: string, content: string, type?: FeedbackType) => {
|
|
65
|
+
const payload: Parameters<typeof client.submitFeedback>[0] = { content };
|
|
66
|
+
if (title.trim()) payload.title = title.trim();
|
|
67
|
+
if (type !== undefined) payload.type = type;
|
|
68
|
+
await client.submitFeedback(payload);
|
|
69
|
+
await loadFeed();
|
|
70
|
+
},
|
|
71
|
+
[client, loadFeed]
|
|
72
|
+
);
|
|
65
73
|
|
|
66
74
|
const vote = useCallback(async (feedbackId: string, value: VoteValue) => {
|
|
67
75
|
await client.vote(feedbackId, value);
|
|
68
76
|
await loadFeed();
|
|
69
77
|
}, [client, loadFeed]);
|
|
70
78
|
|
|
79
|
+
const getItem = useCallback(
|
|
80
|
+
(feedbackId: string) => client.getItem(feedbackId),
|
|
81
|
+
[client]
|
|
82
|
+
);
|
|
83
|
+
|
|
71
84
|
const openSheet = useCallback(() => {
|
|
72
85
|
sheetRef.current?.expand();
|
|
73
86
|
}, []);
|
|
74
87
|
|
|
75
|
-
const identify = useCallback(
|
|
88
|
+
const identify = useCallback(
|
|
89
|
+
(userId: string | undefined) => client.setUserId(userId),
|
|
90
|
+
[client]
|
|
91
|
+
);
|
|
76
92
|
|
|
77
93
|
return (
|
|
78
94
|
<UpstepContext.Provider
|
|
79
95
|
value={{
|
|
80
|
-
client, feedItems, loadFeed, submit, vote,
|
|
96
|
+
client, feedItems, loadFeed, submit, vote, getItem,
|
|
97
|
+
openSheet, identify, sheetRef,
|
|
81
98
|
accentColor: config.accentColor ?? DEFAULT_ACCENT,
|
|
82
99
|
theme: config.theme ?? "auto",
|
|
83
100
|
}}
|
package/src/FeedbackSheet.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useMemo } from "react";
|
|
1
|
+
import React, { useState, useMemo, useCallback } from "react";
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
4
|
Text,
|
|
@@ -6,65 +6,73 @@ import {
|
|
|
6
6
|
TouchableOpacity,
|
|
7
7
|
StyleSheet,
|
|
8
8
|
ActivityIndicator,
|
|
9
|
+
ScrollView,
|
|
9
10
|
useColorScheme,
|
|
11
|
+
Platform,
|
|
10
12
|
} from "react-native";
|
|
11
13
|
import BottomSheet, { BottomSheetView, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
|
12
|
-
import type { Feedback, FeedbackType } from "@upstep/types";
|
|
14
|
+
import type { Comment, Feedback, FeedbackType, FeedbackWithComments } from "@upstep/types";
|
|
13
15
|
import { useUpstep } from "./FeedbackContext";
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const TYPES: FeedbackType[] = ["BUG", "FEATURE", "GENERAL"];
|
|
18
|
-
const TYPE_LABELS: Record<FeedbackType, string> = {
|
|
19
|
-
BUG: "🐞 Bug", FEATURE: "✨ Feature", GENERAL: "💬 General",
|
|
20
|
-
};
|
|
17
|
+
// ─── Theme ────────────────────────────────────────────────────────────────────
|
|
21
18
|
|
|
22
19
|
interface Palette {
|
|
23
20
|
bg: string;
|
|
24
21
|
bgSoft: string;
|
|
22
|
+
bgHover: string;
|
|
25
23
|
text: string;
|
|
26
24
|
textSoft: string;
|
|
27
25
|
textFaint: string;
|
|
28
26
|
border: string;
|
|
29
27
|
handle: string;
|
|
28
|
+
shadow: string;
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
const LIGHT: Palette = {
|
|
33
|
-
bg: "#ffffff",
|
|
34
|
-
|
|
32
|
+
bg: "#ffffff",
|
|
33
|
+
bgSoft: "#f6f5f2",
|
|
34
|
+
bgHover: "#efede8",
|
|
35
|
+
text: "#1a1915",
|
|
36
|
+
textSoft: "#56544d",
|
|
37
|
+
textFaint: "#9b9890",
|
|
38
|
+
border: "#e8e6df",
|
|
39
|
+
handle: "#dad7ce",
|
|
40
|
+
shadow: "#00000014",
|
|
35
41
|
};
|
|
42
|
+
|
|
36
43
|
const DARK: Palette = {
|
|
37
|
-
bg: "#1c1b19",
|
|
38
|
-
|
|
44
|
+
bg: "#1c1b19",
|
|
45
|
+
bgSoft: "#262522",
|
|
46
|
+
bgHover: "#302e2a",
|
|
47
|
+
text: "#f5f4f0",
|
|
48
|
+
textSoft: "#b4b1a8",
|
|
49
|
+
textFaint: "#7d7a72",
|
|
50
|
+
border: "#33312c",
|
|
51
|
+
handle: "#48453f",
|
|
52
|
+
shadow: "#0000003a",
|
|
39
53
|
};
|
|
40
54
|
|
|
55
|
+
// ─── Navigation ───────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
type Nav =
|
|
58
|
+
| { screen: "list" }
|
|
59
|
+
| { screen: "detail"; feedbackId: string }
|
|
60
|
+
| { screen: "create" };
|
|
61
|
+
|
|
62
|
+
// ─── FeedbackSheet ────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
41
64
|
export function FeedbackSheet() {
|
|
42
|
-
const {
|
|
65
|
+
const { sheetRef, accentColor, theme } = useUpstep();
|
|
43
66
|
const scheme = useColorScheme();
|
|
44
67
|
const isDark = theme === "dark" || (theme === "auto" && scheme === "dark");
|
|
45
68
|
const p = isDark ? DARK : LIGHT;
|
|
46
|
-
const accent = accentColor;
|
|
47
69
|
|
|
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);
|
|
70
|
+
const [nav, setNav] = useState<Nav>({ screen: "list" });
|
|
71
|
+
const snapPoints = useMemo(() => ["90%"], []);
|
|
54
72
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
await submit(content.trim(), type);
|
|
60
|
-
setContent("");
|
|
61
|
-
setMsg("Thanks — we got your feedback! 🎉");
|
|
62
|
-
} catch {
|
|
63
|
-
setMsg("Something went wrong. Please try again.");
|
|
64
|
-
} finally {
|
|
65
|
-
setLoading(false);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
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" }), []);
|
|
68
76
|
|
|
69
77
|
return (
|
|
70
78
|
<BottomSheet
|
|
@@ -74,166 +82,559 @@ export function FeedbackSheet() {
|
|
|
74
82
|
enablePanDownToClose
|
|
75
83
|
backgroundStyle={{ backgroundColor: p.bg, borderRadius: 24 }}
|
|
76
84
|
handleIndicatorStyle={{ backgroundColor: p.handle, width: 40, height: 4 }}
|
|
85
|
+
onClose={() => setNav({ screen: "list" })}
|
|
77
86
|
>
|
|
78
|
-
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<View style={[styles.segment, { backgroundColor: p.bgSoft }]}>
|
|
87
|
-
{(["submit", "feed"] as Tab[]).map((t) => {
|
|
88
|
-
const active = tab === t;
|
|
89
|
-
return (
|
|
90
|
-
<TouchableOpacity
|
|
91
|
-
key={t}
|
|
92
|
-
onPress={() => setTab(t)}
|
|
93
|
-
activeOpacity={0.8}
|
|
94
|
-
style={[styles.segmentBtn, active && { backgroundColor: p.bg, shadowOpacity: 0.08 }]}
|
|
95
|
-
>
|
|
96
|
-
<Text style={[styles.segmentText, { color: active ? p.text : p.textSoft }]}>
|
|
97
|
-
{t === "submit" ? "Give feedback" : "Vote on ideas"}
|
|
98
|
-
</Text>
|
|
99
|
-
</TouchableOpacity>
|
|
100
|
-
);
|
|
101
|
-
})}
|
|
102
|
-
</View>
|
|
103
|
-
|
|
104
|
-
{tab === "submit" ? (
|
|
105
|
-
<BottomSheetView style={styles.body}>
|
|
106
|
-
<TextInput
|
|
107
|
-
value={content}
|
|
108
|
-
onChangeText={setContent}
|
|
109
|
-
placeholder="What's on your mind?"
|
|
110
|
-
placeholderTextColor={p.textFaint}
|
|
111
|
-
multiline
|
|
112
|
-
maxLength={2000}
|
|
113
|
-
style={[styles.textarea, { borderColor: p.border, backgroundColor: p.bgSoft, color: p.text }]}
|
|
114
|
-
/>
|
|
115
|
-
<View style={styles.typeRow}>
|
|
116
|
-
{TYPES.map((t) => {
|
|
117
|
-
const active = type === t;
|
|
118
|
-
return (
|
|
119
|
-
<TouchableOpacity
|
|
120
|
-
key={t}
|
|
121
|
-
onPress={() => setType(t)}
|
|
122
|
-
activeOpacity={0.8}
|
|
123
|
-
style={[
|
|
124
|
-
styles.typeBtn,
|
|
125
|
-
{ borderColor: active ? accent : p.border, backgroundColor: active ? accent : p.bg },
|
|
126
|
-
]}
|
|
127
|
-
>
|
|
128
|
-
<Text style={[styles.typeBtnText, { color: active ? "#fff" : p.textSoft }]}>
|
|
129
|
-
{TYPE_LABELS[t]}
|
|
130
|
-
</Text>
|
|
131
|
-
</TouchableOpacity>
|
|
132
|
-
);
|
|
133
|
-
})}
|
|
134
|
-
</View>
|
|
135
|
-
<TouchableOpacity
|
|
136
|
-
onPress={handleSubmit}
|
|
137
|
-
disabled={loading || !content.trim()}
|
|
138
|
-
activeOpacity={0.85}
|
|
139
|
-
style={[styles.submitBtn, { backgroundColor: accent }, (loading || !content.trim()) && styles.disabledBtn]}
|
|
140
|
-
>
|
|
141
|
-
{loading ? (
|
|
142
|
-
<ActivityIndicator color="#fff" />
|
|
143
|
-
) : (
|
|
144
|
-
<Text style={styles.submitBtnText}>Send feedback</Text>
|
|
145
|
-
)}
|
|
146
|
-
</TouchableOpacity>
|
|
147
|
-
{msg ? <Text style={[styles.msgText, { color: accent }]}>{msg}</Text> : null}
|
|
148
|
-
</BottomSheetView>
|
|
149
|
-
) : (
|
|
150
|
-
<BottomSheetScrollView contentContainerStyle={styles.body}>
|
|
151
|
-
{feedItems.length === 0 ? (
|
|
152
|
-
<View style={styles.empty}>
|
|
153
|
-
<Text style={[styles.emptyTitle, { color: p.textSoft }]}>No ideas yet</Text>
|
|
154
|
-
<Text style={[styles.emptyText, { color: p.textFaint }]}>Be the first to share one.</Text>
|
|
155
|
-
</View>
|
|
156
|
-
) : (
|
|
157
|
-
feedItems.map((f) => (
|
|
158
|
-
<FeedItem key={f.id} item={f} onVote={vote} accent={accent} p={p} />
|
|
159
|
-
))
|
|
160
|
-
)}
|
|
161
|
-
</BottomSheetScrollView>
|
|
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} />
|
|
162
95
|
)}
|
|
163
96
|
</BottomSheet>
|
|
164
97
|
);
|
|
165
98
|
}
|
|
166
99
|
|
|
167
|
-
|
|
168
|
-
|
|
100
|
+
// ─── FeedList ─────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function FeedList({
|
|
103
|
+
p, accent, onSelect, onCreate,
|
|
104
|
+
}: {
|
|
105
|
+
p: Palette; accent: string;
|
|
106
|
+
onSelect: (id: string) => void;
|
|
107
|
+
onCreate: () => void;
|
|
108
|
+
}) {
|
|
109
|
+
const { feedItems, vote } = useUpstep();
|
|
110
|
+
|
|
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>
|
|
123
|
+
|
|
124
|
+
<BottomSheetScrollView contentContainerStyle={styles.listBody}>
|
|
125
|
+
{feedItems.length === 0 ? (
|
|
126
|
+
<EmptyState p={p} message="No feedback yet. Be the first to add one." />
|
|
127
|
+
) : (
|
|
128
|
+
feedItems.map((f) => (
|
|
129
|
+
<FeedCard
|
|
130
|
+
key={f.id}
|
|
131
|
+
item={f}
|
|
132
|
+
p={p}
|
|
133
|
+
accent={accent}
|
|
134
|
+
onPress={() => onSelect(f.id)}
|
|
135
|
+
onVote={() => vote(f.id, "UP")}
|
|
136
|
+
/>
|
|
137
|
+
))
|
|
138
|
+
)}
|
|
139
|
+
</BottomSheetScrollView>
|
|
140
|
+
</>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
169
143
|
|
|
170
|
-
function
|
|
171
|
-
item,
|
|
144
|
+
function FeedCard({
|
|
145
|
+
item, p, accent, onPress, onVote,
|
|
172
146
|
}: {
|
|
173
|
-
item: Feedback;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
p: Palette;
|
|
147
|
+
item: Feedback; p: Palette; accent: string;
|
|
148
|
+
onPress: () => void;
|
|
149
|
+
onVote: () => void;
|
|
177
150
|
}) {
|
|
178
151
|
const voted = item.userVote === "UP";
|
|
152
|
+
const displayTitle = item.title ?? item.content;
|
|
153
|
+
const hasDescription = Boolean(item.title);
|
|
154
|
+
|
|
179
155
|
return (
|
|
180
|
-
<
|
|
156
|
+
<TouchableOpacity
|
|
157
|
+
onPress={onPress}
|
|
158
|
+
activeOpacity={0.85}
|
|
159
|
+
style={[styles.card, { borderColor: p.border, backgroundColor: p.bg }]}
|
|
160
|
+
>
|
|
181
161
|
<TouchableOpacity
|
|
182
|
-
onPress={
|
|
162
|
+
onPress={onVote}
|
|
183
163
|
activeOpacity={0.8}
|
|
164
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 4 }}
|
|
184
165
|
style={[
|
|
185
|
-
styles.
|
|
186
|
-
{ borderColor: voted ? accent : p.border, backgroundColor: voted ?
|
|
166
|
+
styles.voteBox,
|
|
167
|
+
{ borderColor: voted ? accent : p.border, backgroundColor: voted ? accent + "1a" : p.bgSoft },
|
|
187
168
|
]}
|
|
188
169
|
>
|
|
189
170
|
<Text style={[styles.voteArrow, { color: accent }]}>▲</Text>
|
|
190
171
|
<Text style={[styles.voteCount, { color: p.text }]}>{item.upvotes}</Text>
|
|
191
172
|
</TouchableOpacity>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
<
|
|
195
|
-
|
|
196
|
-
|
|
173
|
+
|
|
174
|
+
<View style={styles.cardBody}>
|
|
175
|
+
<Text style={[styles.cardTitle, { color: p.text }]} numberOfLines={2}>
|
|
176
|
+
{displayTitle}
|
|
177
|
+
</Text>
|
|
178
|
+
{hasDescription && (
|
|
179
|
+
<Text style={[styles.cardDescription, { color: p.textFaint }]} numberOfLines={1}>
|
|
180
|
+
{item.content}
|
|
181
|
+
</Text>
|
|
182
|
+
)}
|
|
183
|
+
<View style={styles.cardMeta}>
|
|
184
|
+
<TypeBadge type={item.type} p={p} />
|
|
185
|
+
<StatusDot status={item.status} />
|
|
186
|
+
<Text style={[styles.cardDate, { color: p.textFaint }]}>
|
|
187
|
+
{formatDate(item.createdAt)}
|
|
188
|
+
</Text>
|
|
189
|
+
</View>
|
|
190
|
+
</View>
|
|
191
|
+
|
|
192
|
+
<Text style={[styles.chevron, { color: p.textFaint }]}>›</Text>
|
|
193
|
+
</TouchableOpacity>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── FeedDetail ───────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
function FeedDetail({
|
|
200
|
+
p, accent, feedbackId, onBack,
|
|
201
|
+
}: {
|
|
202
|
+
p: Palette; accent: string; feedbackId: string; onBack: () => void;
|
|
203
|
+
}) {
|
|
204
|
+
const { vote, getItem } = useUpstep();
|
|
205
|
+
const [item, setItem] = useState<FeedbackWithComments | null>(null);
|
|
206
|
+
const [loading, setLoading] = useState(true);
|
|
207
|
+
const [voting, setVoting] = useState(false);
|
|
208
|
+
|
|
209
|
+
React.useEffect(() => {
|
|
210
|
+
let cancelled = false;
|
|
211
|
+
setLoading(true);
|
|
212
|
+
getItem(feedbackId).then((data) => {
|
|
213
|
+
if (!cancelled) { setItem(data); setLoading(false); }
|
|
214
|
+
}).catch(() => {
|
|
215
|
+
if (!cancelled) setLoading(false);
|
|
216
|
+
});
|
|
217
|
+
return () => { cancelled = true; };
|
|
218
|
+
}, [feedbackId, getItem]);
|
|
219
|
+
|
|
220
|
+
async function handleVote() {
|
|
221
|
+
if (!item || voting) return;
|
|
222
|
+
setVoting(true);
|
|
223
|
+
try {
|
|
224
|
+
await vote(item.id, "UP");
|
|
225
|
+
// Refresh
|
|
226
|
+
const updated = await getItem(feedbackId);
|
|
227
|
+
setItem(updated);
|
|
228
|
+
} finally {
|
|
229
|
+
setVoting(false);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<>
|
|
235
|
+
<BottomSheetView style={[styles.header, { borderBottomColor: p.border }]}>
|
|
236
|
+
<TouchableOpacity onPress={onBack} hitSlop={{ top: 8, bottom: 8, left: 8, right: 16 }}>
|
|
237
|
+
<Text style={[styles.backBtn, { color: accent }]}>← Back</Text>
|
|
238
|
+
</TouchableOpacity>
|
|
239
|
+
</BottomSheetView>
|
|
240
|
+
|
|
241
|
+
{loading || !item ? (
|
|
242
|
+
<BottomSheetView style={styles.centered}>
|
|
243
|
+
<ActivityIndicator color={accent} />
|
|
244
|
+
</BottomSheetView>
|
|
245
|
+
) : (
|
|
246
|
+
<BottomSheetScrollView contentContainerStyle={styles.detailBody}>
|
|
247
|
+
{/* Title + badges */}
|
|
248
|
+
{item.title && (
|
|
249
|
+
<Text style={[styles.detailTitle, { color: p.text }]}>{item.title}</Text>
|
|
250
|
+
)}
|
|
251
|
+
<View style={styles.detailBadgeRow}>
|
|
252
|
+
<TypeBadge type={item.type} p={p} />
|
|
253
|
+
<StatusBadge status={item.status} p={p} />
|
|
197
254
|
</View>
|
|
198
|
-
|
|
199
|
-
|
|
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>
|
|
297
|
+
)}
|
|
298
|
+
</>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function CommentBubble({ comment, p, accent }: { comment: Comment; p: Palette; accent: string }) {
|
|
303
|
+
return (
|
|
304
|
+
<View style={[styles.commentBubble, { backgroundColor: p.bgSoft, borderColor: p.border }]}>
|
|
305
|
+
<View style={styles.commentMeta}>
|
|
306
|
+
<View style={[styles.ownerBadge, { backgroundColor: accent + "20" }]}>
|
|
307
|
+
<Text style={[styles.ownerBadgeText, { color: accent }]}>
|
|
308
|
+
{comment.authorName ?? "Team"}
|
|
200
309
|
</Text>
|
|
201
310
|
</View>
|
|
311
|
+
<Text style={[styles.commentDate, { color: p.textFaint }]}>
|
|
312
|
+
{formatDate(comment.createdAt)}
|
|
313
|
+
</Text>
|
|
202
314
|
</View>
|
|
315
|
+
<Text style={[styles.commentContent, { color: p.textSoft }]}>{comment.content}</Text>
|
|
203
316
|
</View>
|
|
204
317
|
);
|
|
205
318
|
}
|
|
206
319
|
|
|
320
|
+
// ─── FeedCreate ───────────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
const TYPES: FeedbackType[] = ["BUG", "FEATURE", "GENERAL"];
|
|
323
|
+
const TYPE_LABELS: Record<FeedbackType, string> = {
|
|
324
|
+
BUG: "Bug report",
|
|
325
|
+
FEATURE: "Feature request",
|
|
326
|
+
GENERAL: "General",
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
function FeedCreate({ p, accent, onBack }: { p: Palette; accent: string; onBack: () => void }) {
|
|
330
|
+
const { submit } = useUpstep();
|
|
331
|
+
const [title, setTitle] = useState("");
|
|
332
|
+
const [description, setDescription] = useState("");
|
|
333
|
+
const [type, setType] = useState<FeedbackType>("GENERAL");
|
|
334
|
+
const [loading, setLoading] = useState(false);
|
|
335
|
+
const [success, setSuccess] = useState(false);
|
|
336
|
+
const [error, setError] = useState("");
|
|
337
|
+
|
|
338
|
+
async function handleSubmit() {
|
|
339
|
+
if (!title.trim() || loading) return;
|
|
340
|
+
setLoading(true);
|
|
341
|
+
setError("");
|
|
342
|
+
try {
|
|
343
|
+
await submit(title.trim(), description.trim() || title.trim(), type);
|
|
344
|
+
setSuccess(true);
|
|
345
|
+
// Return to list after a moment
|
|
346
|
+
setTimeout(onBack, 1400);
|
|
347
|
+
} catch {
|
|
348
|
+
setError("Something went wrong. Please try again.");
|
|
349
|
+
} finally {
|
|
350
|
+
setLoading(false);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
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}
|
|
438
|
+
|
|
439
|
+
<TouchableOpacity
|
|
440
|
+
onPress={handleSubmit}
|
|
441
|
+
disabled={loading || !title.trim()}
|
|
442
|
+
activeOpacity={0.85}
|
|
443
|
+
style={[
|
|
444
|
+
styles.submitBtn,
|
|
445
|
+
{ backgroundColor: accent },
|
|
446
|
+
(loading || !title.trim()) && styles.submitBtnDisabled,
|
|
447
|
+
]}
|
|
448
|
+
>
|
|
449
|
+
{loading ? (
|
|
450
|
+
<ActivityIndicator color="#fff" />
|
|
451
|
+
) : (
|
|
452
|
+
<Text style={styles.submitBtnText}>Submit feedback</Text>
|
|
453
|
+
)}
|
|
454
|
+
</TouchableOpacity>
|
|
455
|
+
</>
|
|
456
|
+
)}
|
|
457
|
+
</BottomSheetScrollView>
|
|
458
|
+
</>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─── Shared sub-components ────────────────────────────────────────────────────
|
|
463
|
+
|
|
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>
|
|
468
|
+
</View>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const TYPE_BG: Record<FeedbackType, string> = {
|
|
473
|
+
BUG: "#fdecec", FEATURE: "#e9f0fd", GENERAL: "",
|
|
474
|
+
};
|
|
475
|
+
const TYPE_FG: Record<FeedbackType, string> = {
|
|
476
|
+
BUG: "#d6453d", FEATURE: "#3b76d6", GENERAL: "",
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
function TypeBadge({ type, p }: { type: FeedbackType; p: Palette }) {
|
|
480
|
+
return (
|
|
481
|
+
<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>
|
|
485
|
+
</View>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
490
|
+
OPEN: "#b45309",
|
|
491
|
+
IN_PROGRESS: "#1d4ed8",
|
|
492
|
+
DONE: "#15803d",
|
|
493
|
+
PENDING: "#ea580c",
|
|
494
|
+
CLOSED: "#6b7280",
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
function StatusDot({ status }: { status: string }) {
|
|
498
|
+
const color = STATUS_COLORS[status] ?? "#9ca3af";
|
|
499
|
+
return <View style={[styles.statusDot, { backgroundColor: color }]} />;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function StatusBadge({ status, p }: { status: string; p: Palette }) {
|
|
503
|
+
const label = status.replace("_", " ");
|
|
504
|
+
return (
|
|
505
|
+
<View style={[styles.statusBadge, { backgroundColor: p.bgSoft, borderColor: p.border }]}>
|
|
506
|
+
<StatusDot status={status} />
|
|
507
|
+
<Text style={[styles.statusBadgeText, { color: p.textSoft }]}>{label}</Text>
|
|
508
|
+
</View>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function formatDate(iso: string) {
|
|
513
|
+
const d = new Date(iso);
|
|
514
|
+
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ─── Styles ───────────────────────────────────────────────────────────────────
|
|
518
|
+
|
|
207
519
|
const styles = StyleSheet.create({
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
520
|
+
// Header
|
|
521
|
+
header: {
|
|
522
|
+
flexDirection: "row",
|
|
523
|
+
alignItems: "center",
|
|
524
|
+
justifyContent: "space-between",
|
|
525
|
+
paddingHorizontal: 20,
|
|
526
|
+
paddingVertical: 14,
|
|
527
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
528
|
+
},
|
|
529
|
+
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
|
+
},
|
|
536
|
+
newBtnText: { color: "#fff", fontSize: 13, fontWeight: "700" },
|
|
537
|
+
|
|
538
|
+
// List
|
|
539
|
+
listBody: { padding: 16, paddingBottom: 40 },
|
|
540
|
+
|
|
541
|
+
// Card
|
|
542
|
+
card: {
|
|
543
|
+
flexDirection: "row",
|
|
544
|
+
alignItems: "center",
|
|
545
|
+
gap: 12,
|
|
546
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
547
|
+
borderRadius: 16,
|
|
548
|
+
padding: 14,
|
|
549
|
+
marginBottom: 10,
|
|
550
|
+
},
|
|
551
|
+
voteBox: {
|
|
552
|
+
alignItems: "center",
|
|
553
|
+
justifyContent: "center",
|
|
554
|
+
width: 44,
|
|
555
|
+
paddingVertical: 8,
|
|
556
|
+
borderRadius: 11,
|
|
557
|
+
borderWidth: 1,
|
|
215
558
|
},
|
|
216
|
-
segmentText: { fontSize: 13, fontWeight: "600" },
|
|
217
|
-
body: { padding: 20 },
|
|
218
|
-
textarea: { borderWidth: 1, borderRadius: 12, padding: 12, fontSize: 14, minHeight: 110, textAlignVertical: "top", lineHeight: 21 },
|
|
219
|
-
typeRow: { flexDirection: "row", gap: 7, marginVertical: 12 },
|
|
220
|
-
typeBtn: { flex: 1, paddingVertical: 9, alignItems: "center", borderRadius: 9, borderWidth: 1 },
|
|
221
|
-
typeBtnText: { fontSize: 12.5, fontWeight: "600" },
|
|
222
|
-
submitBtn: { borderRadius: 11, padding: 13, alignItems: "center", marginTop: 4 },
|
|
223
|
-
disabledBtn: { opacity: 0.5 },
|
|
224
|
-
submitBtnText: { color: "#fff", fontSize: 14, fontWeight: "600" },
|
|
225
|
-
msgText: { textAlign: "center", fontSize: 13, fontWeight: "500", marginTop: 12 },
|
|
226
|
-
empty: { alignItems: "center", paddingVertical: 40 },
|
|
227
|
-
emptyTitle: { fontSize: 15, fontWeight: "600", marginBottom: 4 },
|
|
228
|
-
emptyText: { fontSize: 14 },
|
|
229
|
-
feedItem: { flexDirection: "row", gap: 12, padding: 14, borderWidth: 1, borderRadius: 14, marginBottom: 10 },
|
|
230
|
-
vote: { minWidth: 46, paddingVertical: 7, borderRadius: 11, borderWidth: 1, alignItems: "center", justifyContent: "center" },
|
|
231
559
|
voteArrow: { fontSize: 11, lineHeight: 13 },
|
|
232
|
-
voteCount: { fontSize:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
560
|
+
voteCount: { fontSize: 15, fontWeight: "700", lineHeight: 18 },
|
|
561
|
+
cardBody: { flex: 1 },
|
|
562
|
+
cardTitle: { fontSize: 14, fontWeight: "600", lineHeight: 19, marginBottom: 2 },
|
|
563
|
+
cardDescription: { fontSize: 12.5, lineHeight: 17, marginBottom: 6 },
|
|
564
|
+
cardMeta: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
565
|
+
cardDate: { fontSize: 11 },
|
|
566
|
+
chevron: { fontSize: 22, fontWeight: "300", marginLeft: 4 },
|
|
567
|
+
|
|
568
|
+
// Detail
|
|
569
|
+
detailBody: { padding: 20, paddingBottom: 50 },
|
|
570
|
+
detailTitle: { fontSize: 22, fontWeight: "700", letterSpacing: -0.4, marginBottom: 10, lineHeight: 28 },
|
|
571
|
+
detailBadgeRow: { flexDirection: "row", gap: 8, marginBottom: 16 },
|
|
572
|
+
detailContent: { fontSize: 15, lineHeight: 23, marginBottom: 24 },
|
|
573
|
+
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",
|
|
585
|
+
},
|
|
586
|
+
upvoteArrow: { fontSize: 13, lineHeight: 15 },
|
|
587
|
+
upvoteCount: { fontSize: 18, fontWeight: "700" },
|
|
588
|
+
upvoteLabel: { fontSize: 13, fontWeight: "500" },
|
|
589
|
+
|
|
590
|
+
// Comments
|
|
591
|
+
commentsSection: { marginTop: 28, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 20 },
|
|
592
|
+
commentsLabel: { fontSize: 10, fontWeight: "700", letterSpacing: 0.8, marginBottom: 12 },
|
|
593
|
+
commentBubble: {
|
|
594
|
+
borderRadius: 12,
|
|
595
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
596
|
+
padding: 14,
|
|
597
|
+
marginBottom: 10,
|
|
598
|
+
},
|
|
599
|
+
commentMeta: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 },
|
|
600
|
+
ownerBadge: { borderRadius: 20, paddingHorizontal: 8, paddingVertical: 3 },
|
|
601
|
+
ownerBadgeText: { fontSize: 11, fontWeight: "700" },
|
|
602
|
+
commentDate: { fontSize: 11 },
|
|
603
|
+
commentContent: { fontSize: 14, lineHeight: 21 },
|
|
604
|
+
|
|
605
|
+
// Create
|
|
606
|
+
createBody: { padding: 20, paddingBottom: 50 },
|
|
607
|
+
fieldLabel: { fontSize: 10, fontWeight: "700", letterSpacing: 0.8, marginBottom: 8 },
|
|
608
|
+
typeRow: { flexDirection: "row", gap: 8, marginBottom: 20, flexWrap: "wrap" },
|
|
609
|
+
typeBtn: { paddingVertical: 9, paddingHorizontal: 14, borderRadius: 10, borderWidth: 1 },
|
|
610
|
+
typeBtnText: { fontSize: 13, fontWeight: "600" },
|
|
611
|
+
titleInput: {
|
|
612
|
+
borderWidth: 1, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12,
|
|
613
|
+
fontSize: 15, marginBottom: 20,
|
|
614
|
+
...(Platform.OS === "ios" ? {} : { paddingTop: 12 }),
|
|
615
|
+
},
|
|
616
|
+
descInput: {
|
|
617
|
+
borderWidth: 1, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12,
|
|
618
|
+
fontSize: 14, minHeight: 110, marginBottom: 20,
|
|
619
|
+
...(Platform.OS === "ios" ? {} : { paddingTop: 12 }),
|
|
620
|
+
},
|
|
621
|
+
errorText: { fontSize: 13, marginBottom: 12, fontWeight: "500" },
|
|
622
|
+
submitBtn: { borderRadius: 12, padding: 14, alignItems: "center" },
|
|
623
|
+
submitBtnDisabled: { opacity: 0.5 },
|
|
624
|
+
submitBtnText: { color: "#fff", fontSize: 15, fontWeight: "700" },
|
|
625
|
+
successTitle: { fontSize: 20, fontWeight: "700", textAlign: "center", marginBottom: 6 },
|
|
626
|
+
successSub: { fontSize: 14, textAlign: "center" },
|
|
627
|
+
|
|
628
|
+
// Shared
|
|
629
|
+
empty: { alignItems: "center", paddingVertical: 60 },
|
|
630
|
+
emptyText: { fontSize: 14 },
|
|
631
|
+
centered: { flex: 1, alignItems: "center", justifyContent: "center", paddingVertical: 60 },
|
|
632
|
+
badge: { paddingHorizontal: 7, paddingVertical: 3, borderRadius: 6 },
|
|
633
|
+
badgeText: { fontSize: 10, fontWeight: "700", letterSpacing: 0.2 },
|
|
634
|
+
statusDot: { width: 6, height: 6, borderRadius: 3 },
|
|
635
|
+
statusBadge: {
|
|
636
|
+
flexDirection: "row", alignItems: "center", gap: 5, paddingHorizontal: 8,
|
|
637
|
+
paddingVertical: 3, borderRadius: 6, borderWidth: StyleSheet.hairlineWidth,
|
|
638
|
+
},
|
|
639
|
+
statusBadgeText: { fontSize: 10, fontWeight: "600", textTransform: "capitalize" },
|
|
239
640
|
});
|
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",
|