convex-feedback-ui 0.2.0-beta.2 → 0.2.0-beta.3

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.
Files changed (64) hide show
  1. package/README.md +38 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/native/expo/RoadmapRoutes.d.ts +3 -0
  4. package/dist/native/expo/RoadmapRoutes.d.ts.map +1 -0
  5. package/dist/native/expo/RoadmapRoutes.js +44 -0
  6. package/dist/native/expo/RoadmapRoutes.js.map +1 -0
  7. package/dist/native/expo/RoadmapScreen.d.ts +24 -0
  8. package/dist/native/expo/RoadmapScreen.d.ts.map +1 -0
  9. package/dist/native/expo/RoadmapScreen.js +36 -0
  10. package/dist/native/expo/RoadmapScreen.js.map +1 -0
  11. package/dist/native/expo/RoadmapStackLayout.d.ts +8 -0
  12. package/dist/native/expo/RoadmapStackLayout.d.ts.map +1 -0
  13. package/dist/native/expo/RoadmapStackLayout.js +56 -0
  14. package/dist/native/expo/RoadmapStackLayout.js.map +1 -0
  15. package/dist/native/expo/RoutedRoadmapContext.d.ts +4 -0
  16. package/dist/native/expo/RoutedRoadmapContext.d.ts.map +1 -0
  17. package/dist/native/expo/RoutedRoadmapContext.js +11 -0
  18. package/dist/native/expo/RoutedRoadmapContext.js.map +1 -0
  19. package/dist/native/expo/index.d.ts +4 -1
  20. package/dist/native/expo/index.d.ts.map +1 -1
  21. package/dist/native/expo/index.js +4 -1
  22. package/dist/native/expo/index.js.map +1 -1
  23. package/dist/native/expo/routes.d.ts +17 -1
  24. package/dist/native/expo/routes.d.ts.map +1 -1
  25. package/dist/native/expo/routes.js +46 -0
  26. package/dist/native/expo/routes.js.map +1 -1
  27. package/dist/native/expo/types.d.ts +35 -1
  28. package/dist/native/expo/types.d.ts.map +1 -1
  29. package/dist/native/shared/ui/RoadmapScreen.d.ts +21 -1
  30. package/dist/native/shared/ui/RoadmapScreen.d.ts.map +1 -1
  31. package/dist/native/shared/ui/RoadmapScreen.js +228 -50
  32. package/dist/native/shared/ui/RoadmapScreen.js.map +1 -1
  33. package/dist/native/shared/ui/primitives.d.ts +1 -1
  34. package/dist/native/shared/ui/primitives.d.ts.map +1 -1
  35. package/dist/native/shared/ui/primitives.js +3 -1
  36. package/dist/native/shared/ui/primitives.js.map +1 -1
  37. package/dist/react/roadmap.d.ts.map +1 -1
  38. package/dist/react/roadmap.js +24 -12
  39. package/dist/react/roadmap.js.map +1 -1
  40. package/dist/react/styles.css +123 -0
  41. package/dist/shared/messages.d.ts.map +1 -1
  42. package/dist/shared/messages.js +23 -0
  43. package/dist/shared/messages.js.map +1 -1
  44. package/dist/shared/types/messages.d.ts +26 -1
  45. package/dist/shared/types/messages.d.ts.map +1 -1
  46. package/dist/shared/types/roadmap.d.ts +10 -1
  47. package/dist/shared/types/roadmap.d.ts.map +1 -1
  48. package/package.json +2 -2
  49. package/src/native/expo/README.md +6 -2
  50. package/src/native/expo/RoadmapRoutes.tsx +133 -0
  51. package/src/native/expo/RoadmapScreen.tsx +101 -0
  52. package/src/native/expo/RoadmapStackLayout.tsx +111 -0
  53. package/src/native/expo/RoutedRoadmapContext.tsx +21 -0
  54. package/src/native/expo/index.ts +8 -1
  55. package/src/native/expo/routes.ts +69 -1
  56. package/src/native/expo/types.ts +43 -0
  57. package/src/native/native/README.md +4 -3
  58. package/src/native/shared/ui/RoadmapScreen.tsx +550 -228
  59. package/src/native/shared/ui/primitives.tsx +3 -0
  60. package/src/react/roadmap.tsx +145 -85
  61. package/src/react/styles.css +123 -0
  62. package/src/shared/messages.ts +24 -0
  63. package/src/shared/types/messages.ts +38 -1
  64. package/src/shared/types/roadmap.ts +18 -1
@@ -1,20 +1,88 @@
1
- import type {
2
- FeedbackEntry as FeedbackEntryData,
3
- RoadmapItem,
4
- } from "convex-feedback";
5
- import { useState } from "react";
6
- import { Pressable, ScrollView, Text, View } from "react-native";
1
+ import type { RoadmapItem, RoadmapStatus } from "convex-feedback";
2
+ import { useState, type PropsWithChildren } from "react";
3
+ import {
4
+ ActivityIndicator,
5
+ Pressable,
6
+ ScrollView,
7
+ StyleSheet,
8
+ Text,
9
+ View,
10
+ } from "react-native";
11
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
7
12
 
13
+ import {
14
+ FeedbackBodyProvider,
15
+ useFeedbackBody,
16
+ } from "../../../shared/context/FeedbackBodyProvider.js";
8
17
  import {
9
18
  FeedbackProvider,
10
19
  useFeedbackUi,
11
20
  } from "../../../shared/context/FeedbackProvider.js";
12
- import { allowAuthenticatedAction } from "../../../shared/helpers.js";
21
+ import { kinds } from "../../../shared/helpers.js";
13
22
  import type { RoadmapScreenProps } from "../../../shared/types/index.js";
14
- import { useNativeAction } from "../helpers.js";
23
+ import { collectNativeMetadata } from "../metadata.js";
15
24
  import { Button } from "./Button.js";
16
- import { FeedbackBoard, FeedbackEntry } from "./primitives.js";
25
+ import { EntryCard } from "./EntryCard.js";
26
+ import { EntryDetail } from "./EntryDetail.js";
27
+ import { FeedbackBoard } from "./primitives.js";
28
+
29
+ const stages: readonly { value: RoadmapStatus }[] = [
30
+ { value: "planned" },
31
+ { value: "in_progress" },
32
+ { value: "shipped" },
33
+ ];
34
+
35
+ /**
36
+ * Keeps the roadmap and its attached entry details on the same UI context.
37
+ * Routed Expo layouts reuse this provider so every route sees the same hooks,
38
+ * theme, messages, and comment configuration.
39
+ */
40
+ export function RoadmapProvider({
41
+ hooks,
42
+ messages,
43
+ theme,
44
+ unstyled,
45
+ commentSort = "top",
46
+ maxCommentDepth = 5,
47
+ transformComments,
48
+ renderActor,
49
+ onUnauthenticated,
50
+ children,
51
+ }: PropsWithChildren<
52
+ Pick<
53
+ RoadmapScreenProps,
54
+ | "hooks"
55
+ | "messages"
56
+ | "theme"
57
+ | "unstyled"
58
+ | "commentSort"
59
+ | "maxCommentDepth"
60
+ | "transformComments"
61
+ | "renderActor"
62
+ | "onUnauthenticated"
63
+ >
64
+ >) {
65
+ return (
66
+ <FeedbackProvider messages={messages} theme={theme} unstyled={unstyled}>
67
+ <FeedbackBodyProvider
68
+ hooks={hooks}
69
+ entrySort="top"
70
+ commentSort={commentSort}
71
+ enabledKinds={kinds}
72
+ maxCommentDepth={maxCommentDepth}
73
+ debounceDuration={300}
74
+ collectStandardMetadata={collectNativeMetadata}
75
+ transformComments={transformComments}
76
+ renderActor={renderActor}
77
+ onUnauthenticated={onUnauthenticated}
78
+ >
79
+ {children}
80
+ </FeedbackBodyProvider>
81
+ </FeedbackProvider>
82
+ );
83
+ }
17
84
 
85
+ /** Self-contained public roadmap screen for native and non-routed Expo apps. */
18
86
  export function RoadmapScreen({
19
87
  hooks,
20
88
  messages,
@@ -22,250 +90,231 @@ export function RoadmapScreen({
22
90
  unstyled,
23
91
  onEntryOpen,
24
92
  onUnauthenticated,
25
- pageSize,
26
- entryPageSize,
93
+ commentSort,
94
+ maxCommentDepth,
95
+ transformComments,
96
+ renderActor,
97
+ pageSize = hooks.pageSizes.roadmap,
98
+ entryPageSize = hooks.pageSizes.entries,
27
99
  ...colors
28
100
  }: RoadmapScreenProps) {
29
101
  return (
30
- <FeedbackProvider messages={messages} theme={theme} unstyled={unstyled}>
102
+ <RoadmapProvider
103
+ hooks={hooks}
104
+ messages={messages}
105
+ theme={theme}
106
+ unstyled={unstyled}
107
+ commentSort={commentSort}
108
+ maxCommentDepth={maxCommentDepth}
109
+ transformComments={transformComments}
110
+ renderActor={renderActor}
111
+ onUnauthenticated={onUnauthenticated}
112
+ >
31
113
  <RoadmapScreenInner
32
- hooks={hooks}
114
+ pageSize={pageSize}
115
+ entryPageSize={entryPageSize}
33
116
  onEntryOpen={onEntryOpen}
34
- onUnauthenticated={onUnauthenticated}
35
- pageSize={pageSize ?? hooks.pageSizes.roadmap}
36
- entryPageSize={entryPageSize ?? hooks.pageSizes.entries}
37
117
  {...colors}
38
118
  />
39
- </FeedbackProvider>
119
+ </RoadmapProvider>
40
120
  );
41
121
  }
42
122
 
43
123
  function RoadmapScreenInner({
44
- hooks,
45
- onEntryOpen,
46
- onUnauthenticated,
47
124
  pageSize,
48
125
  entryPageSize,
126
+ onEntryOpen,
49
127
  ...colors
50
- }: RoadmapScreenProps & {
128
+ }: Omit<RoadmapScreenProps, "hooks" | "pageSize" | "entryPageSize"> & {
51
129
  pageSize: number;
52
130
  entryPageSize: number;
53
131
  }) {
132
+ const [selected, setSelected] = useState<RoadmapItem | null>(null);
133
+
134
+ return (
135
+ <FeedbackBoard.Root {...colors}>
136
+ {selected === null ? (
137
+ <RoadmapBoardContent pageSize={pageSize} onItemOpen={setSelected} />
138
+ ) : (
139
+ <RoadmapDetailPage
140
+ item={selected}
141
+ entryPageSize={entryPageSize}
142
+ onEntryOpen={onEntryOpen}
143
+ onBack={() => setSelected(null)}
144
+ />
145
+ )}
146
+ </FeedbackBoard.Root>
147
+ );
148
+ }
149
+
150
+ /** Board content used by both the self-contained and routed Expo screens. */
151
+ export function RoadmapBoardContent({
152
+ pageSize,
153
+ onItemOpen,
154
+ }: {
155
+ pageSize: number;
156
+ onItemOpen: (item: RoadmapItem) => void;
157
+ }) {
158
+ const { hooks } = useFeedbackBody();
54
159
  const { messages, theme } = useFeedbackUi();
160
+ const insets = useSafeAreaInsets();
55
161
  const [query, setQuery] = useState("");
56
- const [selectedId, setSelectedId] = useState<string | null>(null);
57
162
  const roadmap = hooks.useRoadmap();
58
163
  const search = hooks.useSearchRoadmap(query);
59
164
  const searching = query.trim().length > 0;
60
165
  const items = searching ? (search ?? []) : roadmap.results;
61
- const selected = items.find((item) => item.id === selectedId) ?? null;
62
-
63
- if (selected !== null) {
64
- return (
65
- <FeedbackBoard.Root {...colors}>
66
- <ScrollView
67
- contentContainerStyle={{ gap: theme.spacing, padding: theme.spacing }}
68
- >
69
- <RoadmapDetail
70
- item={selected}
71
- hooks={hooks}
72
- entryPageSize={entryPageSize}
73
- onEntryOpen={onEntryOpen}
74
- onUnauthenticated={onUnauthenticated}
75
- onBack={() => setSelectedId(null)}
76
- />
77
- </ScrollView>
78
- </FeedbackBoard.Root>
79
- );
80
- }
81
-
82
166
  const loading = searching
83
167
  ? search === undefined
84
168
  : roadmap.status === "LoadingFirstPage";
85
169
 
86
170
  return (
87
- <FeedbackBoard.Root {...colors}>
171
+ <View
172
+ style={[styles.boardScreen, { backgroundColor: theme.colors.background }]}
173
+ >
88
174
  <FeedbackBoard.Header
89
- style={{ gap: theme.spacing, padding: theme.spacing }}
175
+ style={[
176
+ styles.boardHeader,
177
+ { paddingTop: Math.max(theme.spacing, insets.top + 12) },
178
+ ]}
90
179
  >
91
- <FeedbackBoard.Title>Roadmap</FeedbackBoard.Title>
92
- <Text style={{ color: theme.colors.mutedText }}>
93
- See what is planned, in progress, and shipped.
180
+ <FeedbackBoard.Title>{messages.roadmap.title}</FeedbackBoard.Title>
181
+ <Text style={[styles.subtitle, { color: theme.colors.mutedText }]}>
182
+ {messages.roadmap.subtitle}
94
183
  </Text>
95
184
  <FeedbackBoard.Search
96
185
  value={query}
97
186
  onValueChange={setQuery}
98
- placeholder="Search roadmap"
187
+ placeholder={messages.roadmap.searchPlaceholder}
188
+ accessibilityLabel={messages.roadmap.searchPlaceholder}
99
189
  />
100
190
  </FeedbackBoard.Header>
191
+
101
192
  {loading ? (
102
- <FeedbackBoard.State>
103
- <Text style={{ color: theme.colors.mutedText }}>
104
- {messages.board.loading}
105
- </Text>
106
- </FeedbackBoard.State>
193
+ <RoadmapLoadingBoard />
107
194
  ) : items.length === 0 ? (
108
195
  <FeedbackBoard.State>
109
- <Text style={{ color: theme.colors.mutedText }}>
110
- {searching ? "No roadmap items found." : "No roadmap items yet."}
196
+ <Text style={[styles.stateText, { color: theme.colors.mutedText }]}>
197
+ {searching
198
+ ? messages.roadmap.noSearchResults
199
+ : messages.roadmap.noItems}
111
200
  </Text>
112
201
  </FeedbackBoard.State>
113
202
  ) : (
114
- <FeedbackBoard.List contentContainerStyle={{ padding: theme.spacing }}>
115
- {items.map((item) => (
116
- <RoadmapCard
117
- key={item.id}
118
- item={item}
119
- onOpen={() => setSelectedId(item.id)}
120
- />
121
- ))}
122
- </FeedbackBoard.List>
203
+ <RoadmapColumns items={items} onItemOpen={onItemOpen} />
123
204
  )}
205
+
124
206
  {!searching &&
125
207
  (roadmap.status === "CanLoadMore" ||
126
208
  roadmap.status === "LoadingMore") && (
127
- <Button
128
- label={messages.board.loadMore}
129
- disabled={roadmap.status === "LoadingMore"}
130
- onPress={() => roadmap.loadMore(pageSize)}
131
- />
209
+ <View
210
+ style={{
211
+ alignItems: "center",
212
+ paddingBottom: insets.bottom + theme.spacing,
213
+ }}
214
+ >
215
+ <Button
216
+ label={messages.board.loadMore}
217
+ disabled={roadmap.status === "LoadingMore"}
218
+ onPress={() => roadmap.loadMore(pageSize)}
219
+ />
220
+ </View>
132
221
  )}
133
- </FeedbackBoard.Root>
134
- );
135
- }
136
-
137
- function RoadmapCard({
138
- item,
139
- onOpen,
140
- }: {
141
- item: RoadmapItem;
142
- onOpen: () => void;
143
- }) {
144
- const { theme } = useFeedbackUi();
145
- return (
146
- <Pressable onPress={onOpen} style={{ gap: 5, padding: 12 }}>
147
- <View
148
- style={{
149
- flexDirection: "row",
150
- justifyContent: "space-between",
151
- gap: 8,
152
- }}
153
- >
154
- <Text
155
- style={{
156
- color: theme.colors.mutedText,
157
- fontSize: 12,
158
- fontWeight: "600",
159
- }}
160
- >
161
- {item.status.replaceAll("_", " ")}
162
- </Text>
163
- <Text style={{ color: theme.colors.mutedText, fontSize: 12 }}>
164
- {item.feedbackCount} linked
165
- </Text>
166
- </View>
167
- <Text
168
- style={{ color: theme.colors.text, fontSize: 16, fontWeight: "700" }}
169
- >
170
- {item.title}
171
- </Text>
172
- {item.description && (
173
- <Text style={{ color: theme.colors.mutedText, lineHeight: 20 }}>
174
- {item.description}
175
- </Text>
176
- )}
177
- </Pressable>
222
+ </View>
178
223
  );
179
224
  }
180
225
 
181
- function RoadmapDetail({
226
+ /** Detail content used by routed roadmap item pages and in-screen navigation. */
227
+ export function RoadmapItemContent({
182
228
  item,
183
- hooks,
184
229
  entryPageSize,
185
230
  onEntryOpen,
186
- onUnauthenticated,
187
- onBack,
188
231
  }: {
189
232
  item: RoadmapItem;
190
- hooks: RoadmapScreenProps["hooks"];
191
233
  entryPageSize: number;
192
- onEntryOpen: RoadmapScreenProps["onEntryOpen"];
193
- onUnauthenticated: RoadmapScreenProps["onUnauthenticated"];
194
- onBack: () => void;
234
+ onEntryOpen?: (entryId: string) => void;
195
235
  }) {
236
+ const { hooks } = useFeedbackBody();
196
237
  const { messages, theme } = useFeedbackUi();
197
238
  const feedback = hooks.useRoadmapFeedback(item.id);
239
+ const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
240
+
241
+ if (selectedEntryId !== null) {
242
+ return (
243
+ <View style={styles.entryDetail}>
244
+ <Button
245
+ label={messages.entry.back}
246
+ onPress={() => setSelectedEntryId(null)}
247
+ />
248
+ <EntryDetail
249
+ entryId={selectedEntryId}
250
+ hideBackButton
251
+ onBack={() => setSelectedEntryId(null)}
252
+ />
253
+ </View>
254
+ );
255
+ }
256
+
257
+ const openEntry = (entryId: string) => {
258
+ if (onEntryOpen === undefined) {
259
+ setSelectedEntryId(entryId);
260
+ } else {
261
+ onEntryOpen(entryId);
262
+ }
263
+ };
264
+
198
265
  return (
199
- <View style={{ gap: theme.spacing }}>
200
- <Button label={messages.entry.back} onPress={onBack} />
201
- <View style={{ gap: 6 }}>
202
- <Text
203
- style={{
204
- color: theme.colors.mutedText,
205
- fontSize: 12,
206
- fontWeight: "600",
207
- }}
208
- >
209
- {item.status.replaceAll("_", " ")} · Position {item.position}
210
- </Text>
211
- <Text
212
- style={{ color: theme.colors.text, fontSize: 23, fontWeight: "700" }}
213
- >
266
+ <View style={styles.detailContent}>
267
+ <View style={styles.detailIntro}>
268
+ <View style={styles.detailMetaRow}>
269
+ <RoadmapStatusBadge status={item.status} />
270
+ <Text style={[styles.detailCount, { color: theme.colors.mutedText }]}>
271
+ {messages.roadmap.linkedEntries(item.feedbackCount)}
272
+ </Text>
273
+ </View>
274
+ <Text style={[styles.detailTitle, { color: theme.colors.text }]}>
214
275
  {item.title}
215
276
  </Text>
216
- <Text style={{ color: theme.colors.mutedText, lineHeight: 21 }}>
217
- {item.description ?? "No description."}
218
- </Text>
219
- </View>
220
- <View style={{ gap: 4 }}>
221
- <RoadmapField label="ID" value={item.id} />
222
- <RoadmapField
223
- label="Linked feedback"
224
- value={String(item.feedbackCount)}
225
- />
226
- <RoadmapField
227
- label="Created"
228
- value={new Date(item.createdAt).toLocaleString()}
229
- />
230
- <RoadmapField
231
- label="Updated"
232
- value={new Date(item.updatedAt).toLocaleString()}
233
- />
234
- <RoadmapField
235
- label="Document created"
236
- value={new Date(item.creationTime).toLocaleString()}
237
- />
277
+ {item.description ? (
278
+ <Text
279
+ style={[
280
+ styles.detailDescription,
281
+ { color: theme.colors.mutedText },
282
+ ]}
283
+ >
284
+ {item.description}
285
+ </Text>
286
+ ) : null}
238
287
  </View>
239
- <Text
240
- style={{ color: theme.colors.text, fontSize: 18, fontWeight: "700" }}
241
- >
242
- Attached feedback
288
+
289
+ <Text style={[styles.sectionTitle, { color: theme.colors.text }]}>
290
+ {messages.roadmap.attachedFeedback}
243
291
  </Text>
292
+
244
293
  {feedback.status === "LoadingFirstPage" ? (
245
- <Text style={{ color: theme.colors.mutedText }}>
246
- {messages.board.loading}
247
- </Text>
294
+ <View style={styles.loadingInline}>
295
+ <ActivityIndicator color={theme.colors.primary} />
296
+ </View>
248
297
  ) : feedback.results.length === 0 ? (
249
- <Text style={{ color: theme.colors.mutedText }}>
250
- No feedback attached.
298
+ <Text style={[styles.emptyText, { color: theme.colors.mutedText }]}>
299
+ {messages.roadmap.noAttachedFeedback}
251
300
  </Text>
252
301
  ) : (
253
- <View style={{ gap: 8 }}>
302
+ <View style={styles.entryList}>
254
303
  {feedback.results.map((entry) => (
255
- <RoadmapEntry
304
+ <EntryCard
256
305
  key={entry.id}
257
306
  entry={entry}
258
307
  hooks={hooks}
259
- onOpen={onEntryOpen}
260
- onUnauthenticated={onUnauthenticated}
308
+ onOpen={() => openEntry(entry.id)}
261
309
  />
262
310
  ))}
263
311
  </View>
264
312
  )}
313
+
265
314
  {(feedback.status === "CanLoadMore" ||
266
315
  feedback.status === "LoadingMore") && (
267
316
  <Button
268
- label={messages.comments.loadMore}
317
+ label={messages.board.loadMore}
269
318
  disabled={feedback.status === "LoadingMore"}
270
319
  onPress={() => feedback.loadMore(entryPageSize)}
271
320
  />
@@ -274,72 +323,345 @@ function RoadmapDetail({
274
323
  );
275
324
  }
276
325
 
277
- function RoadmapField({ label, value }: { label: string; value: string }) {
278
- const { theme } = useFeedbackUi();
326
+ function RoadmapDetailPage({
327
+ item,
328
+ entryPageSize,
329
+ onEntryOpen,
330
+ onBack,
331
+ }: {
332
+ item: RoadmapItem;
333
+ entryPageSize: number;
334
+ onEntryOpen?: (entryId: string) => void;
335
+ onBack: () => void;
336
+ }) {
337
+ const { theme, messages } = useFeedbackUi();
338
+ const insets = useSafeAreaInsets();
339
+
279
340
  return (
280
- <View style={{ flexDirection: "row", gap: 8 }}>
281
- <Text style={{ color: theme.colors.mutedText, fontWeight: "600" }}>
282
- {label}:
283
- </Text>
284
- <Text style={{ flex: 1, color: theme.colors.text }}>{value}</Text>
341
+ <View
342
+ style={[
343
+ styles.detailScreen,
344
+ { backgroundColor: theme.colors.background },
345
+ ]}
346
+ >
347
+ <View
348
+ style={[
349
+ styles.detailToolbar,
350
+ { paddingTop: Math.max(theme.spacing, insets.top + 8) },
351
+ ]}
352
+ >
353
+ <Button label={messages.entry.back} onPress={onBack} />
354
+ </View>
355
+ <FeedbackBoard.List
356
+ style={{
357
+ paddingHorizontal: theme.spacing,
358
+ paddingBottom: insets.bottom + theme.spacing * 2,
359
+ }}
360
+ >
361
+ <RoadmapItemContent
362
+ item={item}
363
+ entryPageSize={entryPageSize}
364
+ onEntryOpen={onEntryOpen}
365
+ />
366
+ </FeedbackBoard.List>
285
367
  </View>
286
368
  );
287
369
  }
288
370
 
289
- function RoadmapEntry({
290
- entry,
291
- hooks,
292
- onOpen,
293
- onUnauthenticated,
371
+ function RoadmapColumns({
372
+ items,
373
+ onItemOpen,
294
374
  }: {
295
- entry: FeedbackEntryData;
296
- hooks: RoadmapScreenProps["hooks"];
297
- onOpen: RoadmapScreenProps["onEntryOpen"];
298
- onUnauthenticated: RoadmapScreenProps["onUnauthenticated"];
375
+ items: readonly RoadmapItem[];
376
+ onItemOpen: (item: RoadmapItem) => void;
299
377
  }) {
300
- const { isAuthenticated } = useFeedbackAuth(hooks);
301
- const setUpvote = hooks.useSetEntryUpvote();
302
- const action = useNativeAction();
303
- const toggle = (desiredState: boolean) => {
304
- if (
305
- !allowAuthenticatedAction(isAuthenticated, onUnauthenticated) ||
306
- action.pending
307
- ) {
308
- return;
309
- }
310
- void action.run(
311
- () => setUpvote({ entryId: entry.id, desiredState }),
312
- "Could not update vote",
313
- );
314
- };
378
+ const { messages, theme } = useFeedbackUi();
379
+ const insets = useSafeAreaInsets();
315
380
 
316
381
  return (
317
- <FeedbackEntry.Root entry={entry}>
318
- <FeedbackEntry.Upvote
319
- disabled={isAuthenticated === undefined || action.pending}
320
- onToggle={toggle}
321
- />
322
- <Pressable
323
- onPress={onOpen === undefined ? undefined : () => onOpen(entry.id)}
324
- accessibilityRole={onOpen === undefined ? undefined : "button"}
325
- style={{ flex: 1, minWidth: 0 }}
326
- >
327
- <FeedbackEntry.Content>
328
- <View style={{ gap: 5 }}>
329
- <View style={{ flexDirection: "row", gap: 8 }}>
330
- <FeedbackEntry.Kind />
331
- <FeedbackEntry.Status />
382
+ <ScrollView
383
+ horizontal
384
+ style={styles.columnsScroll}
385
+ contentInsetAdjustmentBehavior="automatic"
386
+ showsHorizontalScrollIndicator={false}
387
+ contentContainerStyle={[
388
+ styles.columnsContent,
389
+ {
390
+ paddingLeft: theme.spacing + insets.left,
391
+ paddingRight: theme.spacing + insets.right,
392
+ paddingBottom: insets.bottom + theme.spacing * 2,
393
+ },
394
+ ]}
395
+ >
396
+ {stages.map((stage) => {
397
+ const stageItems = items
398
+ .filter((item) => item.status === stage.value)
399
+ .sort((a, b) => a.position - b.position);
400
+
401
+ return (
402
+ <View
403
+ key={stage.value}
404
+ style={[
405
+ styles.column,
406
+ {
407
+ borderColor: theme.colors.border,
408
+ backgroundColor: theme.colors.surfaceMuted,
409
+ },
410
+ ]}
411
+ >
412
+ <View
413
+ style={[
414
+ styles.columnHeader,
415
+ { borderColor: theme.colors.border },
416
+ ]}
417
+ >
418
+ <View style={styles.columnHeading}>
419
+ <RoadmapStatusBadge status={stage.value} />
420
+ <Text
421
+ style={[styles.columnTitle, { color: theme.colors.text }]}
422
+ >
423
+ {messages.roadmap.statuses[stage.value]}
424
+ </Text>
425
+ </View>
426
+ <Text
427
+ style={[styles.columnCount, { color: theme.colors.mutedText }]}
428
+ >
429
+ {stageItems.length}
430
+ </Text>
332
431
  </View>
333
- <FeedbackEntry.Title />
334
- <FeedbackEntry.Body />
335
- <FeedbackEntry.CommentCount />
432
+ <ScrollView
433
+ style={styles.columnList}
434
+ nestedScrollEnabled
435
+ contentInsetAdjustmentBehavior="never"
436
+ showsVerticalScrollIndicator={false}
437
+ contentContainerStyle={styles.columnItems}
438
+ >
439
+ {stageItems.length === 0 ? (
440
+ <Text
441
+ style={[
442
+ styles.emptyColumn,
443
+ { color: theme.colors.mutedText },
444
+ ]}
445
+ >
446
+ {messages.roadmap.noItems}
447
+ </Text>
448
+ ) : (
449
+ stageItems.map((item) => (
450
+ <RoadmapCard
451
+ key={item.id}
452
+ item={item}
453
+ onOpen={() => onItemOpen(item)}
454
+ />
455
+ ))
456
+ )}
457
+ </ScrollView>
458
+ </View>
459
+ );
460
+ })}
461
+ </ScrollView>
462
+ );
463
+ }
464
+
465
+ function RoadmapLoadingBoard() {
466
+ const { messages, theme } = useFeedbackUi();
467
+ const insets = useSafeAreaInsets();
468
+
469
+ return (
470
+ <ScrollView
471
+ horizontal
472
+ style={styles.columnsScroll}
473
+ contentInsetAdjustmentBehavior="automatic"
474
+ showsHorizontalScrollIndicator={false}
475
+ contentContainerStyle={[
476
+ styles.columnsContent,
477
+ {
478
+ paddingLeft: theme.spacing + insets.left,
479
+ paddingRight: theme.spacing + insets.right,
480
+ paddingBottom: insets.bottom + theme.spacing * 2,
481
+ },
482
+ ]}
483
+ >
484
+ {stages.map((stage) => (
485
+ <View
486
+ key={stage.value}
487
+ style={[
488
+ styles.column,
489
+ {
490
+ borderColor: theme.colors.border,
491
+ backgroundColor: theme.colors.surfaceMuted,
492
+ },
493
+ ]}
494
+ >
495
+ <View
496
+ style={[styles.columnHeader, { borderColor: theme.colors.border }]}
497
+ >
498
+ <Text style={[styles.columnTitle, { color: theme.colors.text }]}>
499
+ {messages.roadmap.statuses[stage.value]}
500
+ </Text>
501
+ </View>
502
+ <View style={styles.loadingColumn}>
503
+ <ActivityIndicator color={theme.colors.primary} />
336
504
  </View>
337
- </FeedbackEntry.Content>
338
- </Pressable>
339
- </FeedbackEntry.Root>
505
+ </View>
506
+ ))}
507
+ </ScrollView>
508
+ );
509
+ }
510
+
511
+ function RoadmapCard({
512
+ item,
513
+ onOpen,
514
+ }: {
515
+ item: RoadmapItem;
516
+ onOpen: () => void;
517
+ }) {
518
+ const { messages, theme } = useFeedbackUi();
519
+
520
+ return (
521
+ <Pressable
522
+ accessibilityRole="button"
523
+ accessibilityLabel={item.title}
524
+ onPress={onOpen}
525
+ style={({ pressed }) => [
526
+ styles.card,
527
+ {
528
+ borderColor: theme.colors.border,
529
+ backgroundColor: theme.colors.surface,
530
+ },
531
+ pressed && styles.pressed,
532
+ ]}
533
+ >
534
+ <View style={styles.cardMeta}>
535
+ <RoadmapStatusBadge status={item.status} />
536
+ <Text style={[styles.cardCount, { color: theme.colors.mutedText }]}>
537
+ {messages.roadmap.linkedEntries(item.feedbackCount)}
538
+ </Text>
539
+ </View>
540
+ <Text style={[styles.cardTitle, { color: theme.colors.text }]}>
541
+ {item.title}
542
+ </Text>
543
+ {item.description ? (
544
+ <Text
545
+ style={[styles.cardDescription, { color: theme.colors.mutedText }]}
546
+ numberOfLines={3}
547
+ >
548
+ {item.description}
549
+ </Text>
550
+ ) : null}
551
+ <Text style={[styles.cardLink, { color: theme.colors.primary }]}>
552
+ {messages.entry.open} ›
553
+ </Text>
554
+ </Pressable>
340
555
  );
341
556
  }
342
557
 
343
- function useFeedbackAuth(hooks: RoadmapScreenProps["hooks"]) {
344
- return { isAuthenticated: hooks.useIsAuthenticated() };
558
+ function RoadmapStatusBadge({ status }: { status: RoadmapStatus }) {
559
+ const { messages, theme } = useFeedbackUi();
560
+
561
+ return (
562
+ <View
563
+ style={[
564
+ styles.statusBadge,
565
+ {
566
+ borderColor: theme.colors.border,
567
+ backgroundColor: theme.colors.surfaceMuted,
568
+ },
569
+ ]}
570
+ >
571
+ <View
572
+ style={[styles.statusDot, { backgroundColor: theme.colors.primary }]}
573
+ />
574
+ <Text style={[styles.statusText, { color: theme.colors.mutedText }]}>
575
+ {messages.roadmap.statuses[status]}
576
+ </Text>
577
+ </View>
578
+ );
345
579
  }
580
+
581
+ const styles = StyleSheet.create({
582
+ boardScreen: { flex: 1, minHeight: 0 },
583
+ boardHeader: {
584
+ gap: 10,
585
+ paddingHorizontal: 18,
586
+ paddingBottom: 16,
587
+ },
588
+ subtitle: { fontSize: 14, lineHeight: 20 },
589
+ stateText: { textAlign: "center" },
590
+ columnsScroll: { flex: 1 },
591
+ columnsContent: { gap: 12, alignItems: "stretch", paddingTop: 4 },
592
+ column: {
593
+ width: 306,
594
+ minHeight: 400,
595
+ borderWidth: StyleSheet.hairlineWidth,
596
+ borderRadius: 18,
597
+ overflow: "hidden",
598
+ },
599
+ columnHeader: {
600
+ minHeight: 58,
601
+ flexDirection: "row",
602
+ alignItems: "center",
603
+ justifyContent: "space-between",
604
+ borderBottomWidth: StyleSheet.hairlineWidth,
605
+ paddingHorizontal: 14,
606
+ },
607
+ columnHeading: { flexDirection: "row", alignItems: "center", gap: 8 },
608
+ columnTitle: { fontSize: 14, fontWeight: "700" },
609
+ columnCount: { fontSize: 12, fontWeight: "700" },
610
+ columnItems: { gap: 10, padding: 10 },
611
+ columnList: { flex: 1 },
612
+ emptyColumn: {
613
+ fontSize: 13,
614
+ lineHeight: 20,
615
+ paddingHorizontal: 8,
616
+ paddingVertical: 26,
617
+ textAlign: "center",
618
+ },
619
+ loadingColumn: { flex: 1, alignItems: "center", justifyContent: "center" },
620
+ card: {
621
+ gap: 8,
622
+ borderWidth: StyleSheet.hairlineWidth,
623
+ borderRadius: 14,
624
+ padding: 14,
625
+ },
626
+ pressed: { opacity: 0.7 },
627
+ cardMeta: {
628
+ flexDirection: "row",
629
+ alignItems: "center",
630
+ justifyContent: "space-between",
631
+ gap: 8,
632
+ },
633
+ cardCount: { fontSize: 11, fontWeight: "600" },
634
+ cardTitle: { fontSize: 16, fontWeight: "700", lineHeight: 21 },
635
+ cardDescription: { fontSize: 13, lineHeight: 19 },
636
+ cardLink: { fontSize: 13, fontWeight: "700" },
637
+ statusBadge: {
638
+ alignSelf: "flex-start",
639
+ flexDirection: "row",
640
+ alignItems: "center",
641
+ gap: 5,
642
+ borderWidth: StyleSheet.hairlineWidth,
643
+ borderRadius: 999,
644
+ paddingHorizontal: 7,
645
+ paddingVertical: 4,
646
+ },
647
+ statusDot: { width: 6, height: 6, borderRadius: 3 },
648
+ statusText: { fontSize: 11, fontWeight: "700" },
649
+ detailScreen: { flex: 1, minHeight: 0 },
650
+ detailToolbar: { paddingHorizontal: 18, paddingBottom: 10 },
651
+ detailContent: { gap: 18 },
652
+ detailIntro: { gap: 10 },
653
+ detailMetaRow: {
654
+ flexDirection: "row",
655
+ alignItems: "center",
656
+ justifyContent: "space-between",
657
+ gap: 10,
658
+ },
659
+ detailCount: { fontSize: 12, fontWeight: "600" },
660
+ detailTitle: { fontSize: 26, fontWeight: "800", lineHeight: 31 },
661
+ detailDescription: { fontSize: 15, lineHeight: 23 },
662
+ sectionTitle: { fontSize: 18, fontWeight: "800" },
663
+ loadingInline: { paddingVertical: 22 },
664
+ emptyText: { lineHeight: 20 },
665
+ entryList: { gap: 10 },
666
+ entryDetail: { gap: 16 },
667
+ });